feat: SPA Roles screens
This commit is contained in:
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([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user