From ca2f813810de9f03ba8b7d6dfb08cf742bffcac8 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:54:50 +0800 Subject: [PATCH] test(vuetify): cover responsive input adapters (#698) --- .../vuetify/__tests__/AppInput.spec.ts | 305 ++++++++++++++++++ vite.config.ts | 7 + 2 files changed, 312 insertions(+) create mode 100644 src/plugins/vuetify/__tests__/AppInput.spec.ts diff --git a/src/plugins/vuetify/__tests__/AppInput.spec.ts b/src/plugins/vuetify/__tests__/AppInput.spec.ts new file mode 100644 index 00000000..9266d823 --- /dev/null +++ b/src/plugins/vuetify/__tests__/AppInput.spec.ts @@ -0,0 +1,305 @@ +import { mount } from '@vue/test-utils' +import { createResponsiveInputAdapter } from '@/plugins/vuetify/AppInput' +import { defineComponent, h, type VNode } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type ResponsiveInputKind = 'choice' | 'field' | 'group' | 'multiline' | 'range' + +type ExposedInput = { + blur: () => unknown + focus: () => unknown + reset: () => unknown + resetValidation: () => unknown + validate: (silent?: boolean) => unknown +} + +const mocks = vi.hoisted(() => ({ + inputAttrs: {} as Record, + isMobile: { value: false }, + methods: { + blur: vi.fn(() => 'blur-result'), + focus: vi.fn(() => 'focus-result'), + reset: vi.fn(() => 'reset-result'), + resetValidation: vi.fn(() => 'reset-validation-result'), + validate: vi.fn((silent?: boolean) => (silent ? 'silent-validation-result' : 'validation-result')), + }, +})) + +vi.mock('vuetify', () => ({ + useDisplay: () => ({ smAndDown: mocks.isMobile }), +})) + +const InputStub = defineComponent({ + name: 'InputStub', + inheritAttrs: false, + setup(_, { attrs, expose, slots }) { + expose(mocks.methods) + + return () => { + mocks.inputAttrs = { ...attrs } + + return h('div', { class: 'input-stub' }, [ + h('input', { ...attrs }), + slots.default?.(), + slots['append-inner']?.(), + slots.label?.(), + ]) + } + }, +}) + +type TestSlot = (slotProps?: unknown) => VNode + +function mountAdapter( + options: { + kind?: ResponsiveInputKind + props?: Record + attrs?: Record + slots?: Record + } = {}, +) { + const kind = options.kind ?? 'field' + const adapter = createResponsiveInputAdapter(InputStub, { kind, name: `${kind}Input` }) + + return mount(adapter, { + attrs: options.attrs, + props: options.props, + slots: options.slots, + }) +} + +function resetStub() { + mocks.inputAttrs = {} + mocks.methods.blur.mockClear() + mocks.methods.focus.mockClear() + mocks.methods.reset.mockClear() + mocks.methods.resetValidation.mockClear() + mocks.methods.validate.mockClear() +} + +describe('createResponsiveInputAdapter', () => { + beforeEach(() => { + mocks.isMobile.value = false + resetStub() + }) + + it('keeps desktop rendering, attributes, and slots on the original component', () => { + const wrapper = mountAdapter({ + attrs: { + class: 'desktop-class', + id: 'desktop-id', + label: 'Desktop label', + modelValue: 'value', + style: { color: 'red' }, + }, + slots: { + default: () => h('span', { class: 'default-slot' }, 'default content'), + label: () => h('span', { class: 'label-slot' }, 'label content'), + }, + }) + + expect(wrapper.find('.app-responsive-input').exists()).toBe(false) + expect(wrapper.find('.default-slot').text()).toBe('default content') + expect(wrapper.find('.label-slot').text()).toBe('label content') + expect(mocks.inputAttrs).toMatchObject({ + class: 'desktop-class', + id: 'desktop-id', + label: 'Desktop label', + modelValue: 'value', + }) + }) + + it.each([ + ['mobileLayout=false', { label: 'Name' }, { mobileLayout: false }], + ['without a label', { hint: 'Hint' }, {}], + ])('falls back to the original component when %s', (_, attrs, props) => { + mocks.isMobile.value = true + const wrapper = mountAdapter({ attrs, props }) + + expect(wrapper.find('.app-responsive-input').exists()).toBe(false) + expect(wrapper.find('.input-stub').exists()).toBe(true) + expect(mocks.inputAttrs).toMatchObject(attrs) + }) + + it.each([ + ['field', true], + ['multiline', true], + ['choice', false], + ['range', false], + ['group', false], + ] as const)('applies the %s mobile control contract', (kind, isTextInput) => { + mocks.isMobile.value = true + const wrapper = mountAdapter({ kind, attrs: { label: 'Title', modelValue: 'value' } }) + + expect(wrapper.find('.app-responsive-input').classes()).toContain(`app-responsive-input--${kind}`) + expect(mocks.inputAttrs).toMatchObject({ + 'aria-label': 'Title', + density: 'compact', + id: expect.any(String), + label: undefined, + hint: undefined, + persistentHint: false, + }) + + if (isTextInput) { + expect(mocks.inputAttrs).toMatchObject({ + placeholder: '-', + singleLine: true, + variant: 'plain', + }) + } else { + expect(mocks.inputAttrs).not.toHaveProperty('placeholder') + expect(mocks.inputAttrs).not.toHaveProperty('singleLine') + expect(mocks.inputAttrs).not.toHaveProperty('variant') + } + }) + + it('renders a label slot, hint, generated ids, and merged descriptions on mobile', () => { + mocks.isMobile.value = true + let receivedLabelProps: unknown + const wrapper = mountAdapter({ + attrs: { + 'aria-describedby': 'existing custom-hint', + hint: 'Helpful hint', + label: 'Name', + }, + slots: { + label: props => { + receivedLabelProps = props + return h('span', { class: 'custom-label' }, 'custom label') + }, + }, + }) + const controlId = String(mocks.inputAttrs.id) + + expect(controlId).toMatch(/^app-responsive-input-\d+$/) + expect(wrapper.get('label').attributes('for')).toBe(controlId) + expect(wrapper.get('.custom-label').text()).toBe('custom label') + expect(wrapper.get('.app-responsive-input__hint').attributes('id')).toBe(`${controlId}-hint`) + expect(wrapper.get('.app-responsive-input__hint').text()).toBe('Helpful hint') + expect(mocks.inputAttrs['aria-describedby']).toBe(`existing custom-hint ${controlId}-hint ${controlId}-messages`) + expect(receivedLabelProps).toEqual({ label: 'Name', props: { for: controlId } }) + }) + + it('preserves an explicit id and aria-label while forwarding control slots', () => { + mocks.isMobile.value = true + const wrapper = mountAdapter({ + attrs: { + 'aria-label': 'Accessible name', + id: 'custom-id', + label: 'Visible label', + }, + slots: { + 'append-inner': () => h('span', { class: 'append-inner-slot' }, 'append action'), + default: () => h('span', { class: 'default-slot' }, 'control content'), + }, + }) + + expect(wrapper.get('label').attributes('for')).toBe('custom-id') + expect(mocks.inputAttrs).toMatchObject({ + 'aria-label': 'Accessible name', + id: 'custom-id', + }) + expect(wrapper.get('.default-slot').text()).toBe('control content') + expect(wrapper.get('.app-responsive-input__control .append-inner-slot').text()).toBe('append action') + }) + + it.each([ + ['undefined', {}, true], + ['true', { hideDetails: true }, false], + ['empty string', { hideDetails: '' }, false], + ['true string', { 'hide-details': 'true' }, false], + ['false string', { 'hide-details': 'false' }, true], + ])('uses hide-details semantics for %s', (_, attrs, showHint) => { + mocks.isMobile.value = true + const wrapper = mountAdapter({ attrs: { hint: 'Hint', label: 'Name', ...attrs } }) + + expect(wrapper.find('.app-responsive-input__hint').exists()).toBe(showHint) + if (showHint) { + expect(mocks.inputAttrs['aria-describedby']).toEqual(expect.any(String)) + } else { + expect(mocks.inputAttrs['aria-describedby']).toBeUndefined() + } + }) + + it.each([ + ['empty field', 'field', {}, true, '-'], + ['numeric model value', 'field', { modelValue: 0 }, false, '-'], + ['explicit placeholder', 'field', { placeholder: 'Enter value' }, false, 'Enter value'], + ['choice control', 'choice', {}, false, undefined], + ] as Array<[string, ResponsiveInputKind, Record, boolean, string | undefined]>)( + 'handles %s without losing the model contract', + (_, kind, attrs, isEmpty, placeholder) => { + mocks.isMobile.value = true + const wrapper = mountAdapter({ kind, attrs: { label: 'Name', ...attrs } }) + + expect(wrapper.find('.app-responsive-input').classes()).toContain( + kind === 'field' && isEmpty ? 'app-responsive-input--empty' : `app-responsive-input--${kind}`, + ) + if (Object.hasOwn(attrs, 'modelValue')) { + expect(mocks.inputAttrs.modelValue).toBe(attrs.modelValue) + } else { + expect(mocks.inputAttrs).not.toHaveProperty('modelValue') + } + expect(mocks.inputAttrs.placeholder).toBe(placeholder) + }, + ) + + it('keeps root class and style on the mobile shell while giving the native control its own class', () => { + mocks.isMobile.value = true + const wrapper = mountAdapter({ + attrs: { + class: 'outer-class', + disabled: 'true', + label: 'Name', + style: { color: 'red' }, + }, + props: { mobileControlWidth: 40 }, + }) + const root = wrapper.get('.app-responsive-input') + + expect(root.classes()).toEqual(expect.arrayContaining(['app-responsive-input--disabled', 'outer-class'])) + const rootElement = root.element as HTMLElement + + expect(rootElement.style.color).toBe('red') + expect(rootElement.style.getPropertyValue('--app-responsive-input-control-width')).toBe('40%') + expect(mocks.inputAttrs.class).toBe('app-responsive-input__native') + expect(mocks.inputAttrs.style).toBeUndefined() + }) + + it.each([ + [50, '50%'], + [-10, '0%'], + [120, '100%'], + [' 42vw ', '42vw'], + [' ', undefined], + [Number.POSITIVE_INFINITY, undefined], + ] as const)('normalizes mobileControlWidth %s', (width, expected) => { + mocks.isMobile.value = true + const wrapper = mountAdapter({ + attrs: { label: 'Name' }, + props: { mobileControlWidth: width }, + }) + const rootElement = wrapper.get('.app-responsive-input').element as HTMLElement + const actual = rootElement.style.getPropertyValue('--app-responsive-input-control-width') + + expect(actual || undefined).toBe(expected) + }) + + it('forwards all public control methods and their return values', () => { + mocks.isMobile.value = true + const wrapper = mountAdapter({ attrs: { label: 'Name' } }) + const exposed = wrapper.vm as unknown as ExposedInput + + expect(exposed.focus()).toBe('focus-result') + expect(exposed.blur()).toBe('blur-result') + expect(exposed.validate(true)).toBe('silent-validation-result') + expect(exposed.reset()).toBe('reset-result') + expect(exposed.resetValidation()).toBe('reset-validation-result') + expect(mocks.methods.focus).toHaveBeenCalledOnce() + expect(mocks.methods.blur).toHaveBeenCalledOnce() + expect(mocks.methods.validate).toHaveBeenCalledWith(true) + expect(mocks.methods.reset).toHaveBeenCalledOnce() + expect(mocks.methods.resetValidation).toHaveBeenCalledOnce() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 7a19aac0..9b30c16b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -348,6 +348,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/composables/useScheduleProgress.ts', 'src/composables/useServerConnectionProbe.ts', 'src/composables/useTorrentFilter.ts', + 'src/plugins/vuetify/AppInput.ts', 'src/layouts/default/components/OfflinePage.vue', 'src/components/cards/SubscribeCard.vue', 'src/components/cards/UserCard.vue', @@ -461,6 +462,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 90, statements: 90, }, + 'src/plugins/vuetify/AppInput.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, 'src/layouts/default/components/OfflinePage.vue': { branches: 75, functions: 80,