test(12.1-05): unit tests for the Phase 12.1 SPA components

- BulkActionsMenu, RowStateBadges, RecordActions, PreviewField, FormErrorBanner, PasswordField and PermissionEditorField, each mounted on its own
- backstop: a row state outside the fixed set renders no badge and no class
- backstop: the permission editor's emission rules per mode, the locked row and codes outside the options
This commit is contained in:
Jakub Zych
2026-10-05 15:29:44 +02:00
parent 2e94cbf9f8
commit 84efdc09e0
7 changed files with 939 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
// Bulk actions menu (UI-SPEC S1, D-09), the component on its own. The whole
// flow with its confirmation is in tests/list/ListView.test.ts. Fixtures are
// neutral acme.* data; no application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import BulkActionsMenu from '../../src/components/list/BulkActionsMenu.vue'
import { langFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
const LONG = 'Move the selected people to the archive of the previous season and notify their team leads'
const actions = [
{ name: 'activate', label: 'Activate', confirm: 'Activate the selected people?' },
{ name: 'archive', label: LONG },
{ name: 'markup', label: '<img src=x onerror=alert(1)>' },
]
function mountMenu(props: { actions?: typeof actions; disabled?: boolean } = {}) {
return mount(BulkActionsMenu, {
props: { actions: props.actions ?? actions, disabled: props.disabled ?? false },
attachTo: document.body,
})
}
const trigger = (wrapper: VueWrapper) => wrapper.find('[data-action="bulk-actions"]')
const items = () => Array.from(document.body.querySelectorAll<HTMLElement>('[data-bulk-menu] [data-bulk-action]'))
async function open(wrapper: VueWrapper): Promise<void> {
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
await flushPromises()
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, 'backend::lang.list.bulk_actions': { other: 'Bulk actions' } }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('bulk actions menu component (UI-SPEC S1, D-09)', () => {
it('is one outline trigger with its label and a decorative chevron', () => {
const wrapper = mountMenu()
expect(trigger(wrapper).text()).toBe('Bulk actions')
expect(trigger(wrapper).classes()).toContain('border-border-strong')
expect(trigger(wrapper).attributes('aria-haspopup')).toBe('menu')
expect(trigger(wrapper).find('svg').attributes('aria-hidden')).toBe('true')
expect(document.body.querySelector('[data-bulk-menu]')).toBeNull()
})
it('keeps its label while disabled and does not open', async () => {
const wrapper = mountMenu({ disabled: true })
expect(trigger(wrapper).text()).toBe('Bulk actions')
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
await open(wrapper)
expect(document.body.querySelector('[data-bulk-menu]')).toBeNull()
await wrapper.setProps({ disabled: false })
expect(trigger(wrapper).attributes('disabled')).toBeUndefined()
})
it('lists the actions in the given order as menu items', async () => {
const wrapper = mountMenu()
await open(wrapper)
const menu = document.body.querySelector('[data-bulk-menu]')!
expect(menu.getAttribute('role')).toBe('menu')
expect(items().map((item) => item.dataset.bulkAction)).toEqual(['activate', 'archive', 'markup'])
expect(items().every((item) => item.getAttribute('role') === 'menuitem')).toBe(true)
// Declared order, not alphabetical: a reordered list renders reordered.
await wrapper.setProps({ actions: [actions[1]!, actions[0]!] })
await flushPromises()
expect(items().map((item) => item.dataset.bulkAction)).toEqual(['archive', 'activate'])
})
it('lets a long label wrap inside the menu width and renders markup as text', async () => {
const wrapper = mountMenu()
await open(wrapper)
const menu = document.body.querySelector<HTMLElement>('[data-bulk-menu]')!
expect(Array.from(menu.classList)).toEqual(expect.arrayContaining(['w-[240px]', 'max-w-[320px]']))
const [, long, markup] = items()
expect(long!.textContent?.trim()).toBe(LONG)
expect(long!.className).not.toMatch(/truncate|whitespace-nowrap|line-clamp/)
expect(long!.classList.contains('min-h-10')).toBe(true)
expect(markup!.textContent?.trim()).toBe('<img src=x onerror=alert(1)>')
expect(markup!.querySelector('img')).toBeNull()
})
it('emits the chosen action name once and closes', async () => {
const wrapper = mountMenu()
await open(wrapper)
items()[1]!.click()
await flushPromises()
expect(wrapper.emitted('select')).toEqual([['archive']])
expect(document.body.querySelector('[data-bulk-menu]')).toBeNull()
})
it('exposes focus(), which moves focus to the trigger', () => {
const wrapper = mountMenu()
const other = document.createElement('button')
document.body.appendChild(other)
other.focus()
expect(document.activeElement).toBe(other)
;(wrapper.vm as unknown as { focus: () => void }).focus()
expect(document.activeElement).toBe(trigger(wrapper).element)
})
})

View File

@@ -0,0 +1,68 @@
// Row state badges (UI-SPEC S4, D-12). Fixtures are neutral; no application
// names appear in framework tests.
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import RowStateBadges from '../../src/components/list/RowStateBadges.vue'
const labels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Not active' }
const badges = (states: readonly string[], use: Record<string, string> = labels) =>
mount(RowStateBadges, { props: { states, labels: use } })
describe('row state badges (UI-SPEC S4, D-12)', () => {
it.each([
['deleted', 'Deleted', ['border', 'border-border-strong', 'text-muted']],
['negative', 'Blocked', ['bg-danger-soft', 'text-danger']],
['disabled', 'Not active', ['bg-subtle', 'text-muted']],
])('renders %s as a text badge with its own classes', (state, text, classes) => {
const wrapper = badges([state])
const all = wrapper.findAll('[data-row-state]')
expect(all).toHaveLength(1)
expect(all[0]!.attributes('data-row-state')).toBe(state)
expect(all[0]!.text()).toBe(text)
expect(all[0]!.classes()).toEqual(expect.arrayContaining([...classes, 'rounded-pill', 'whitespace-nowrap', 'shrink-0']))
// The state is said in text: colour alone would fail WCAG 1.4.1.
expect(all[0]!.classes()).not.toContain('sr-only')
expect(all[0]!.classes()).not.toContain('line-through')
})
it('renders combined states in the fixed order deleted, negative, disabled', () => {
for (const input of [
['disabled', 'negative', 'deleted'],
['negative', 'deleted', 'disabled'],
['deleted', 'negative', 'disabled'],
]) {
const wrapper = badges(input)
expect(wrapper.findAll('[data-row-state]').map((badge) => badge.attributes('data-row-state'))).toEqual(['deleted', 'negative', 'disabled'])
}
expect(badges(['disabled', 'deleted']).findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Deleted', 'Not active'])
})
it('renders a repeated state once', () => {
const wrapper = badges(['negative', 'negative', 'negative'])
expect(wrapper.findAll('[data-row-state]')).toHaveLength(1)
})
it('renders nothing at all without states', () => {
const wrapper = badges([])
expect(wrapper.find('span').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
it('falls back to the state name when the schema sends no label for it', () => {
expect(badges(['negative'], {}).find('[data-row-state]').text()).toBe('negative')
})
it('backstop: a row state outside the fixed set renders no badge and no class', () => {
const hostile = ['starred', 'bg-danger-soft', 'deleted line-through', '<b>x</b>', 'DELETED', '__proto__', 'constructor', '']
const wrapper = badges(hostile, { ...labels, starred: 'Starred', constructor: 'Constructor' })
expect(wrapper.find('[data-row-state]').exists()).toBe(false)
expect(wrapper.find('span').exists()).toBe(false)
expect(wrapper.html()).not.toMatch(/starred|bg-danger-soft|line-through|Starred|Constructor/)
// Next to known states the unknown ones change nothing.
const mixed = badges(['starred', 'negative', '<b>x</b>'])
expect(mixed.findAll('[data-row-state]').map((badge) => [badge.attributes('data-row-state'), badge.text()])).toEqual([['negative', 'Blocked']])
expect(mixed.html()).not.toContain('starred')
expect(mixed.find('b').exists()).toBe(false)
})
})