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,218 @@
// Permission editor (UI-SPEC S5, D-16), the component on its own. Fixtures
// are neutral acme.roster.* 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 type { FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import { permissionValues } from '../../src/components/form/control'
import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue'
import { editablePayload } from '../../src/components/form/formState'
import { clone, langFixture, rosterFormSchemaFixture } from '../fixtures/typed'
import { keydown, resetState } from '../helpers'
const strings = {
'backend::lang.permissioneditor.allow': { other: 'Allow' },
'backend::lang.permissioneditor.inherit': { other: 'Inherit' },
'backend::lang.permissioneditor.deny': { other: 'Deny' },
'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' },
'backend::lang.permissioneditor.empty': { other: 'No permissions are defined yet.' },
'backend::lang.permissioneditor.other': { other: 'Other' },
}
/** The roster permissions field: Content (2), Reports (1, locked), untabbed (1). */
function field(change: Partial<FormField> = {}): FormField {
return { ...clone(rosterFormSchemaFixture.data.fields.find((item) => item.name === 'permissions')!), ...change }
}
function mountEditor(props: { field?: FormField; modelValue?: unknown; invalid?: boolean; describedBy?: string } = {}) {
return mount(PermissionEditorField, {
props: { field: props.field ?? field(), modelValue: props.modelValue ?? {}, controlId: 'p', invalid: props.invalid, describedBy: props.describedBy },
attachTo: document.body,
})
}
const row = (wrapper: VueWrapper, code: string) => wrapper.find(`[data-permission="${code}"]`)
const segment = (wrapper: VueWrapper, code: string, value: string) => row(wrapper, code).find(`[data-segment="${value}"]`)
const checked = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[data-state="checked"]')
const box = (wrapper: VueWrapper, code: string) => row(wrapper, code).find('[role="checkbox"]')
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('permission editor component (UI-SPEC S5, D-16)', () => {
it('groups the options into sections by tab, in the order sent, untabbed ones last as Other', () => {
const options = [
{ code: 'z.first', label: 'Z first', tab: 'Zeta' },
{ code: 'loose.one', label: 'Loose one' },
{ code: 'a.one', label: 'A one', tab: 'Alpha' },
{ code: 'z.second', label: 'Z second', tab: 'Zeta' },
{ code: 'loose.two', label: '' },
]
const wrapper = mountEditor({ field: field({ permissionOptions: options }) })
const sections = wrapper.findAll('[data-permission-section]')
expect(sections.map((section) => section.find('h3 span').text())).toEqual(['Zeta', 'Alpha', 'Other'])
expect(sections.map((section) => section.findAll('[data-permission]').map((item) => item.attributes('data-permission')))).toEqual([
['z.first', 'z.second'],
['a.one'],
['loose.one', 'loose.two'],
])
// Every section is a group named by its heading; a label falls back to the code.
expect(sections.every((section) => section.attributes('role') === 'group' && section.attributes('aria-labelledby') === section.find('h3').attributes('id'))).toBe(true)
expect(row(wrapper, 'loose.two').text()).toContain('loose.two')
})
it('has no Other section when every option has a tab', () => {
const wrapper = mountEditor({ field: field({ permissionOptions: [{ code: 'a.one', label: 'A', tab: 'Alpha' }] }) })
expect(wrapper.findAll('[data-permission-section]').map((section) => section.find('h3 span').text())).toEqual(['Alpha'])
})
it('shows the empty state without options, keeping the control id for the label', () => {
for (const permissionOptions of [[], undefined]) {
const wrapper = mountEditor({ field: field({ permissionOptions }) })
const empty = wrapper.find('[data-permission-empty]')
expect(empty.text()).toBe('No permissions are defined yet.')
expect(empty.attributes('id')).toBe('p')
expect(wrapper.find('[data-permission-editor]').exists()).toBe(false)
wrapper.unmount()
}
})
it('names the group by the field label, or by the field name without one', () => {
const labelled = mountEditor()
expect(labelled.find('[data-permission-editor]').attributes('aria-labelledby')).toBe('p-label')
expect(labelled.find('[data-permission-editor]').attributes('aria-label')).toBeUndefined()
const bare = mountEditor({ field: field({ label: '' }) })
expect(bare.find('[data-permission-editor]').attributes('aria-label')).toBe('permissions')
expect(bare.find('[data-permission-editor]').attributes('aria-labelledby')).toBeUndefined()
})
it('shows the invalid border and points at the error line', async () => {
const wrapper = mountEditor({ invalid: true, describedBy: 'p-error' })
const editor = wrapper.find('[data-permission-editor]')
expect(editor.classes()).toContain('border-danger')
expect(editor.classes()).not.toContain('border-border')
expect(editor.attributes('aria-describedby')).toBe('p-error')
await wrapper.setProps({ invalid: false })
expect(wrapper.find('[data-permission-editor]').classes()).toContain('border-border')
})
it('shows stored values: allow, deny, and inherit for a code without one', () => {
const wrapper = mountEditor({ modelValue: { 'posts.edit': 1, 'posts.publish': -1 } })
expect(wrapper.find('[data-permission-editor]').attributes('data-mode')).toBe('radio')
expect(checked(wrapper, 'posts.edit').text()).toBe('Allow')
expect(checked(wrapper, 'posts.publish').text()).toBe('Deny')
expect(checked(wrapper, 'misc.beta').text()).toBe('Inherit')
// Values the mode does not know read as inherit.
for (const modelValue of [{ 'posts.edit': 2 }, { 'posts.edit': 'yes' }, { 'posts.edit': null }, null, 'x', [1]]) {
const odd = mountEditor({ modelValue })
expect(checked(odd, 'posts.edit').text()).toBe('Inherit')
odd.unmount()
}
})
it('shows a locked row with its stored value, a lock and its text, and a disabled control', async () => {
const wrapper = mountEditor({ modelValue: { 'reports.export': 1 } })
const locked = row(wrapper, 'reports.export')
expect(locked.attributes('data-locked')).toBe('')
expect(locked.find('[data-permission-locked]').text()).toBe('You cannot change this permission.')
expect(locked.find('svg').attributes('aria-hidden')).toBe('true')
expect(checked(wrapper, 'reports.export').text()).toBe('Allow')
const group = locked.find('[role="radiogroup"]')
expect(group.attributes('aria-disabled')).toBe('true')
expect(group.attributes('aria-describedby')).toBe(locked.find('[data-permission-locked]').attributes('id'))
// An unlocked row has neither.
expect(row(wrapper, 'posts.edit').attributes('data-locked')).toBeUndefined()
expect(row(wrapper, 'posts.edit').find('[data-permission-locked]').exists()).toBe(false)
})
it('is read-only as a whole when the field is: every control disabled, no lock shown', async () => {
for (const change of [{ readOnly: true }, { attributes: { readonly: true } }] as Partial<FormField>[]) {
const wrapper = mountEditor({ field: field(change), modelValue: { 'posts.edit': 1 } })
expect(wrapper.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true)
expect(wrapper.find('[data-locked]').exists()).toBe(false)
expect(wrapper.find('[data-permission-locked]').exists()).toBe(false)
await segment(wrapper, 'posts.edit', '-1').trigger('click')
expect(emitted(wrapper)).toEqual([])
wrapper.unmount()
}
const boxes = mountEditor({ field: field({ mode: 'checkbox', readOnly: true }), modelValue: {} })
expect(boxes.findAll('[role="checkbox"]').every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
await box(boxes, 'posts.edit').trigger('click')
expect(emitted(boxes)).toEqual([])
})
it('checkbox mode: one labelled checkbox per row and an Allow column heading', () => {
const wrapper = mountEditor({ field: field({ mode: 'checkbox' }), modelValue: { 'posts.edit': 1, 'posts.publish': -1 } })
expect(wrapper.find('[data-permission-editor]').attributes('data-mode')).toBe('checkbox')
expect(wrapper.find('[role="radiogroup"]').exists()).toBe(false)
expect(wrapper.findAll('[data-permission-section] h3 [aria-hidden="true"]').map((item) => item.text())).toEqual(['Allow', 'Allow', 'Allow'])
expect(box(wrapper, 'posts.edit').attributes('aria-checked')).toBe('true')
// A stored deny is not a checkbox value: it shows unchecked.
expect(box(wrapper, 'posts.publish').attributes('aria-checked')).toBe('false')
const label = row(wrapper, 'posts.edit').find('label')
expect(label.attributes('for')).toBe(box(wrapper, 'posts.edit').attributes('id'))
expect(box(wrapper, 'reports.export').attributes('aria-disabled')).toBe('true')
})
it('backstop: radio mode emits 1 and -1 and omits inherit, checkbox mode emits 1 and omits unchecked, a locked row cannot change, and codes outside the options are never sent', async () => {
// Radio: a stored code that is not offered (legacy.code) and an
// out-of-set value are dropped from everything the control emits.
const stored = { 'legacy.code': 1, 'posts.edit': 1, 'posts.publish': 7, 'reports.export': -1 }
const radio = mountEditor({ modelValue: stored })
await segment(radio, 'posts.publish', '-1').trigger('click')
expect(emitted(radio).at(-1)).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': -1 })
await radio.setProps({ modelValue: emitted(radio).at(-1) })
await segment(radio, 'misc.beta', '1').trigger('click')
expect(emitted(radio).at(-1)).toEqual({ 'posts.edit': 1, 'posts.publish': -1, 'reports.export': -1, 'misc.beta': 1 })
await radio.setProps({ modelValue: emitted(radio).at(-1) })
// Inherit is the absence of the code, never a 0.
await segment(radio, 'posts.edit', '0').trigger('click')
expect(emitted(radio).at(-1)).toEqual({ 'posts.publish': -1, 'reports.export': -1, 'misc.beta': 1 })
expect(Object.values(emitted(radio).at(-1) as Record<string, number>).every((value) => value === 1 || value === -1)).toBe(true)
// A locked row cannot change: by click, and by the keyboard.
const before = emitted(radio).length
for (const value of ['1', '0', '-1']) {
await segment(radio, 'reports.export', value).trigger('click')
}
keydown(segment(radio, 'reports.export', '-1').element, 'ArrowLeft')
keydown(segment(radio, 'reports.export', '-1').element, ' ')
await flushPromises()
expect(emitted(radio)).toHaveLength(before)
expect(segment(radio, 'reports.export', '1').attributes('disabled')).toBeDefined()
// Checkbox: checked is 1, unchecked is absent; a stored deny is dropped.
const checkbox = mountEditor({ field: field({ mode: 'checkbox' }), modelValue: { 'legacy.code': 1, 'posts.edit': 1, 'posts.publish': -1, 'reports.export': 1 } })
await box(checkbox, 'misc.beta').trigger('click')
expect(emitted(checkbox).at(-1)).toEqual({ 'posts.edit': 1, 'reports.export': 1, 'misc.beta': 1 })
await checkbox.setProps({ modelValue: emitted(checkbox).at(-1) })
await box(checkbox, 'posts.edit').trigger('click')
expect(emitted(checkbox).at(-1)).toEqual({ 'reports.export': 1, 'misc.beta': 1 })
const settled = emitted(checkbox).length
await box(checkbox, 'reports.export').trigger('click')
expect(emitted(checkbox)).toHaveLength(settled)
// The save body is reduced the same way, whatever the form state holds.
const radioField = field()
expect(permissionValues(radioField, { ...stored, 'misc.beta': 0, extra: -1 })).toEqual({ 'posts.edit': 1, 'reports.export': -1 })
expect(editablePayload([radioField], { permissions: stored }, 'update')).toEqual({ permissions: { 'posts.edit': 1, 'reports.export': -1 } })
const checkboxField = field({ mode: 'checkbox' })
expect(editablePayload([checkboxField], { permissions: { 'posts.edit': 1, 'posts.publish': -1, 'legacy.code': 1 } }, 'update')).toEqual({
permissions: { 'posts.edit': 1 },
})
// Nothing stored still sends the field, as an empty object.
expect(editablePayload([radioField], {}, 'update')).toEqual({ permissions: {} })
// A field with no options can send no code at all.
expect(permissionValues(field({ permissionOptions: [] }), stored)).toEqual({})
})
})