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 = {}) { const value = ref(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([]) }) })