Files
RenewCMS/web/src/views/__tests__/EntryEditorControls.spec.ts
T

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)
})
})