feat: SPA Roles screens

This commit is contained in:
Florian Sylvain committed 2026-10-27 23:05:04 +01:00
1 parent 907949aa12
commit 3db490a615
47 files changed
+6947 -9

No files matched your search

@@ -0,0 +1,198 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue'
import ScopeControl from '../ScopeControl.vue'
import OnOffCell from '../OnOffCell.vue'
import { press, radiosOf, renderInApp } from './roleKit'
import type { Scope } from '@/lib/permissions'
let wrapper: VueWrapper | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
function mountControl(initial: Scope, extra: Record<string, unknown> = {}) {
const value = ref<Scope>(initial)
const changes: Scope[] = []
wrapper = renderInApp(ScopeControl, {
label: 'Events: Update',
modelValue: initial,
'onUpdate:modelValue': (scope: Scope) => {
changes.push(scope)
value.value = scope
},
...extra,
})
return { changes }
}
describe('ScopeControl', () => {
it('is a radiogroup named after the type and action with None, Own and All', () => {
mountControl('own')
const radios = radiosOf(wrapper!, 'Events: Update')
expect(radios.map((radio) => radio.textContent?.trim())).toEqual(['None', 'Own', 'All'])
expect(radios.map((radio) => radio.getAttribute('aria-checked'))).toEqual([
'false',
'true',
'false',
])
})
it('has one tab stop: the checked radio', () => {
mountControl('own')
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
-1, 0, -1,
])
})
it('keeps the checked radio as the single tab stop when the whole control is disabled', () => {
mountControl('own', { disabled: true })
const radios = radiosOf(wrapper!, 'Events: Update')
expect(radios.map((radio) => radio.tabIndex)).toEqual([-1, 0, -1])
expect(radios.map((radio) => radio.getAttribute('aria-disabled'))).toEqual([
'true',
'true',
'true',
])
})
it('falls back to the first option as tab stop when disabled and nothing matches', () => {
mountControl('none', { disabled: true })
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
0, -1, -1,
])
})
it('moves the tab stop off a locked checked option only when an enabled one exists', () => {
mountControl('all', { locks: { all: 'Locked' } })
expect(radiosOf(wrapper!, 'Events: Update').map((radio) => radio.tabIndex)).toEqual([
0, -1, -1,
])
})
it('selects with a click', async () => {
const { changes } = mountControl('own')
radiosOf(wrapper!, 'Events: Update')[2]!.click()
expect(changes).toEqual(['all'])
})
it.each([
['ArrowRight', 'own', 'all'],
['ArrowDown', 'own', 'all'],
['ArrowLeft', 'own', 'none'],
['ArrowUp', 'own', 'none'],
['Home', 'all', 'none'],
['End', 'none', 'all'],
] as const)('%s moves from %s to %s', async (key, from, to) => {
const { changes } = mountControl(from)
const current = radiosOf(wrapper!, 'Events: Update').find(
(radio) => radio.getAttribute('aria-checked') === 'true',
)!
current.focus()
press(current, key)
await flushPromises()
expect(changes).toEqual([to])
})
it('selects the focused radio with Space', async () => {
const { changes } = mountControl('none')
const all = radiosOf(wrapper!, 'Events: Update')[2]!
all.focus()
press(all, ' ')
await flushPromises()
expect(changes).toEqual(['all'])
})
it('marks a locked option aria-disabled, describes it by its reason and does not select it', async () => {
const reason = 'You can only grant what your role has.'
const { changes } = mountControl('own', { locks: { all: reason } })
const all = radiosOf(wrapper!, 'Events: Update')[2]!
all.click()
const described = document.getElementById(all.getAttribute('aria-describedby')!)
expect(all.getAttribute('aria-disabled')).toBe('true')
expect(described?.textContent).toBe(reason)
expect(changes).toEqual([])
})
it('skips locked options with the keyboard and keeps None and Own available', async () => {
const { changes } = mountControl('none', { locks: { all: 'x' } })
const none = radiosOf(wrapper!, 'Events: Update')[0]!
none.focus()
press(none, 'End')
await flushPromises()
expect(changes).toEqual(['own'])
})
it('disables every option when the control is disabled', () => {
mountControl('own', { disabled: true })
for (const radio of radiosOf(wrapper!, 'Events: Update')) {
expect(radio.getAttribute('aria-disabled')).toBe('true')
}
})
it('draws a square marker on a changed cell', () => {
mountControl('own', { changed: true })
expect(wrapper!.find('[data-testid="changed-marker"]').exists()).toBe(true)
})
})
describe('OnOffCell', () => {
it('is a switch named "{Type}: Create" with a visible On or Off label', () => {
wrapper = renderInApp(OnOffCell, { label: 'Events: Create', modelValue: true })
const toggle = wrapper.find('[role="switch"]')
expect(toggle.attributes('aria-label')).toBe('Events: Create')
expect(toggle.attributes('aria-checked')).toBe('true')
expect(wrapper.text()).toContain('On')
})
it('says Off when off and emits the new value on click', async () => {
const seen: boolean[] = []
wrapper = renderInApp(OnOffCell, {
label: 'Events: Create',
modelValue: false,
'onUpdate:modelValue': (value: boolean) => seen.push(value),
})
expect(wrapper.text()).toContain('Off')
await wrapper.find('[role="switch"]').trigger('click')
expect(seen).toEqual([true])
})
it('shows the lock reason and cannot be toggled when locked', async () => {
const seen: boolean[] = []
wrapper = renderInApp(OnOffCell, {
label: 'Events: Create',
modelValue: false,
disabled: true,
reason: 'Only the owner can grant this.',
'onUpdate:modelValue': (value: boolean) => seen.push(value),
})
await wrapper.find('[role="switch"]').trigger('click')
expect(wrapper.text()).toContain('Only the owner can grant this.')
expect(seen).toEqual([])
})
})