1238 lines
38 KiB
TypeScript
1238 lines
38 KiB
TypeScript
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<typeof import('@/composables/useApi')>()
|
||
return { ...actual, useApi: vi.fn() }
|
||
})
|
||
|
||
const toasts = vi.hoisted(() => ({ add: vi.fn() }))
|
||
|
||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||
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<HTMLButtonElement>(`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<HTMLElement>('[data-field="quality"] [role="slider"]')!
|
||
}
|
||
|
||
function sliderRange() {
|
||
return form().querySelector<HTMLElement>('[data-field="quality"] [data-slot="range"]')!
|
||
}
|
||
|
||
function compareInput() {
|
||
return form().querySelector<HTMLInputElement>('input[type="range"]')!
|
||
}
|
||
|
||
function switchByLabel(labelId: string) {
|
||
return form().querySelector<HTMLButtonElement>(
|
||
`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<string, unknown> = {}) {
|
||
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<HTMLElement>('[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<unknown>()
|
||
await mountPage({ routes: { [CREATE]: () => pending.promise } })
|
||
await openAddForm()
|
||
await fillHero()
|
||
|
||
await submit()
|
||
|
||
const panel = form()
|
||
expect(
|
||
Array.from(panel.querySelectorAll<HTMLInputElement>('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<HTMLElement>('[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<HTMLElement>('[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<unknown>()
|
||
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<unknown>()
|
||
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<HTMLElement>('[data-testid="image-compare"]')!
|
||
expect(
|
||
box.querySelector<HTMLElement>('[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<HTMLElement>('[data-testid="compare-divider"]')!.style.left).toBe(
|
||
'50%',
|
||
)
|
||
expect(box.querySelector('[data-testid="compare-divider"]')!.className).toContain('w-0.5')
|
||
const handle = box.querySelector<HTMLElement>('[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<HTMLElement>('[data-testid="compare-divider"]')!.style.left,
|
||
).toBe(`${end}%`)
|
||
expect(
|
||
form().querySelector<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-testid="compare-divider"]')!.style.left,
|
||
).toBe('75%')
|
||
})
|
||
|
||
it('No dimension, no comparison', async () => {
|
||
await mountPage()
|
||
await openAddForm()
|
||
|
||
const box = form().querySelector<HTMLElement>('[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}<name>`)
|
||
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<HTMLElement>('[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)
|
||
})
|
||
})
|