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() 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('[role="tab"]')].find( (candidate) => text(candidate) === label, )! } function toolbarButton(label: string) { return document.body.querySelector(`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('[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('lime putty') 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: 'raw [bad](javascript:alert(1)) [ok](https://example.com)', }), }) await pressTab('Preview') expect(preview()!.querySelector('b')).toBeNull() expect(text(preview())).toContain('raw') 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) }) })