227 lines
6.5 KiB
TypeScript
227 lines
6.5 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises } from '@vue/test-utils'
|
|
import {
|
|
MOBILE_WIDTH,
|
|
chip,
|
|
control,
|
|
entryWith,
|
|
fieldWrapper,
|
|
installSelectPolyfills,
|
|
mountEditor,
|
|
openChoices,
|
|
text,
|
|
typeInto,
|
|
unmountAll,
|
|
} from './entryEditorKit'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return { ...actual, useApi: vi.fn() }
|
|
})
|
|
|
|
beforeAll(installSelectPolyfills)
|
|
|
|
afterEach(unmountAll)
|
|
|
|
function preview() {
|
|
return document.body.querySelector('[data-testid="markdown-preview"]')
|
|
}
|
|
|
|
function tab(label: string) {
|
|
return [...document.body.querySelectorAll<HTMLElement>('[role="tab"]')].find(
|
|
(candidate) => text(candidate) === label,
|
|
)!
|
|
}
|
|
|
|
function toolbarButton(label: string) {
|
|
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!
|
|
}
|
|
|
|
async function pressTab(label: string) {
|
|
const target = tab(label)
|
|
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
|
target.focus()
|
|
target.click()
|
|
await flushPromises()
|
|
}
|
|
|
|
describe('Form controls', () => {
|
|
it('uses one control per field type', async () => {
|
|
await mountEditor('/content/events/42')
|
|
|
|
expect(fieldWrapper('title').querySelector('input[type="text"]')).toBeTruthy()
|
|
expect(fieldWrapper('description').querySelector('textarea')).toBeTruthy()
|
|
expect(fieldWrapper('capacity').querySelector('input')).toBeTruthy()
|
|
expect(fieldWrapper('online').querySelector('[role="switch"]')).toBeTruthy()
|
|
expect(fieldWrapper('starts_on').querySelector('[role="spinbutton"]')).toBeTruthy()
|
|
expect(fieldWrapper('category').querySelector('[role="combobox"]')).toBeTruthy()
|
|
})
|
|
|
|
it('toggles the switch text between Off and On and marks the change', async () => {
|
|
await mountEditor('/content/events/42')
|
|
expect(text(fieldWrapper('online'))).toContain('Off')
|
|
|
|
fieldWrapper('online').querySelector<HTMLElement>('[role="switch"]')!.click()
|
|
await flushPromises()
|
|
|
|
expect(text(fieldWrapper('online'))).toContain('On')
|
|
expect(text(chip())).toBe('Unsaved changes')
|
|
})
|
|
|
|
it('lists the choices in schema order with a None item for an optional select', async () => {
|
|
await mountEditor('/content/events/42')
|
|
|
|
const options = await openChoices('category')
|
|
|
|
expect(options.map((option) => text(option))).toEqual([
|
|
'None',
|
|
'Talk',
|
|
'Workshop',
|
|
'Meetup',
|
|
])
|
|
})
|
|
|
|
it('clears an optional select with None', async () => {
|
|
await mountEditor('/content/events/42')
|
|
const options = await openChoices('category')
|
|
|
|
options[0]!.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
|
options[0]!.click()
|
|
await flushPromises()
|
|
|
|
expect(text(fieldWrapper('category'))).toContain('Choose an option')
|
|
expect(text(chip())).toBe('Unsaved changes')
|
|
})
|
|
|
|
it('shows a counter while typing', async () => {
|
|
await mountEditor('/content/events/42')
|
|
|
|
await typeInto('title', 'Lime plaster workshop, two days')
|
|
|
|
expect(text(fieldWrapper('title'))).toContain('31 / 80')
|
|
expect(
|
|
fieldWrapper('title').querySelector('[data-testid="entry-counter"]')!.className,
|
|
).not.toContain('text-error')
|
|
})
|
|
|
|
it('has no counter for a text field without a max', async () => {
|
|
await mountEditor('/content/events/42', {
|
|
entry: () => entryWith({}),
|
|
type: () => ({
|
|
id: 1,
|
|
key: 'events',
|
|
name: 'Events',
|
|
icon: null,
|
|
created_at: '2026-09-01T09:30:00Z',
|
|
updated_at: '2026-09-01T09:30:00Z',
|
|
fields: [
|
|
{
|
|
key: 'title',
|
|
label: 'Title',
|
|
type: 'text',
|
|
required: false,
|
|
unique: false,
|
|
default: null,
|
|
min: null,
|
|
max: null,
|
|
options: [],
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
expect(fieldWrapper('title').querySelector('[data-testid="entry-counter"]')).toBeNull()
|
|
})
|
|
|
|
it('puts every field in one column below md', async () => {
|
|
await mountEditor('/content/events/42', { width: MOBILE_WIDTH })
|
|
|
|
const row = fieldWrapper('starts_on').parentElement!
|
|
expect(row.className).toContain('md:grid-cols-2')
|
|
expect(row.className).not.toMatch(/(^|\s)grid-cols-2/)
|
|
})
|
|
})
|
|
|
|
describe('4 Markdown preview', () => {
|
|
it('renders bold text and a list in the Preview tab and keeps the value', async () => {
|
|
await mountEditor('/content/events/42')
|
|
const value = control('description').value
|
|
|
|
await pressTab('Preview')
|
|
|
|
expect(preview()!.innerHTML).toContain('<strong>lime putty</strong>')
|
|
expect(preview()!.querySelectorAll('li')).toHaveLength(2)
|
|
expect(toolbarButton('Bold')).toBeNull()
|
|
expect(text(chip())).toBe('Saved')
|
|
|
|
await pressTab('Write')
|
|
|
|
expect(control('description').value).toBe(value)
|
|
expect(preview()).toBeNull()
|
|
expect(toolbarButton('Bold')).toBeTruthy()
|
|
})
|
|
|
|
it('shows raw HTML as text and keeps unsafe links inert', async () => {
|
|
await mountEditor('/content/events/42', {
|
|
entry: () =>
|
|
entryWith({
|
|
description: '<b>raw</b> [bad](javascript:alert(1)) [ok](https://example.com)',
|
|
}),
|
|
})
|
|
|
|
await pressTab('Preview')
|
|
|
|
expect(preview()!.querySelector('b')).toBeNull()
|
|
expect(text(preview())).toContain('<b>raw</b>')
|
|
const links = [...preview()!.querySelectorAll('a')]
|
|
expect(links.map((link) => link.getAttribute('href'))).toEqual(['https://example.com'])
|
|
})
|
|
|
|
it('reads Nothing to preview yet. when empty', async () => {
|
|
await mountEditor('/content/events/new')
|
|
|
|
await pressTab('Preview')
|
|
|
|
expect(text(preview())).toBe('Nothing to preview yet.')
|
|
})
|
|
|
|
it.each([
|
|
['Bold', '**lime**'],
|
|
['Italic', '_lime_'],
|
|
['Heading', '## lime'],
|
|
['Link', '[lime](url)'],
|
|
['List', '- lime'],
|
|
])('the %s button turns the selection into %s', async (label, result) => {
|
|
await mountEditor('/content/events/new')
|
|
await typeInto('description', 'lime')
|
|
const area = control('description') as HTMLTextAreaElement
|
|
area.focus()
|
|
area.setSelectionRange(0, 4)
|
|
|
|
toolbarButton(label).click()
|
|
await flushPromises()
|
|
|
|
expect(area.value).toBe(result)
|
|
expect(document.activeElement).toBe(area)
|
|
})
|
|
|
|
it('exposes the tabs as a tablist and names the toolbar buttons', async () => {
|
|
await mountEditor('/content/events/new')
|
|
|
|
expect(document.body.querySelector('[role="tablist"]')).toBeTruthy()
|
|
for (const label of ['Bold', 'Italic', 'Heading', 'Link', 'List']) {
|
|
expect(toolbarButton(label)).toBeTruthy()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Accessibility', () => {
|
|
it('announces the chip politely and ties labels to controls', async () => {
|
|
await mountEditor('/content/events/42')
|
|
|
|
expect(chip()!.getAttribute('role')).toBe('status')
|
|
const label = fieldWrapper('title').querySelector('label')!
|
|
expect(label.getAttribute('for')).toBe(control('title').id)
|
|
})
|
|
})
|