import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import UIcon from '@nuxt/ui/components/Icon.vue' import USelect from '@nuxt/ui/components/Select.vue' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import { initializeLocale, setLocale } from '@/i18n' import { CREATE, LIST, alertDialog, blur, bodyOf, buttonByLabel, buttonIn, calls, click, clickBackdrop, defaultFixture, deferred, field, fieldHelp, fieldMessage, findButton, menuItem, mountPage, openAddForm, openEditForm, openMenu, pressEscape, pressKey, rows, slideover, stubBrowser, stubClipboard, textOf, toastCalls, typeInto, unmountPage, variant, wrapper, } from './variantsTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const toasts = vi.hoisted(() => ({ add: vi.fn() })) vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => toasts } }) const PUT_HERO = 'PUT /v1/image-variants/hero' const NAME_ERROR = 'Use lowercase letters, numbers and dashes, 2 to 32 characters.' const RANGE_ERROR = 'Enter a value from 1 to 8000.' const QUALITY_ERROR = 'Enter a value from 1 to 100.' const DIMENSION_ERROR = 'Set a width, a height, or both.' const DIMENSION_HELP = 'Set a width, a height, or both. 1–8000 px. Images are never enlarged.' const SAVE_ERROR_TITLE = "Couldn't save the variant" const URL_PREFIX = '/static/uploadedImages/north-wall.jpg?v=' const WARNING_ALERT = 'Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request.' function form() { return slideover()! } function iconNames(root: ParentNode) { return wrapper! .findAllComponents(UIcon) .filter((icon) => root.contains(icon.element)) .map((icon) => icon.props('name')) } function saveButton(label = 'Add variant') { return buttonIn(form(), label)! } function footerStatus() { return form().querySelector('[data-slot="footer"] div.flex-1')?.textContent?.trim() ?? '' } function radio(value: string) { return form().querySelector(`button[role="radio"][value="${value}"]`)! } function checkedFit() { return form().querySelector('button[role="radio"][aria-checked="true"]')?.getAttribute('value') } function formatSelect() { return wrapper!.findComponent(USelect) } async function setFormat(value: string) { formatSelect().vm.$emit('update:modelValue', value) await flushPromises() } function formatShown() { return form().querySelector('[role="combobox"]')?.textContent?.trim() } function qualityInput() { return field('quality') } function sliderThumb() { return form().querySelector('[data-field="quality"] [role="slider"]')! } function sliderRange() { return form().querySelector('[data-field="quality"] [data-slot="range"]')! } function compareInput() { return form().querySelector('input[type="range"]')! } function switchByLabel(labelId: string) { return form().querySelector( `button[role="switch"][aria-labelledby="${labelId}"]`, )! } function previewText() { return textOf(form().querySelector('[data-testid="image-compare"]')!.parentElement!) } async function fillHero() { await typeInto(field('name'), 'hero') await typeInto(field('width'), '1200') await typeInto(field('height'), '630') await setFormat('webp') await typeInto(qualityInput(), '82') await click(switchByLabel('generate-on-upload-label')) } async function submit(label = 'Add variant') { await click(saveButton(label)) } function saved(overrides: Record = {}) { return { ...variant({ name: 'hero' }), generated_files: 0, cache_size: 0, ...overrides } } beforeEach(() => { toasts.add.mockClear() localStorage.clear() initializeLocale() stubBrowser() vi.mocked(useApi).mockReset() }) afterEach(async () => { vi.unstubAllGlobals() await unmountPage() localStorage.clear() initializeLocale() }) describe('Rule: Slide-over opening and closing', () => { it('Add variant opens the slide-over on desktop', async () => { await mountPage() await openAddForm() const panel = form() expect(panel.className).toContain('sm:max-w-240') expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe('Add variant') expect(panel.querySelector('[data-slot="header"]')!.className).toContain('h-14') const close = buttonByLabel('Close')! expect(iconNames(close)).toContain('i-lucide-x') expect(panel.querySelector('[data-slot="body"] form')!.className).toContain( 'sm:grid-cols-[440px_minmax(0,1fr)]', ) expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('sm:h-16') expect(buttonIn(panel, 'Cancel')!.disabled).toBe(false) expect(saveButton().className).toContain('sm:h-8') expect(footerStatus()).toBe('') expect(document.activeElement).toBe(field('name')) expect(document.body.querySelector('[data-slot="overlay"]')).not.toBeNull() expect(rows()).toHaveLength(6) }) it('Add variant opens a full page on mobile', async () => { await mountPage({ desktop: false }) await openAddForm() const panel = form() expect(buttonByLabel('Close')).toBeNull() const back = buttonByLabel('Back')! expect(iconNames(back)).toContain('i-lucide-arrow-left') expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe('Add variant') expect(panel.className).toContain('w-full') expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('h-18') expect(buttonIn(panel, 'Cancel')!.className).toContain('flex-1') expect(saveButton().className).toContain('flex-1') expect(panel.textContent).toContain('Preview') const sections = panel.querySelectorAll('[data-slot="body"] form > div') expect(sections[1]!.className).toContain('border-t') }) it('Edit opens with the stored values', async () => { await mountPage() await openEditForm('hero') const panel = form() expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe( 'Edit variant: hero', ) expect(field('name')!.value).toBe('hero') expect(field('name')!.readOnly).toBe(true) expect(field('name')!.className).toContain('bg-elevated') expect(iconNames(panel.querySelector('[data-field="name"]')!)).toContain('i-lucide-lock') expect(fieldHelp('name')).toBe("The name can't be changed.") expect([field('width')!.value, field('height')!.value]).toEqual(['1200', '630']) expect(checkedFit()).toBe('cover') expect(formatShown()).toBe('WebP') expect(qualityInput()!.value).toBe('82') expect(switchByLabel('generate-on-upload-label').getAttribute('aria-checked')).toBe('false') expect(switchByLabel('variant-enabled-label').getAttribute('aria-checked')).toBe('true') expect(textOf(panel.querySelector('[data-testid="edit-warning"]')!)).toBe(WARNING_ALERT) expect(saveButton('Save changes').disabled).toBe(true) expect(footerStatus()).toBe('') }) it('Dismissing a pristine form closes immediately', async () => { await mountPage() const opener = document.body.querySelector('[data-testid="add-variant"]')! await openAddForm() await click(buttonIn(form(), 'Cancel')) expect(slideover()).toBeNull() expect(alertDialog()).toBeNull() expect(document.activeElement).toBe(opener) }) it('Dismissing a pristine form with the close button or Escape closes immediately', async () => { await mountPage() await openAddForm() await click(buttonByLabel('Close')) expect(slideover()).toBeNull() await openAddForm() await pressEscape() expect(slideover()).toBeNull() expect(alertDialog()).toBeNull() }) it('Closing a pristine edit returns focus to the ellipsis button', async () => { await mountPage() await openEditForm('hero') await click(buttonIn(form(), 'Cancel')) await vi.waitFor(() => expect(document.activeElement).toBe(buttonByLabel('Actions for hero')), ) }) it.each([ ['Cancel', async () => click(buttonIn(form(), 'Cancel'))], ['the close button', async () => click(buttonByLabel('Close'))], ['Escape', async () => pressEscape()], ])('Dismissing a dirty form asks first: %s', async (_name, dismiss) => { await mountPage() await openEditForm('hero') await typeInto(qualityInput(), '70') await dismiss() const dialog = alertDialog()! expect(textOf(dialog)).toContain('Discard changes?') expect(textOf(dialog)).toContain( "You have unsaved changes to “hero”. If you close now, they're lost.", ) expect( Array.from(dialog.querySelectorAll('button')).map((b) => b.textContent?.trim()), ).toEqual(['Keep editing', 'Discard changes']) expect(dialog.querySelectorAll('button')[1]!.className).toContain('bg-error') }) it('Dismissing a dirty form on mobile with the back arrow asks first', async () => { await mountPage({ desktop: false }) await openAddForm() await typeInto(field('name'), 'banner') await click(buttonByLabel('Back')) expect(textOf(alertDialog()!)).toContain('Discard changes?') }) it('Keep editing returns to the form with its values', async () => { await mountPage() await openAddForm() await typeInto(field('name'), 'banner') await click(buttonIn(form(), 'Cancel')) await click(buttonIn(alertDialog()!, 'Keep editing')) expect(alertDialog()).toBeNull() expect(slideover()).not.toBeNull() expect(field('name')!.value).toBe('banner') }) it('Discard changes closes the slide-over and discards the values', async () => { await mountPage() await openEditForm('hero') await typeInto(qualityInput(), '70') await click(buttonIn(form(), 'Cancel')) await click(buttonIn(alertDialog()!, 'Discard changes')) expect(slideover()).toBeNull() expect(alertDialog()).toBeNull() expect(calls(PUT_HERO)).toHaveLength(0) await openEditForm('hero') expect(qualityInput()!.value).toBe('82') }) it('The discard text names the new variant in create mode', async () => { await mountPage() await openAddForm() await typeInto(field('name'), 'banner') await click(buttonIn(form(), 'Cancel')) expect(textOf(alertDialog()!)).toContain( "You have unsaved changes to “banner”. If you close now, they're lost.", ) }) it('The discard text has no name when the name is empty', async () => { await mountPage() await openAddForm() await typeInto(field('width'), '800') await click(buttonIn(form(), 'Cancel')) expect(textOf(alertDialog()!)).toContain( "You have unsaved changes. If you close now, they're lost.", ) }) }) describe('Rule: Form states', () => { it('Create: pristine', async () => { await mountPage() await openAddForm() const panel = form() expect(document.activeElement).toBe(field('name')) expect(field('name')!.value).toBe('') expect(field('name')!.placeholder).toBe('e.g. hero') expect(fieldHelp('name')).toBe( "Lowercase letters, numbers and dashes. Can't be changed later.", ) expect([field('width')!.placeholder, field('height')!.placeholder]).toEqual([ 'auto', 'auto', ]) expect(textOf(panel.querySelector('[data-field="width"]')!)).toContain('px') expect(panel.textContent).toContain(DIMENSION_HELP) expect(panel.querySelector('[role="radiogroup"]')).not.toBeNull() expect(checkedFit()).toBe('cover') expect(panel.textContent).toContain('Fills the box and crops what overflows.') expect(formatShown()).toBe('Original') expect(panel.textContent).toContain('Keeps the format of each source image.') expect(sliderThumb().getAttribute('aria-valuenow')).toBe('80') expect(qualityInput()!.value).toBe('80') expect(fieldHelp('quality')).toBe('1–100. Higher is sharper and larger.') expect(switchByLabel('generate-on-upload-label').getAttribute('aria-checked')).toBe('false') expect(switchByLabel('variant-enabled-label').getAttribute('aria-checked')).toBe('true') expect(panel.textContent).toContain('Creates this variant when an image is uploaded.') expect(panel.textContent).toContain('Disabled variants return 400 on the public site.') expect(saveButton().disabled).toBe(true) expect(panel.textContent).toContain('Set a width or height to see a preview.') expect(panel.querySelector('[data-testid="edit-warning"]')).toBeNull() }) it.each([ ['contain', 'Whole image inside the box, padded to the box size.'], ['inside', 'Whole image inside the box, no padding.'], ['cover', 'Fills the box and crops what overflows.'], ])('The Fit help follows the selection: %s', async (fit, help) => { await mountPage() await openAddForm() await click(radio('inside')) await click(radio(fit)) expect(checkedFit()).toBe(fit) expect(form().textContent).toContain(help) }) it('The Name counter', async () => { await mountPage() await openAddForm() await typeInto(field('name'), 'hero') expect(form().querySelector('[data-testid="name-counter"]')?.textContent).toBe('4/32') }) it('Edit mode has no counter', async () => { await mountPage() await openEditForm('hero') expect(form().querySelector('[data-testid="name-counter"]')).toBeNull() }) it('Create: dirty with valid values', async () => { await mountPage() await openAddForm() await fillHero() expect(saveButton().disabled).toBe(false) expect(footerStatus()).toBe('Unsaved changes') expect(iconNames(form().querySelector('[data-slot="footer"]')!)).toContain( 'i-lucide-circle-dot', ) expect(previewText()).toContain('hero') expect(previewText()).toContain('1200×630 · about 96 KB') }) it.each([ ['name', 'Hero banner', NAME_ERROR], ['name', 'a', NAME_ERROR], ['name', 'a'.repeat(33), NAME_ERROR], ['width', '9000', RANGE_ERROR], ['width', '0', RANGE_ERROR], ['height', '8001', RANGE_ERROR], ['quality', '140', QUALITY_ERROR], ['quality', '0', QUALITY_ERROR], ])('Field errors show on blur: %s %s', async (name, value, message) => { await mountPage() await openAddForm() const input = name === 'quality' ? qualityInput() : field(name) await typeInto(input, value) await blur(input) expect(fieldMessage(name)).toBe(message) expect(iconNames(form().querySelector(`[data-field="${name}"]`)!)).toContain( 'i-lucide-circle-alert', ) expect(input!.getAttribute('aria-invalid')).toBe('true') expect(input!.className).toContain('ring-error') if (name === 'name') { expect(fieldHelp('name')).toBeUndefined() } }) it('Once an error shows it is revalidated on every input and clears when valid', async () => { await mountPage() await openAddForm() await typeInto(field('name'), 'Hero') await blur(field('name')) expect(fieldMessage('name')).toBe(NAME_ERROR) await typeInto(field('name'), 'hero') expect(fieldMessage('name')).toBeUndefined() expect(fieldHelp('name')).toBe( "Lowercase letters, numbers and dashes. Can't be changed later.", ) await typeInto(field('name'), 'h') expect(fieldMessage('name')).toBe(NAME_ERROR) }) it.each([ ['name', 'ab'], ['name', 'a-b-c'], ['width', '1'], ['height', '8000'], ['quality', '1'], ['quality', '100'], ])('Valid boundary values show no error: %s %s', async (name, value) => { await mountPage() await openAddForm() const input = name === 'quality' ? qualityInput() : field(name) await typeInto(input, value) await blur(input) expect(fieldMessage(name)).toBeUndefined() }) it('Submitting with several errors sends nothing', async () => { await mountPage() await openAddForm() await typeInto(field('name'), 'Hero banner') await typeInto(field('width'), '9000') await typeInto(field('height'), '630') await setFormat('webp') await typeInto(qualityInput(), '140') await submit() expect(fieldMessage('name')).toBe(NAME_ERROR) expect(fieldMessage('width')).toBe(RANGE_ERROR) expect(fieldMessage('quality')).toBe(QUALITY_ERROR) expect(calls(CREATE)).toHaveLength(0) expect(document.activeElement).toBe(field('name')) }) it('No dimension', async () => { await mountPage() await openAddForm() await typeInto(field('name'), 'banner') await setFormat('webp') await submit() expect(fieldMessage('width')).toBe(DIMENSION_ERROR) expect(fieldMessage('height')).toBeUndefined() expect(field('height')!.className).toContain('ring-error') expect(form().textContent).not.toContain(DIMENSION_HELP) expect(calls(CREATE)).toHaveLength(0) expect(document.activeElement).toBe(field('width')) }) it('PNG hides Quality', async () => { await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'logo' })) } }) await openAddForm() await typeInto(field('name'), 'logo') await typeInto(field('width'), '300') await setFormat('png') expect(form().querySelector('[data-field="quality"]')).toBeNull() expect(form().querySelector('[role="slider"]')).toBeNull() expect(form().textContent).not.toContain('Higher is sharper') expect(previewText()).toContain('about') await submit() expect('quality' in bodyOf(CREATE)).toBe(false) }) it('Switching back from PNG restores the previous quality', async () => { await mountPage() await openAddForm() await setFormat('webp') await typeInto(qualityInput(), '70') await setFormat('png') await setFormat('webp') expect(qualityInput()!.value).toBe('70') }) it('The Quality slider and field stay in sync', async () => { await mountPage() await openAddForm() await setFormat('webp') sliderThumb().focus() for (let step = 0; step < 10; step++) { await pressKey(sliderThumb(), 'ArrowLeft') } expect(qualityInput()!.value).toBe('70') await typeInto(qualityInput(), '90') expect(sliderThumb().getAttribute('aria-valuenow')).toBe('90') expect(Number.parseFloat(sliderRange().style.right)).toBeCloseTo(10, 0) }) }) describe('Rule: Saving a new variant', () => { it('Add sends POST with the form values', async () => { await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved()) } }) await openAddForm() await fillHero() await submit() expect(bodyOf(CREATE)).toEqual({ name: 'hero', width: 1200, height: 630, fit: 'cover', format: 'webp', quality: 82, generate_on_upload: true, enabled: true, }) }) it('An empty Width or Height is sent as null', async () => { await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'card' })) } }) await openAddForm() await typeInto(field('name'), 'card') await typeInto(field('width'), '800') await submit() expect(bodyOf(CREATE).width).toBe(800) expect(bodyOf(CREATE).height).toBeNull() }) it('While saving, the form is locked', async () => { const pending = deferred() await mountPage({ routes: { [CREATE]: () => pending.promise } }) await openAddForm() await fillHero() await submit() const panel = form() expect( Array.from(panel.querySelectorAll('input:not([type="range"])')).every( (input) => input.disabled, ), ).toBe(true) expect(panel.querySelector('[role="radiogroup"].opacity-60')).not.toBeNull() expect( Array.from(panel.querySelectorAll('button[role="switch"]')).every( (item) => (item as HTMLButtonElement).disabled, ), ).toBe(true) expect((panel.querySelector('[role="combobox"]') as HTMLButtonElement).disabled).toBe(true) expect(buttonIn(panel, 'Cancel')!.disabled).toBe(true) expect(saveButton('Saving…').disabled).toBe(true) expect(iconNames(saveButton('Saving…'))).toContain('i-lucide-loader-circle') await pressEscape() await click(buttonByLabel('Close')) await clickBackdrop() expect(slideover()).not.toBeNull() expect(alertDialog()).toBeNull() pending.resolve(saved()) await flushPromises() }) it('A successful create', async () => { await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'banner' })) }, }) await openAddForm() const opener = document.body.querySelector('[data-testid="add-variant"]')! await typeInto(field('name'), 'banner') await typeInto(field('width'), '1200') await submit() expect(slideover()).toBeNull() expect(rows().map((row) => textOf(row).split(' ')[0])).toEqual([ 'banner', 'card', 'hero', 'og', 'print', 'square', 'thumb', ]) expect(textOf(rows()[0]!)).toContain('0 0 KB') expect( document.body.querySelector('[data-testid="variants-summary"]')?.textContent?.trim(), ).toBe('7 variants · 1,284 generated files · 212 MB') expect(calls(LIST)).toHaveLength(1) expect(toastCalls(toasts)).toEqual([ { title: 'Variant “banner” added', description: undefined, color: 'success' }, ]) await vi.waitFor(() => expect(document.activeElement).toBe(opener)) }) it('A create from the empty state leaves the empty state', async () => { await mountPage({ routes: { [LIST]: () => Promise.resolve({ items: [] }), [CREATE]: () => Promise.resolve(saved({ name: 'thumb' })), }, }) await click(findButton('Add variant')) await typeInto(field('name'), 'thumb') await typeInto(field('width'), '400') await submit() expect(rows()).toHaveLength(1) expect( document.body.querySelector('[data-testid="variants-summary"]')?.textContent?.trim(), ).toBe('1 variant · 0 generated files · 0 KB') expect(document.body.textContent).not.toContain('No variants yet') }) it('Name already taken (409)', async () => { await mountPage({ routes: { [CREATE]: () => Promise.reject( new ApiError( 409, undefined, 'A variant named “thumb” already exists. Choose another name.', ), ), }, }) await openAddForm() await typeInto(field('name'), 'thumb') await typeInto(field('width'), '400') field('width')!.focus() await submit() expect(slideover()).not.toBeNull() expect(fieldMessage('name')).toBe( 'A variant named “thumb” already exists. Choose another name.', ) expect(field('name')!.className).toContain('ring-error') expect([field('name')!.value, field('width')!.value]).toEqual(['thumb', '400']) expect(document.activeElement).toBe(field('name')) expect(field('name')!.disabled).toBe(false) expect(form().querySelector('[role="alert"]')).toBeNull() expect(toasts.add).not.toHaveBeenCalled() await typeInto(field('name'), 'thumb2') expect(fieldMessage('name')).toBeUndefined() }) it.each([ ['status 500', () => Promise.reject(new ApiError(500, undefined, 'boom'))], ['a network failure', () => Promise.reject(new NetworkError())], ])('Save error: %s', async (_name, failure) => { let attempt = 0 await mountPage({ routes: { [CREATE]: () => (++attempt === 1 ? failure() : Promise.resolve(saved())), }, }) await openAddForm() await fillHero() await submit() expect(slideover()).not.toBeNull() expect(field('name')!.value).toBe('hero') expect(field('name')!.disabled).toBe(false) const alert = form().querySelector('[role="alert"]')! expect(textOf(alert)).toContain(SAVE_ERROR_TITLE) expect(textOf(alert)).toContain('Check your connection and try again.') expect(textOf(alert)).not.toContain('boom') expect( form().querySelector('[data-slot="body"] form')!.firstElementChild!.contains(alert), ).toBe(true) await click(buttonIn(alert, 'Try again')) expect(calls(CREATE)).toHaveLength(2) expect(bodyOf(CREATE, 1)).toEqual(bodyOf(CREATE, 0)) expect(form()?.querySelector('[role="alert"]') ?? null).toBeNull() expect(slideover()).toBeNull() }) it('Try again removes the alert as soon as the request starts', async () => { const retry = deferred() let attempt = 0 await mountPage({ routes: { [CREATE]: () => ++attempt === 1 ? Promise.reject(new ApiError(500)) : retry.promise, }, }) await openAddForm() await fillHero() await submit() await click(buttonIn(form().querySelector('[role="alert"]')!, 'Try again')) expect(form().querySelector('[role="alert"]')).toBeNull() retry.resolve(saved()) await flushPromises() }) it.each([ [ 400, 'Check the request', "A value in the request isn't valid. Reload the page and try again.", ], [403, "You don't have access", 'Only admins can manage image variants.'], [404, 'Not found', 'This variant or image no longer exists. Refresh the page.'], ])('Other statuses on save: %s', async (status, title, text) => { await mountPage({ routes: { [CREATE]: () => Promise.reject(new ApiError(status)) } }) await openAddForm() await fillHero() await submit() expect(slideover()).not.toBeNull() expect(field('name')!.value).toBe('hero') expect(toastCalls(toasts)).toEqual([{ title, description: text, color: 'error' }]) }) it.each([ [NAME_ERROR, 'name', NAME_ERROR], [ 'Width and height must be between 1 and 8000, and one of them is required.', 'width', DIMENSION_ERROR, ], ['Quality must be between 1 and 100.', 'quality', QUALITY_ERROR], ])('A 422 from the server is shown inline: %s', async (serverText, fieldName, inline) => { await mountPage({ routes: { [CREATE]: () => Promise.reject(new ApiError(422, undefined, serverText)) }, }) await openAddForm() await fillHero() await submit() expect(fieldMessage(fieldName)).toBe(inline) expect(form().querySelector('[role="alert"]')).toBeNull() }) it('A 422 about the fit shows the save error alert', async () => { await mountPage({ routes: { [CREATE]: () => Promise.reject( new ApiError(422, undefined, 'Fit must be cover, contain or inside.'), ), }, }) await openAddForm() await fillHero() await submit() expect(textOf(form().querySelector('[role="alert"]')!)).toContain(SAVE_ERROR_TITLE) }) }) describe('Rule: Saving an edit', () => { it('Edit: dirty', async () => { await mountPage() await openEditForm('hero') expect(previewText()).toContain('1200×630 · about 96 KB') await typeInto(qualityInput(), '70') expect(saveButton('Save changes').disabled).toBe(false) expect(footerStatus()).toBe('Unsaved changes') expect(qualityInput()!.value).toBe('70') expect(previewText()).toContain('1200×630 · about 71 KB') expect(form().querySelector('[data-testid="edit-warning"]')).not.toBeNull() }) it('Save sends PUT with the complete definition and a successful edit reports the purge', async () => { await mountPage({ routes: { [PUT_HERO]: () => Promise.resolve({ ...variant({ quality: 70, generated_files: 0, cache_size: 0 }), purged_files: 244, }), }, }) await openEditForm('hero') await typeInto(qualityInput(), '70') await submit('Save changes') expect(bodyOf(PUT_HERO)).toEqual({ width: 1200, height: 630, fit: 'cover', format: 'webp', quality: 70, generate_on_upload: false, enabled: true, }) expect(slideover()).toBeNull() const hero = rows().find((row) => textOf(row).startsWith('hero'))! expect(textOf(hero)).toBe('hero 1200×630 cover WebP 70 0 0 KB Enabled') expect(calls(LIST)).toHaveLength(1) expect(toastCalls(toasts)).toEqual([ { title: 'Variant “hero” saved. 244 files purged.', description: undefined, color: 'success', }, ]) }) it('A switch-only edit says nothing about purging', async () => { await mountPage({ routes: { [PUT_HERO]: () => Promise.resolve({ ...variant({ generate_on_upload: true }), purged_files: 0 }), }, }) await openEditForm('hero') await click(switchByLabel('generate-on-upload-label')) await submit('Save changes') expect(bodyOf(PUT_HERO).generate_on_upload).toBe(true) expect(toastCalls(toasts)).toEqual([ { title: 'Variant “hero” saved.', description: undefined, color: 'success' }, ]) }) it('Saving while saving and the save error behave as in create', async () => { const pending = deferred() await mountPage({ routes: { [PUT_HERO]: () => pending.promise } }) await openEditForm('hero') await typeInto(qualityInput(), '70') await submit('Save changes') expect(saveButton('Saving…').disabled).toBe(true) expect(buttonIn(form(), 'Cancel')!.disabled).toBe(true) await pressEscape() expect(slideover()).not.toBeNull() pending.reject(new ApiError(500)) await flushPromises() expect(textOf(form().querySelector('[role="alert"]')!)).toContain(SAVE_ERROR_TITLE) expect(qualityInput()!.value).toBe('70') }) }) describe('Rule: Preview and ImageCompare', () => { async function openHero() { await mountPage() await openAddForm() await typeInto(field('name'), 'hero') await typeInto(field('width'), '1200') await typeInto(field('height'), '630') await setFormat('webp') await typeInto(qualityInput(), '82') } it('Preview layout', async () => { await openHero() const panel = form() expect(panel.querySelector('h3')?.textContent).toBe('Preview') expect(panel.textContent).toContain('Sample: north-wall.jpg') const box = panel.querySelector('[data-testid="image-compare"]')! expect(box.className).toContain('aspect-3/2') expect(box.className).toContain('border') expect(box.className).toContain('overflow-hidden') const labels = box.nextElementSibling! expect(textOf(labels)).toBe('Original 4032 × 3024 · 3.9 MB hero 1200×630 · about 96 KB') expect(panel.textContent).toContain( 'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.', ) }) it('Compare box content', async () => { await openHero() const box = form().querySelector('[data-testid="image-compare"]')! expect( box.querySelector('[data-testid="compare-variant"]')!.style.clipPath, ).toBe('inset(0 0 0 50%)') const chips = Array.from(box.querySelectorAll('span.absolute.top-2')) expect(chips.map((chip) => chip.textContent?.trim())).toEqual(['Original', 'hero']) expect(chips[0]!.className).toContain('left-2') expect(chips[1]!.className).toContain('right-2') expect(chips[1]!.className).toContain('font-mono') expect(box.querySelector('[data-testid="compare-divider"]')!.style.left).toBe( '50%', ) expect(box.querySelector('[data-testid="compare-divider"]')!.className).toContain('w-0.5') const handle = box.querySelector('[data-testid="compare-handle"]')! expect(handle.className).toContain('size-8') expect(handle.className).toContain('max-sm:size-11') expect(iconNames(handle)).toContain('i-lucide-chevrons-left-right') }) it('ImageCompare is a native range input', async () => { await openHero() const input = compareInput() expect(input.type).toBe('range') expect([input.min, input.max, input.step, input.value]).toEqual(['0', '100', '1', '50']) expect(input.getAttribute('aria-label')).toBe('Compare original and hero') expect(input.getAttribute('aria-valuetext')).toBe('50% hero') expect(input.getAttribute('aria-valuenow')).toBe('50') }) it.each([ [50, 'ArrowRight', {}, 51], [50, 'ArrowUp', {}, 51], [50, 'ArrowLeft', {}, 49], [50, 'ArrowDown', {}, 49], [50, 'ArrowRight', { shiftKey: true }, 60], [50, 'ArrowLeft', { shiftKey: true }, 40], [50, 'Home', {}, 0], [50, 'End', {}, 100], [100, 'ArrowRight', {}, 100], [0, 'ArrowLeft', {}, 0], [95, 'ArrowRight', { shiftKey: true }, 100], [5, 'ArrowLeft', { shiftKey: true }, 0], ])('Keyboard behaviour: from %s press %s %j gives %s', async (start, key, init, end) => { await openHero() const input = compareInput() input.value = String(start) input.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() input.focus() await pressKey(input, key, init) expect(compareInput().value).toBe(String(end)) expect(compareInput().getAttribute('aria-valuetext')).toBe(`${end}% hero`) expect( form().querySelector('[data-testid="compare-divider"]')!.style.left, ).toBe(`${end}%`) expect( form().querySelector('[data-testid="compare-variant"]')!.style.clipPath, ).toBe(`inset(0 0 0 ${end}%)`) }) it('Focus ring and the slider keeps its value when the form fields change', async () => { await openHero() const handle = form().querySelector('[data-testid="compare-handle"]')! expect(handle.className).toContain('peer-focus-visible:outline-2') expect(handle.className).toContain('peer-focus-visible:outline-offset-2') expect(handle.className).toContain('peer-focus-visible:outline-primary') expect(compareInput().className).toContain('peer') await pressKey(compareInput(), 'End') await typeInto(field('width'), '800') expect(compareInput().value).toBe('100') }) it('Pointer dragging', async () => { await openHero() const input = compareInput() input.value = '75' input.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() expect(input.getAttribute('aria-valuetext')).toBe('75% hero') expect( form().querySelector('[data-testid="compare-divider"]')!.style.left, ).toBe('75%') }) it('No dimension, no comparison', async () => { await mountPage() await openAddForm() const box = form().querySelector('[data-testid="image-compare"]')! expect(box.querySelector('[data-testid="compare-variant"]')).toBeNull() expect(box.querySelector('[data-testid="compare-divider"]')).toBeNull() expect(box.querySelector('[data-testid="compare-handle"]')).toBeNull() expect(box.querySelector('input[type="range"]')).toBeNull() expect(Array.from(box.querySelectorAll('span.absolute.top-2')).map(textOf)).toEqual([ 'Original', ]) const overlay = box.querySelector('p')! expect(overlay.textContent).toBe('Set a width or height to see a preview.') expect(overlay.className).toContain('right-3') expect(overlay.className).toContain('bottom-3') expect(overlay.className).toContain('left-3') expect(textOf(box.nextElementSibling!)).toBe( 'Original 4032 × 3024 · 3.9 MB name No output yet', ) }) it('The sample name and slider labels follow the name', async () => { await mountPage() await openAddForm() await typeInto(field('width'), '800') expect(compareInput().getAttribute('aria-label')).toBe('Compare original and name') expect(previewText()).toContain('name 800×600 · about') expect( Array.from(form().querySelectorAll('span.absolute.top-2')).map( (chip) => chip.textContent, ), ).toEqual(['Original', 'name']) }) it('The estimate follows quality and format', async () => { await openHero() const labels: string[] = [] for (const quality of ['30', '60', '90']) { await typeInto(qualityInput(), quality) labels.push(previewText().match(/about (\d+) KB/)![1]!) } expect(labels.map(Number)).toEqual([...labels.map(Number)].sort((a, b) => a - b)) await setFormat('png') expect(previewText()).toContain('1200×630 · about 310 KB') }) }) describe('Rule: URL and copy', () => { it('The URL row', async () => { await mountPage() await openAddForm() await typeInto(field('name'), 'hero') const url = form().querySelector('[data-testid="variant-url"]')! expect(url.textContent?.trim()).toBe(`${URL_PREFIX}hero`) expect(url.className).toContain('font-mono') expect(url.className).toContain('bg-elevated') expect(url.className).toContain('text-highlighted') const copy = buttonIn(form(), 'Copy')! expect(copy.disabled).toBe(false) expect(iconNames(copy)).toContain('i-lucide-copy') }) it('Without a name', async () => { await mountPage() await openAddForm() const url = form().querySelector('[data-testid="variant-url"]')! expect(url.textContent?.trim()).toBe(`${URL_PREFIX}`) expect(url.className).toContain('text-muted') expect(buttonIn(form(), 'Copy')!.disabled).toBe(true) }) it('Copy writes the URL, shows Copied, then returns to Copy', async () => { const writeText = stubClipboard() await mountPage() await openAddForm() await typeInto(field('name'), 'hero') vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await click(buttonIn(form(), 'Copy')) expect(writeText).toHaveBeenCalledWith(`${URL_PREFIX}hero`) const copied = buttonIn(form(), 'Copied')! expect(copied.textContent).toContain('Copied') expect(iconNames(copied)).toContain('i-lucide-check') vi.advanceTimersByTime(2500) await flushPromises() expect(buttonIn(form(), 'Copied')).toBeUndefined() expect(iconNames(buttonIn(form(), 'Copy')!)).toContain('i-lucide-copy') }) it('A clipboard failure does not claim success', async () => { const writeText = stubClipboard() writeText.mockRejectedValue(new Error('denied')) await mountPage() await openAddForm() await typeInto(field('name'), 'hero') await click(buttonIn(form(), 'Copy')) expect(buttonIn(form(), 'Copy')!.textContent).toContain('Copy') expect(buttonIn(form(), 'Copied')).toBeUndefined() expect(form().querySelector('[role="alert"]')).toBeNull() expect(toasts.add).not.toHaveBeenCalled() }) it('In edit mode the URL uses the locked name', async () => { await mountPage() await openEditForm('hero') expect(form().querySelector('[data-testid="variant-url"]')!.textContent?.trim()).toBe( `${URL_PREFIX}hero`, ) expect(buttonIn(form(), 'Copy')!.disabled).toBe(false) }) }) describe('Rule: Menu keyboard behaviour', () => { it('arrow keys move between items and Enter activates the focused item', async () => { await mountPage() const trigger = buttonByLabel('Actions for hero')! trigger.focus() await click(trigger) const menu = document.body.querySelector('[role="menu"]')! await pressKey(menu, 'ArrowDown') const first = document.activeElement as HTMLElement await pressKey(first, 'ArrowDown') const second = document.activeElement as HTMLElement await pressKey(second, 'ArrowUp') expect(first.textContent?.trim()).toBe('Edit') expect(second.textContent?.trim()).toBe('Purge cache') expect((document.activeElement as HTMLElement).textContent?.trim()).toBe('Edit') await pressKey(document.activeElement!, 'Enter') expect(menuItem('Edit') ?? null).toBeNull() expect(slideover()?.textContent).toContain('Edit variant: hero') await openMenu('card') }) }) describe('Rule: French strings on the form', () => { it('renders the form in French', async () => { setLocale('fr') await mountPage() await click(buttonByLabel('Actions pour hero')) await click(menuItem('Modifier')) const text = form().textContent! expect(form().querySelector('[data-slot="title"]')?.textContent?.trim()).toBe( 'Modifier la variante : hero', ) for (const french of [ 'Aperçu', 'Ajustement', 'Qualité', 'Générer à l’import', 'Enregistrer les modifications', 'Annuler', 'Copier', ]) { expect(text).toContain(french) } for (const english of [ 'Preview', 'Quality', 'Save changes', 'Cancel', 'Generate on upload', 'Enabled', 'Copy', 'Drag the divider', ]) { expect(text).not.toContain(english) } expect(defaultFixture()).toHaveLength(6) }) })