test(10-05): bring every SPA module, composable and component under Vitest

- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
  covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
  left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 17:53:57 +02:00
parent 8259a456bb
commit 1c2a66df45
61 changed files with 4299 additions and 91 deletions

View File

@@ -102,10 +102,16 @@ export const winterIcons: Readonly<Record<string, string>> = {
/** Neutral icon for names neither lucide map knows. */ /** Neutral icon for names neither lucide map knows. */
export const fallbackIcon: Component = Square export const fallbackIcon: Component = Square
/** An own entry of a map: names come from the server, so inherited object
* members such as "constructor" or "toString" never count as icons. */
function own<T>(map: Readonly<Record<string, T>>, key: string): T | undefined {
return Object.hasOwn(map, key) ? map[key] : undefined
}
export function iconFor(name: string | null | undefined): Component { export function iconFor(name: string | null | undefined): Component {
if (!name) { if (!name) {
return fallbackIcon return fallbackIcon
} }
const key = name.trim() const key = name.trim()
return icons[key] ?? icons[winterIcons[key] ?? ''] ?? fallbackIcon return own(icons, key) ?? own(icons, own(winterIcons, key) ?? '') ?? fallbackIcon
} }

View File

@@ -0,0 +1,69 @@
// Shared props and helpers of the field controls (D-05). Kept apart from the
// registry, which imports every control: a control importing the registry
// would close an import cycle.
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
/** Props every field control receives from FieldRenderer. */
export interface FieldControlProps {
field: FormField
modelValue: unknown
/** id of the focusable control; the label points at it. */
controlId: string
invalid?: boolean
/** Space-separated ids of the comment and error lines. */
describedBy?: string
/** Display labels of the field's current relation values (meta.labels). */
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
/** Id of the record being edited; null on create and on settings pages. */
recordId?: number | null
}
// Winter `attributes:` are HTML attributes on the control. Only these are
// passed through; anything else (event handlers included) is dropped.
const allowedAttributes = new Set([
'readonly',
'placeholder',
'maxlength',
'minlength',
'min',
'max',
'step',
'autocomplete',
'spellcheck',
'pattern',
])
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {}
for (const [name, value] of Object.entries(field.attributes ?? {})) {
const key = name.toLowerCase()
if (!allowedAttributes.has(key) || value === null || value === false) {
continue
}
out[key] = value
}
return out
}
/** Shared control styling: 44px, radius 10, danger border when invalid. */
export function controlClass(invalid: boolean | undefined): string {
return [
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
invalid ? 'border-danger' : 'border-border-strong',
].join(' ')
}
/** Truthiness of a stored toggle value (true, 1, "1", "true"). */
export function toggleOn(value: unknown): boolean {
if (typeof value === 'string') {
return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
}
return Boolean(value)
}
/** A toggle's next value in the stored value's own type (1/0 or boolean). */
export function toggleValue(current: unknown, on: boolean): boolean | number {
return typeof current === 'number' ? (on ? 1 : 0) : on
}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { toggleOn, toggleValue, type FieldControlProps } from '../registry' import { toggleOn, toggleValue, type FieldControlProps } from '../control'
// Toggle card (design screen 4) with a 20px checkbox on the right. // Toggle card (design screen 4) with a 20px checkbox on the right.
const props = defineProps<FieldControlProps>() const props = defineProps<FieldControlProps>()

View File

@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { ChevronDown } from '@lucide/vue' import { ChevronDown } from '@lucide/vue'
import type { FormOption } from '../../../api/types' import type { FormOption } from '../../../api/types'
import { t } from '../../../app/i18n' import { t } from '../../../app/i18n'
import { controlClass, type FieldControlProps } from '../registry' import { controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>() const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>() const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
@@ -63,7 +63,9 @@ function onChange(event: Event): void {
@change="onChange" @change="onChange"
> >
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option> <option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
<option v-else-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled> <!-- A stored value no option matches shows the placeholder, never a
wrong choice (also next to an emptyOption). -->
<option v-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
{{ t('backend::lang.form.select_placeholder') }} {{ t('backend::lang.form.select_placeholder') }}
</option> </option>
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text"> <option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry' import { controlAttributes, controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>() const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>() const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>()

View File

@@ -4,7 +4,7 @@ import { ChevronDown, X } from '@lucide/vue'
import { api } from '../../../api/client' import { api } from '../../../api/client'
import type { RelationOption } from '../../../api/types' import type { RelationOption } from '../../../api/types'
import { t } from '../../../app/i18n' import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry' import type { FieldControlProps } from '../control'
// Relation field (D-17, D-18, D-26), three modes from the schema: // Relation field (D-17, D-18, D-26), three modes from the schema:
// readOnly the label from meta.labels only, no control // readOnly the label from meta.labels only, no control

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { toggleOn, toggleValue, type FieldControlProps } from '../registry' import { toggleOn, toggleValue, type FieldControlProps } from '../control'
// Toggle card (design screen 4): the label as title, the comment as helper // Toggle card (design screen 4): the label as title, the comment as helper
// text and a 44x26 role="switch" control; the off track uses borderStrong. // text and a 44x26 role="switch" control; the off track uses borderStrong.

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry' import { controlAttributes, controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>() const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry' import { controlAttributes, controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>() const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -2,7 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { Puzzle } from '@lucide/vue' import { Puzzle } from '@lucide/vue'
import { t } from '../../../app/i18n' import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry' import type { FieldControlProps } from '../control'
// D-05 fallback for any field type without a renderer. The translated // D-05 fallback for any field type without a renderer. The translated
// sentence is split around the type so the type renders in DM Mono, as text. // sentence is split around the type so the type renders in DM Mono, as text.

View File

@@ -5,7 +5,6 @@
// relation through its own endpoints and renders only on an existing record. // relation through its own endpoints and renders only on an existing record.
// Phase 10.1 turns this seam into the plugin extension point. // Phase 10.1 turns this seam into the plugin extension point.
import type { Component } from 'vue' import type { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue' import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue' import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue' import NumberField from './fields/NumberField.vue'
@@ -16,22 +15,11 @@ import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue' import TextareaField from './fields/TextareaField.vue'
import UnsupportedField from './fields/UnsupportedField.vue' import UnsupportedField from './fields/UnsupportedField.vue'
/** Props every field control receives from FieldRenderer. */ // The control helpers live in ./control so the field components never import
export interface FieldControlProps { // this module: registry -> field -> registry would be an import cycle whose
field: FormField // evaluation order decides whether a renderer is registered at all.
modelValue: unknown export { controlAttributes, controlClass, toggleOn, toggleValue } from './control'
/** id of the focusable control; the label points at it. */ export type { FieldControlProps } from './control'
controlId: string
invalid?: boolean
/** Space-separated ids of the comment and error lines. */
describedBy?: string
/** Display labels of the field's current relation values (meta.labels). */
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
/** Id of the record being edited; null on create and on settings pages. */
recordId?: number | null
}
/** The relation manager's field type (design screen 5). */ /** The relation manager's field type (design screen 5). */
export const RELATION_MANAGER = 'relation-manager' export const RELATION_MANAGER = 'relation-manager'
@@ -73,51 +61,3 @@ export function needsRecord(type: string): boolean {
export function ownsLabel(type: string): boolean { export function ownsLabel(type: string): boolean {
return selfLabelled.has(type) return selfLabelled.has(type)
} }
// Winter `attributes:` are HTML attributes on the control. Only these are
// passed through; anything else (event handlers included) is dropped.
const allowedAttributes = new Set([
'readonly',
'placeholder',
'maxlength',
'minlength',
'min',
'max',
'step',
'autocomplete',
'spellcheck',
'pattern',
])
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {}
for (const [name, value] of Object.entries(field.attributes ?? {})) {
const key = name.toLowerCase()
if (!allowedAttributes.has(key) || value === null || value === false) {
continue
}
out[key] = value
}
return out
}
/** Shared control styling: 44px, radius 10, danger border when invalid. */
export function controlClass(invalid: boolean | undefined): string {
return [
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
invalid ? 'border-danger' : 'border-border-strong',
].join(' ')
}
/** Truthiness of a stored toggle value (true, 1, "1", "true"). */
export function toggleOn(value: unknown): boolean {
if (typeof value === 'string') {
return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
}
return Boolean(value)
}
/** A toggle's next value in the stored value's own type (1/0 or boolean). */
export function toggleValue(current: unknown, on: boolean): boolean | number {
return typeof current === 'number' ? (on ? 1 : 0) : on
}

View File

@@ -11,7 +11,7 @@ import Pagination from '../list/Pagination.vue'
import Button from '../ui/Button.vue' import Button from '../ui/Button.vue'
import ConfirmDialog from '../ui/ConfirmDialog.vue' import ConfirmDialog from '../ui/ConfirmDialog.vue'
import { useConfirm } from '../ui/confirm' import { useConfirm } from '../ui/confirm'
import type { FieldControlProps } from '../form/registry' import type { FieldControlProps } from '../form/control'
import RelationPickerModal from './RelationPickerModal.vue' import RelationPickerModal from './RelationPickerModal.vue'
// Relation manager field (D-05, design screen 5). Renders only for an // Relation manager field (D-05, design screen 5). Renders only for an

View File

@@ -9,7 +9,7 @@ import { activeEntry, firstControllerPath } from '../../state/useNavigation'
import { settingsEntry } from '../../state/useSettings' import { settingsEntry } from '../../state/useSettings'
// Header breadcrumbs (design: Top header): plugin, controller and record // Header breadcrumbs (design: Top header): plugin, controller and record
// labels, e.g. "Płytarium › Albumy › Enigmatic". Parent crumbs are muted // labels, e.g. "Demo › Widgets › Blue widget". Parent crumbs are muted
// links; the current crumb uses the text colour at 600 with // links; the current crumb uses the text colour at 600 with
// aria-current="page". Separators are 14px chevron-right icons. // aria-current="page". Separators are 14px chevron-right icons.
interface Crumb { interface Crumb {

View File

@@ -2,9 +2,11 @@
/* The generated API document and types carry prose, not classes: keep them /* The generated API document and types carry prose, not classes: keep them
out of the class scan so an admin API change does not churn the built CSS out of the class scan so an admin API change does not churn the built CSS
in boardwalk/dist (D-04 drift check). */ in boardwalk/dist (D-04 drift check). Tests are not shipped either: words
in test code (a "blur" event, say) must never add utilities to the bundle. */
@source not "../api/schema.d.ts"; @source not "../api/schema.d.ts";
@source not "../../openapi"; @source not "../../openapi";
@source not "../../tests";
/* Self-hosted fonts (D-07). Whole-weight files carry latin and latin-ext with /* Self-hosted fonts (D-07). Whole-weight files carry latin and latin-ext with
unicode-range, so Polish diacritics and basic Latin both resolve. */ unicode-range, so Polish diacritics and basic Latin both resolve. */

View File

@@ -39,7 +39,11 @@ const schema = ref<ListSchema | null>(null)
const rows = ref<AdminRecord[]>([]) const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null) const meta = ref<ListMeta | null>(null)
const loading = ref(true) const loading = ref(true)
const failed = ref(false) // The schema and the rows load in parallel; either failing is announced, so a
// list response that arrives after a failed schema never hides the failure.
const schemaFailed = ref(false)
const listFailed = ref(false)
const failed = computed(() => schemaFailed.value || listFailed.value)
const selected = ref<RowId[]>([]) const selected = ref<RowId[]>([])
const deleting = ref(false) const deleting = ref(false)
const confirm = useConfirm() const confirm = useConfirm()
@@ -92,24 +96,22 @@ let generation = 0
async function loadList(): Promise<void> { async function loadList(): Promise<void> {
const current = ++generation const current = ++generation
loading.value = true loading.value = true
failed.value = false listFailed.value = false
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } }) const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
if (current !== generation) { if (current !== generation) {
return return
} }
rows.value = result.data?.data ?? [] rows.value = result.data?.data ?? []
meta.value = result.data?.meta ?? null meta.value = result.data?.meta ?? null
failed.value = !result.data listFailed.value = !result.data
loading.value = false loading.value = false
} }
async function load(): Promise<void> { async function load(): Promise<void> {
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } }) const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
schema.value = result.data?.data ?? null schema.value = result.data?.data ?? null
if (!schema.value) { schemaFailed.value = !schema.value
failed.value = true
loading.value = false
}
} }
void load() void load()

View File

@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api, onRefreshed, onUnauthorized, refreshSession, REQUESTED_WITH } from '../../src/api/client'
import { API, json, pathOf } from '../helpers'
const unauthenticated = { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } }
const refreshed = (expiresIn: unknown) => json(200, { data: { token_type: 'cookie', expires_in: expiresIn }, meta: {} })
const navigation = { data: [], meta: {} }
/** fetch mock driven by a handler; every request is recorded. */
function serve(handler: (request: Request) => Response | Promise<Response>): Request[] {
const seen: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
seen.push(request)
return handler(request)
})
return seen
}
const count = (seen: Request[], path: string) => seen.filter((request) => pathOf(request) === `${API}${path}`).length
beforeEach(() => {
onUnauthorized(null)
onRefreshed(null)
})
afterEach(() => {
onUnauthorized(null)
onRefreshed(null)
})
describe('transport', () => {
it('sends X-Requested-With and same-origin credentials on every request to the runtime API base', async () => {
const seen = serve(() => json(200, navigation))
await api.GET('/navigation')
await api.POST('/auth/login', { body: { login: 'dev', password: 'x' } })
expect(REQUESTED_WITH).toBe('XMLHttpRequest')
for (const request of seen) {
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(request.credentials).toBe('same-origin')
expect(pathOf(request).startsWith(API)).toBe(true)
}
expect(seen.map(pathOf)).toEqual([`${API}/navigation`, `${API}/auth/login`])
})
it('passes a 401 from login or refresh through without refreshing', async () => {
const seen = serve(() => json(401, unauthenticated))
const handler = vi.fn()
onUnauthorized(handler)
const login = await api.POST('/auth/login', { body: { login: 'dev', password: 'bad' } })
const refresh = await api.POST('/auth/refresh')
expect(login.response.status).toBe(401)
expect(refresh.response.status).toBe(401)
expect(count(seen, '/auth/refresh')).toBe(1)
expect(handler).not.toHaveBeenCalled()
})
it('leaves other statuses alone', async () => {
const seen = serve(() => json(403, { error: { code: 'forbidden', message: 'no', details: {} } }))
const handler = vi.fn()
onUnauthorized(handler)
const result = await api.GET('/navigation')
expect(result.response.status).toBe(403)
expect(seen).toHaveLength(1)
expect(handler).not.toHaveBeenCalled()
})
it('single-flights one refresh for concurrent 401s and replays each request once with its body', async () => {
let session = false
const seen = serve(async (request) => {
if (pathOf(request) === `${API}/auth/refresh`) {
await new Promise((resolve) => setTimeout(resolve, 5))
session = true
return refreshed(900)
}
if (!session) {
return json(401, unauthenticated)
}
if (request.method === 'PUT') {
return json(200, { data: await request.json(), meta: { labels: {} } })
}
return json(200, navigation)
})
const lifetimes: Array<number | null> = []
onRefreshed((seconds) => lifetimes.push(seconds))
const handler = vi.fn()
onUnauthorized(handler)
const path = { vendor: 'acme', plugin: 'demo', controller: 'widgets', id: 1 }
const [a, b, put] = await Promise.all([
api.GET('/navigation'),
api.GET('/navigation'),
api.PUT('/{vendor}/{plugin}/{controller}/{id}', { params: { path }, body: { name: 'Replayed' } }),
])
expect(a.response.status).toBe(200)
expect(b.response.status).toBe(200)
expect(put.data?.data).toEqual({ name: 'Replayed' })
expect(count(seen, '/auth/refresh')).toBe(1)
expect(count(seen, '/navigation')).toBe(4)
expect(count(seen, '/acme/demo/widgets/1')).toBe(2)
expect(lifetimes).toEqual([900])
expect(handler).not.toHaveBeenCalled()
})
it('reports the session gone when the refresh fails, without replaying', async () => {
const seen = serve((request) =>
pathOf(request) === `${API}/auth/refresh` ? json(401, unauthenticated) : json(401, unauthenticated),
)
const handler = vi.fn()
onUnauthorized(handler)
const result = await api.GET('/navigation')
expect(result.response.status).toBe(401)
expect(count(seen, '/navigation')).toBe(1)
expect(handler).toHaveBeenCalledTimes(1)
})
it('reports the session gone when the replay is still 401, and replays only once', async () => {
const seen = serve((request) => (pathOf(request) === `${API}/auth/refresh` ? refreshed(60) : json(401, unauthenticated)))
const handler = vi.fn()
onUnauthorized(handler)
const result = await api.GET('/navigation')
expect(result.response.status).toBe(401)
expect(count(seen, '/navigation')).toBe(2)
expect(count(seen, '/auth/refresh')).toBe(1)
expect(handler).toHaveBeenCalledTimes(1)
})
})
describe('refreshSession', () => {
it('shares one in-flight request between concurrent callers and starts a new one afterwards', async () => {
const seen = serve(async () => {
await new Promise((resolve) => setTimeout(resolve, 5))
return refreshed(120)
})
const first = refreshSession()
const second = refreshSession()
expect(second).toBe(first)
expect(await first).toBe(true)
expect(count(seen, '/auth/refresh')).toBe(1)
expect(await refreshSession()).toBe(true)
expect(count(seen, '/auth/refresh')).toBe(2)
})
it('reports a missing lifetime as null', async () => {
serve(() => refreshed('soon'))
const lifetimes: Array<number | null> = []
onRefreshed((seconds) => lifetimes.push(seconds))
expect(await refreshSession()).toBe(true)
expect(lifetimes).toEqual([null])
})
it('answers false on an error status or a network failure', async () => {
serve(() => json(500, { error: { code: 'server', message: 'x', details: {} } }))
expect(await refreshSession()).toBe(false)
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network'))
expect(await refreshSession()).toBe(false)
})
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
controllerIdFromParams,
controllerIdFromPath,
controllerPath,
parseControllerId,
pluginKey,
} from '../../src/app/controllerRoutes'
describe('controller routes (D-10)', () => {
it('parses a three-segment controller id', () => {
expect(parseControllerId('acme.demo.widgets')).toEqual({ vendor: 'acme', plugin: 'demo', controller: 'widgets' })
expect(parseControllerId('a_b.c-d.E9')).toEqual({ vendor: 'a_b', plugin: 'c-d', controller: 'E9' })
})
it.each(['', 'acme.demo', 'acme.demo.widgets.extra', 'acme..widgets', 'acme.demo.wid gets', 'acme/demo/widgets', '../x.y.z'])(
'rejects %j',
(id) => {
expect(parseControllerId(id)).toBeNull()
expect(controllerPath(id)).toBeNull()
expect(pluginKey(id)).toBeNull()
},
)
it('maps ids and paths both ways', () => {
expect(controllerPath('acme.demo.widgets')).toBe('/acme/demo/widgets')
expect(controllerIdFromParams({ vendor: 'acme', plugin: 'demo', controller: 'widgets' })).toBe('acme.demo.widgets')
expect(controllerIdFromPath('/acme/demo/widgets')).toBe('acme.demo.widgets')
expect(controllerIdFromPath('/acme/demo/widgets/12')).toBe('acme.demo.widgets')
expect(controllerIdFromPath('acme/demo/widgets/')).toBe('acme.demo.widgets')
expect(controllerIdFromPath('/settings')).toBeNull()
expect(controllerIdFromPath('/a/b')).toBeNull()
expect(controllerIdFromPath('/a/b c/d')).toBeNull()
})
it('names the owning plugin of a controller', () => {
expect(pluginKey('acme.demo.widgets')).toBe('acme.demo')
})
})

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
currentLocale,
interpolate,
loadStrings,
message,
pluralCategory,
setBundle,
t,
tc,
} from '../../src/app/i18n'
import { API, mockApi, requestsTo } from '../helpers'
import { langFixture } from '../fixtures/typed'
beforeEach(() => {
setBundle(langFixture.data, 'pl')
})
describe('interpolate (parity with phrasebook.interpolate)', () => {
it('replaces :name, :Name and :NAME with the plain, first-upper and upper value', () => {
expect(interpolate(':name / :Name / :NAME', { name: 'ada' })).toBe('ada / Ada / ADA')
})
it('replaces the longer placeholder first so :names never loses to :name', () => {
expect(interpolate(':names and :name', { name: 'one', names: 'many' })).toBe('many and one')
expect(interpolate(':name and :names', { names: 'many', name: 'one' })).toBe('one and many')
})
it('accepts a leading colon on the parameter name and skips an empty name', () => {
expect(interpolate('Hello :who', { ':who': 'world' })).toBe('Hello world')
expect(interpolate('Hello :', { ':': 'x', '': 'y' })).toBe('Hello :')
})
it('upper-cases the first code point, not the first UTF-16 unit', () => {
expect(interpolate(':Name', { name: 'łódź' })).toBe('Łódź')
expect(interpolate(':Name', { name: '𝒶bc' })).toBe('𝒶bc')
})
it('stringifies numbers and leaves text without placeholders or params alone', () => {
expect(interpolate(':count items', { count: 3 })).toBe('3 items')
expect(interpolate('plain', { name: 'x' })).toBe('plain')
expect(interpolate(':name')).toBe(':name')
expect(interpolate('', { name: 'x' })).toBe('')
})
})
describe('plural selection', () => {
it.each([
[1, 'one'],
[2, 'few'],
[5, 'many'],
[22, 'few'],
[25, 'many'],
])('pl %i is %s', (count, category) => {
expect(pluralCategory(count, 'pl')).toBe(category)
})
it.each([
[1, 'one'],
[2, 'other'],
])('en %i is %s', (count, category) => {
expect(pluralCategory(count, 'en')).toBe(category)
})
it('answers other for a locale Intl cannot parse', () => {
expect(pluralCategory(1, '!!invalid!!')).toBe('other')
})
it('uses the bundle locale by default', () => {
setBundle(langFixture.data, 'en')
expect(pluralCategory(2)).toBe('other')
setBundle(langFixture.data, 'pl')
expect(pluralCategory(2)).toBe('few')
})
it('picks the form for the count and falls back to other when that form is missing', () => {
const forms = { one: ':count plik', other: ':count pliku' }
expect(message(forms, 1)).toBe('1 plik')
expect(message(forms, 5)).toBe('5 pliku')
expect(message(forms, 2, { count: 'dwa' })).toBe('dwa pliku')
})
it('renders other without a count and nothing without forms', () => {
expect(message({ other: 'Hi :name', one: 'x' }, undefined, { name: 'Ada' })).toBe('Hi Ada')
expect(message(null)).toBe('')
expect(message(undefined, 3)).toBe('')
expect(message({ one: 'only one' }, 5)).toBe('')
})
})
describe('bundle lookups', () => {
it('t returns the key itself when the key is not in the bundle', () => {
expect(t('backend::lang.missing.key')).toBe('backend::lang.missing.key')
expect(tc('backend::lang.missing.key', 3)).toBe('backend::lang.missing.key')
})
it('t interpolates the other form and tc the plural form', () => {
expect(t('backend::lang.form.remove_item', { name: 'Ada' })).toBe('Usuń: Ada')
expect(tc('backend::lang.list.results', 1)).toBe('1 wynik')
expect(tc('backend::lang.list.results', 3)).toBe('3 wyniki')
expect(tc('backend::lang.list.results', 7)).toBe('7 wyników')
})
it('setBundle replaces the strings and sets the document language', () => {
setBundle({ 'backend::lang.auth.title': { other: 'Welcome back' } }, 'en')
expect(t('backend::lang.auth.title')).toBe('Welcome back')
expect(t('backend::lang.auth.submit')).toBe('backend::lang.auth.submit')
expect(document.documentElement.lang).toBe('en')
expect(currentLocale.value).toBe('en')
})
it('setBundle without a locale keeps the current locale', () => {
setBundle({}, 'pl')
setBundle({ a: { other: 'b' } })
expect(currentLocale.value).toBe('pl')
expect(t('a')).toBe('b')
})
})
describe('loadStrings', () => {
it('loads GET /lang and adopts meta.locale', async () => {
const calls = mockApi({
[`GET ${API}/lang`]: { body: { data: { 'backend::lang.auth.title': { other: 'Hello' } }, meta: { locale: 'en' } } },
})
await loadStrings()
expect(requestsTo(calls, 'GET', `${API}/lang`)).toHaveLength(1)
expect(t('backend::lang.auth.title')).toBe('Hello')
expect(currentLocale.value).toBe('en')
})
it('keeps the current strings when the bundle cannot be loaded', async () => {
mockApi({ [`GET ${API}/lang`]: { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } } })
await loadStrings()
expect(t('backend::lang.auth.title')).toBe('Witaj ponownie')
expect(currentLocale.value).toBe('pl')
})
})

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { Archive, Disc3, Settings, Square, Tags } from '@lucide/vue'
import { fallbackIcon, iconFor, icons, winterIcons } from '../../src/app/icons'
describe('iconFor', () => {
it('resolves lucide names', () => {
expect(iconFor('disc-3')).toBe(Disc3)
expect(iconFor('tags')).toBe(Tags)
expect(iconFor(' tags ')).toBe(Tags)
})
it('resolves Winter icon aliases', () => {
expect(iconFor('icon-archive')).toBe(Archive)
expect(iconFor('icon-cog')).toBe(Settings)
})
it('falls back to the neutral square for unknown, empty or missing names', () => {
expect(fallbackIcon).toBe(Square)
expect(iconFor('unknown-icon-name')).toBe(Square)
expect(iconFor('icon-unknown')).toBe(Square)
expect(iconFor('')).toBe(Square)
expect(iconFor(null)).toBe(Square)
expect(iconFor(undefined)).toBe(Square)
expect(iconFor('constructor')).toBe(Square)
expect(iconFor('toString')).toBe(Square)
expect(iconFor('__proto__')).toBe(Square)
})
it('maps every Winter alias onto a registered lucide icon', () => {
for (const [alias, name] of Object.entries(winterIcons)) {
expect(icons[name], alias).toBeDefined()
}
})
})

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { listQueryKey, parseListQuery, toListQuery } from '../../src/app/listQuery'
describe('parseListQuery', () => {
it('reads search, sort, dir, page, per_page and filter[<name>]', () => {
expect(
parseListQuery({
search: ' blue ',
sort: 'name',
dir: 'desc',
page: '3',
per_page: '50',
'filter[active]': 'true',
'filter[created]': '2026-01-01..2026-02-01',
}),
).toEqual({
search: 'blue',
sort: 'name',
dir: 'desc',
page: 3,
per_page: 50,
filter: { active: 'true', created: '2026-01-01..2026-02-01' },
})
})
it.each(['0', '-1', '1.5', 'abc', '', '01', ' 2'])('drops the invalid page and per_page %j', (value) => {
const out = parseListQuery({ page: value, per_page: value })
expect(out.page).toBeUndefined()
expect(out.per_page).toBeUndefined()
})
it('defaults the direction to asc with a sort and ignores a direction without one', () => {
expect(parseListQuery({ sort: 'name', dir: 'sideways' })).toEqual({ sort: 'name', dir: 'asc' })
expect(parseListQuery({ dir: 'desc' })).toEqual({})
expect(parseListQuery({ sort: ' ' })).toEqual({})
})
it('takes the last value of a repeated key and drops empty filters and non-filter keys', () => {
expect(parseListQuery({ search: ['a', 'b'], 'filter[x]': '', 'filter[]': 'y', filters: 'z', other: '1' })).toEqual({
search: 'b',
})
expect(parseListQuery({ page: ['2', null] })).toEqual({})
})
})
describe('toListQuery', () => {
it('writes only meaningful values and omits page 1', () => {
expect(toListQuery({})).toEqual({})
expect(toListQuery({ page: 1, search: '' })).toEqual({})
expect(toListQuery({ page: 2, per_page: 10 })).toEqual({ page: '2', per_page: '10' })
expect(toListQuery({ sort: 'code' })).toEqual({ sort: 'code', dir: 'asc' })
expect(toListQuery({ filter: { b: '2', a: '1', c: '' } })).toEqual({ 'filter[a]': '1', 'filter[b]': '2' })
})
it('round-trips through parseListQuery', () => {
const input = {
search: 'blue',
sort: 'name',
dir: 'desc' as const,
page: 4,
per_page: 25,
filter: { active: 'false', maker: '2' },
}
const raw = toListQuery(input)
const back = parseListQuery(raw as Record<string, string>)
expect(back).toEqual(input)
})
it('keys equal queries alike regardless of filter order', () => {
expect(listQueryKey({ filter: { a: '1', b: '2' } })).toBe(listQueryKey({ filter: { b: '2', a: '1' } }))
expect(listQueryKey({ page: 1 })).toBe(listQueryKey({}))
expect(listQueryKey({ page: 2 })).not.toBe(listQueryKey({}))
})
})

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryHistory } from 'vue-router'
import { createAdminRouter, safeRedirect } from '../../src/app/router'
import { setNavigation } from '../../src/state/useNavigation'
import { navigationFixture } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
beforeEach(() => {
resetState()
})
describe('safeRedirect', () => {
it('accepts in-app paths with exactly one leading slash', () => {
expect(safeRedirect('/acme/demo/widgets')).toBe('/acme/demo/widgets')
expect(safeRedirect('/acme/demo/widgets/1?tab=2#x')).toBe('/acme/demo/widgets/1?tab=2#x')
expect(safeRedirect('/')).toBe('/')
})
it.each([
['protocol-relative', '//evil.example/x'],
['backslash protocol-relative', '/\\evil.example'],
['absolute http', 'http://evil.example/'],
['absolute https', 'https://evil.example/'],
['javascript', 'javascript:alert(1)'],
['relative', 'acme/demo'],
['empty', ''],
])('rejects a %s value', (_label, value) => {
expect(safeRedirect(value)).toBeNull()
})
it('rejects non-string values', () => {
expect(safeRedirect(undefined)).toBeNull()
expect(safeRedirect(null)).toBeNull()
expect(safeRedirect(['/a'])).toBeNull()
expect(safeRedirect(1)).toBeNull()
})
})
describe('route guard', () => {
it('sends a visitor without a session to login with the requested path', async () => {
const router = await routerAt('/acme/demo/widgets/3?x=1')
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets/3?x=1')
})
it('lets a visitor open the login screen', async () => {
const router = await routerAt('/login')
expect(router.currentRoute.value.name).toBe('login')
})
it('moves a signed-in admin away from login to a safe redirect', async () => {
await signIn()
const router = await routerAt('/login?redirect=/acme/demo/gadgets')
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/gadgets')
})
it.each(['//evil.example/x', 'https://evil.example/', '/\\evil'])(
'ignores the unsafe redirect %j and goes home',
async (redirect) => {
await signIn()
setNavigation(navigationFixture.data)
const router = createAdminRouter(createMemoryHistory())
await router.push({ path: '/login', query: { redirect } })
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
},
)
it('lands home on the first permitted controller, or on the empty page without navigation', async () => {
await signIn()
setNavigation(navigationFixture.data)
expect((await routerAt('/')).currentRoute.value.fullPath).toBe('/acme/demo/widgets')
setNavigation([])
const empty = await routerAt('/')
expect(empty.currentRoute.value.name).toBe('home')
})
it('names the list, create, record, settings and not-found routes', async () => {
await signIn()
const cases: Array<[string, string]> = [
['/acme/demo/widgets', 'list'],
['/acme/demo/widgets/create', 'create'],
['/acme/demo/widgets/12', 'record'],
['/acme/demo/widgets/abc', 'not-found'],
['/settings', 'settings'],
['/settings/mail', 'settings-form'],
['/nowhere', 'not-found'],
]
for (const [path, name] of cases) {
const router = await routerAt(path)
expect(router.currentRoute.value.name, path).toBe(name)
expect(router.currentRoute.value.meta.shell, path).toBe(true)
}
expect((await routerAt('/acme/demo/widgets/12')).currentRoute.value.params.id).toBe('12')
})
})

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_BASE, readBase, runtime } from '../../src/app/runtime'
function docWith(content: string | null): Document {
const doc = document.implementation.createHTMLDocument('test')
if (content !== null) {
const meta = doc.createElement('meta')
meta.setAttribute('name', 'summer-admin-base')
meta.setAttribute('content', content)
doc.head.appendChild(meta)
}
return doc
}
describe('runtime base', () => {
it('derives the router base and the API base from the served meta', () => {
expect(runtime.base).toBe('/admin-test')
expect(runtime.api).toBe('/admin-test/api/v1')
})
it('reads a custom prefix and trims trailing slashes and whitespace', () => {
expect(readBase(docWith('/acp'))).toBe('/acp')
expect(readBase(docWith(' /acp/panel// '))).toBe('/acp/panel')
})
it.each([
['a missing meta', null],
['an empty value', ''],
['the unreplaced build token', '__SUMMER_ADMIN_BASE__'],
['a relative path', 'backend'],
['an absolute URL', 'https://evil.example/backend'],
['the root alone', '/'],
['slashes only', '///'],
])('falls back to the default for %s', (_label, content) => {
expect(readBase(docWith(content))).toBe(DEFAULT_BASE)
})
it('uses /backend as the default', () => {
expect(DEFAULT_BASE).toBe('/backend')
})
})

View File

@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { applyColorScheme, DARK_QUERY } from '../../src/app/theme'
import { fakeMedia } from '../helpers'
afterEach(() => {
document.documentElement.classList.remove('dark')
})
describe('applyColorScheme (A6: system preference only)', () => {
it('sets .dark from prefers-color-scheme and follows changes until unsubscribed', () => {
const media = fakeMedia({ [DARK_QUERY]: true })
const stop = applyColorScheme()
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(media.listeners(DARK_QUERY)).toBe(1)
media.change(DARK_QUERY, false)
expect(document.documentElement.classList.contains('dark')).toBe(false)
media.change(DARK_QUERY, true)
expect(document.documentElement.classList.contains('dark')).toBe(true)
stop()
expect(media.listeners(DARK_QUERY)).toBe(0)
media.change(DARK_QUERY, false)
expect(document.documentElement.classList.contains('dark')).toBe(true)
})
it('stays light without matchMedia', () => {
document.documentElement.classList.add('dark')
const win = { document } as unknown as Window
const stop = applyColorScheme(win)
expect(document.documentElement.classList.contains('dark')).toBe(false)
expect(stop()).toBeUndefined()
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { mapWinterUrl } from '../../src/app/winterUrl'
const ID = 'acme.demo.widgets'
const LIST = '/acme/demo/widgets'
describe('mapWinterUrl', () => {
it('maps an empty, null or missing URL to the list', () => {
expect(mapWinterUrl('', ID)).toBe(LIST)
expect(mapWinterUrl(null, ID)).toBe(LIST)
expect(mapWinterUrl(undefined, ID)).toBe(LIST)
expect(mapWinterUrl(' ', ID)).toBe(LIST)
})
it('maps the controller root and create', () => {
expect(mapWinterUrl('acme/demo/widgets', ID)).toBe(LIST)
expect(mapWinterUrl('/acme/demo/widgets/', ID)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/create', ID)).toBe(`${LIST}/create`)
})
it('substitutes :id in update/:id and accepts a literal numeric id', () => {
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, 42)).toBe(`${LIST}/42`)
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, '7')).toBe(`${LIST}/7`)
expect(mapWinterUrl('acme/demo/widgets/update/9', ID)).toBe(`${LIST}/9`)
})
it('falls back to the list when update has no usable id', () => {
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, null)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, 'abc')).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/update/1e3', ID)).toBe(LIST)
})
it('never leaves the current controller for a foreign or absolute URL (T-10-20)', () => {
expect(mapWinterUrl('acme/demo/gadgets/update/:id', ID, 1)).toBe(LIST)
expect(mapWinterUrl('https://evil.example/acme/demo/widgets/create', ID)).toBe(LIST)
expect(mapWinterUrl('//evil.example/x', ID)).toBe(LIST)
expect(mapWinterUrl('javascript:alert(1)', ID)).toBe(LIST)
})
it('ignores the query and hash, matches the owner case-insensitively and rejects unknown actions', () => {
expect(mapWinterUrl('acme/demo/widgets/create?x=1#top', ID)).toBe(`${LIST}/create`)
expect(mapWinterUrl('Acme/Demo/Widgets/update/:id', ID, 3)).toBe(`${LIST}/3`)
expect(mapWinterUrl('acme/demo/widgets/preview/3', ID)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/update/3/extra', ID)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/CREATE', ID)).toBe(LIST)
})
it('answers the root for a malformed controller id', () => {
expect(mapWinterUrl('acme/demo/widgets', 'acme.demo')).toBe('/')
expect(mapWinterUrl('acme/demo/widgets', 'acme.demo.wid gets')).toBe('/')
})
})

74
admin/tests/fixtures/typed.ts vendored Normal file
View File

@@ -0,0 +1,74 @@
// Typed views of the JSON fixtures. Each fixture is assigned to the generated
// OpenAPI schema type of the response it stands for, so a change to the
// admin document that the fixtures no longer match fails `npm run typecheck`
// instead of letting the tests drift from the wire (D-15, D-16). Fixtures are
// neutral acme.demo.* data; no application names appear in framework tests.
import type { components } from '../../src/api/schema'
import langJson from './lang.json'
import navigationJson from './navigation.json'
import settingsJson from './settings.json'
import formSchemaJson from './widgets.form-schema.json'
import listJson from './widgets.list.json'
import listSchemaJson from './widgets.list-schema.json'
import optionsJson from './widgets.options.json'
import recordJson from './widgets.record.json'
import candidatesJson from './widgets.relation-candidates.json'
import linkedJson from './widgets.relation-linked.json'
import relationSchemaJson from './widgets.relation-schema.json'
type S = components['schemas']
type Rows = S['cabana.ListEnvelope-array_cabana_AdminRecord']
export const langFixture: S['cabana.Envelope-cabana_LangBundle'] = langJson
export const navigationFixture: S['cabana.Envelope-array_cabana_NavigationEntry'] = navigationJson
export const settingsFixture: {
list: S['cabana.Envelope-array_cabana_SettingsEntry']
schema: S['cabana.Envelope-cabana_FormView']
values: S['cabana.Envelope-cabana_SettingsResult']
} = settingsJson
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
export const listFixture: Rows = listJson
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
export const optionsFixture: {
maker: S['cabana.Envelope-array_cabana_FilterOption']
makerField: S['cabana.ListEnvelope-array_cabana_RelationOption']
makerFieldPage2: S['cabana.ListEnvelope-array_cabana_RelationOption']
makerFieldSearch: S['cabana.ListEnvelope-array_cabana_RelationOption']
tagsField: S['cabana.ListEnvelope-array_cabana_RelationOption']
} = optionsJson
export const recordFixture: S['cabana.RecordEnvelope'] = recordJson
export const relationSchemaFixture: S['cabana.Envelope-cabana_RelationSchema'] = relationSchemaJson
export const linkedFixture: { all: Rows; search: Rows; empty: Rows } = linkedJson
export const candidatesFixture: { page1: Rows; page2: Rows; search: Rows; empty: Rows } = candidatesJson
/** A deep copy, so a test can reshape a fixture without touching the others. */
export function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
/** The form schema's field by name; throws when the fixture lost it. */
export function formField(name: string): S['cabana.FormField'] {
const field = formSchemaFixture.data.fields.find((item) => item.name === name)
if (!field) {
throw new Error(`fixture field ${name} missing`)
}
return clone(field)
}
/** The list schema's column by key; throws when the fixture lost it. */
export function listColumn(key: string): S['cabana.ListColumn'] {
const column = listSchemaFixture.data.columns.find((item) => item.key === key)
if (!column) {
throw new Error(`fixture column ${key} missing`)
}
return clone(column)
}
/** The list schema's filter by name; throws when the fixture lost it. */
export function listFilter(name: string): S['cabana.ListFilter'] {
const filter = listSchemaFixture.data.filters.find((item) => item.name === name)
if (!filter) {
throw new Error(`fixture filter ${name} missing`)
}
return clone(filter)
}

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import FieldRenderer from '../../src/components/form/FieldRenderer.vue'
import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue'
import FormField from '../../src/components/form/FormField.vue'
import { formField } from '../fixtures/typed'
import { resetState } from '../helpers'
beforeEach(() => {
resetState()
})
describe('form field', () => {
it('links the label to the control and hides the required asterisk from assistive tech', () => {
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: 'Blue', idPrefix: 'f' } })
const label = wrapper.find('label')
expect(label.attributes('for')).toBe('f-name')
expect(label.text()).toBe('Name *')
expect(label.find('[aria-hidden="true"]').text()).toBe('*')
const input = wrapper.find('#f-name')
expect(input.attributes('aria-required')).toBe('true')
expect(input.attributes('aria-invalid')).toBeUndefined()
expect(input.attributes('aria-describedby')).toBeUndefined()
})
it('describes the control by its comment and, when invalid, by its error line', () => {
const wrapper = mount(FormField, {
props: { field: formField('code'), modelValue: '', errors: ['Taken', 'Too short'], idPrefix: 'f' },
})
const input = wrapper.find('#f-code')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBe('f-code-comment f-code-error')
expect(wrapper.find('#f-code-comment').text()).toBe('Unique widget code')
expect(wrapper.findAll('#f-code-error p').map((line) => line.text())).toEqual(['Taken', 'Too short'])
})
it('uses the field name without a label and the default id prefix', () => {
const field = { ...formField('code'), label: '', comment: undefined }
const wrapper = mount(FormField, { props: { field, modelValue: '' } })
expect(wrapper.find('label').text()).toBe('code')
expect(wrapper.find('label').attributes('for')).toBe('field-code')
})
it('lets a toggle card render its own label and still points at its comment', () => {
const wrapper = mount(FormField, { props: { field: formField('active'), modelValue: false, errors: ['Bad'], idPrefix: 'f' } })
expect(wrapper.findAll('label')).toHaveLength(1)
expect(wrapper.find('label').attributes('for')).toBe('f-active')
expect(wrapper.find('[role="switch"]').attributes('aria-describedby')).toBe('f-active-comment f-active-error')
})
it('forwards value updates from the control', async () => {
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: '', idPrefix: 'f' } })
await wrapper.find('input').setValue('New')
expect(wrapper.emitted('update:modelValue')).toEqual([['New']])
})
})
describe('field renderer', () => {
it('picks the control by type and forwards the shared props and updates', async () => {
const wrapper = mount(FieldRenderer, {
props: { field: formField('weight'), modelValue: 3, controlId: 'x', invalid: true, describedBy: 'x-error' },
})
const input = wrapper.find('input[type="number"]')
expect(input.attributes('id')).toBe('x')
expect(input.attributes('aria-invalid')).toBe('true')
expect(input.attributes('aria-describedby')).toBe('x-error')
await input.setValue('7')
expect(wrapper.emitted('update:modelValue')).toEqual([[7]])
})
it('renders the unsupported box for an unknown type', () => {
const wrapper = mount(FieldRenderer, { props: { field: formField('color'), modelValue: '#fff', controlId: 'c' } })
expect(wrapper.find('[data-unsupported-field]').exists()).toBe(true)
})
})
describe('form error banner (D-09)', () => {
it('is an alert with the plural field count that lists messages of non-field keys', () => {
const wrapper = mount(FormErrorBanner, {
props: { errors: { name: ['Required'], code: ['Taken'], _form: ['Record is locked'] }, fieldNames: ['name', 'code'] },
})
const banner = wrapper.find('[role="alert"]')
expect(banner.text()).toContain('Nie udało się zapisać.')
expect(banner.text()).toContain('Popraw 3 pola oznaczone poniżej.')
expect(banner.findAll('p').at(-1)!.text()).toBe('Record is locked')
})
it('renders nothing without errors', () => {
const wrapper = mount(FormErrorBanner, { props: { errors: {}, fieldNames: ['name'] } })
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import FormGrid from '../../src/components/form/FormGrid.vue'
import { formField } from '../fixtures/typed'
import { resetState } from '../helpers'
beforeEach(() => {
resetState()
})
describe('form grid', () => {
it('places fields by span: left, right, full by default, auto and row in the next slot', () => {
const fields = [
formField('name'),
formField('code'),
formField('notes'),
{ ...formField('weight'), span: 'auto' },
{ ...formField('size'), span: 'row' },
{ ...formField('serial'), span: undefined },
formField('members'),
]
const wrapper = mount(FormGrid, { props: { fields, values: {}, errors: {}, source: null, idPrefix: 'grid' } })
const classes = (name: string) => wrapper.find(`[data-field="${name}"]`).classes()
expect(classes('name')).toContain('min-[600px]:col-start-1')
expect(classes('code')).toContain('min-[600px]:col-start-2')
expect(classes('notes')).toContain('col-span-full')
expect(classes('weight')).not.toContain('col-span-full')
expect(classes('size')).not.toContain('col-span-full')
expect(classes('serial')).toContain('col-span-full')
// The relation manager always takes the whole row.
expect(classes('members')).toContain('col-span-full')
})
it('passes values, errors and labels down and emits updates by field name', async () => {
const wrapper = mount(FormGrid, {
props: {
fields: [formField('name'), formField('owner')],
values: { name: 'Blue', owner: 5 },
errors: { name: ['Required'] },
labels: { owner: [{ value: 5, label: 'dana@example.test' }] },
source: null,
idPrefix: 'grid',
},
})
const input = wrapper.find('#grid-name')
expect((input.element as HTMLInputElement).value).toBe('Blue')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.find('#grid-owner').text()).toBe('dana@example.test')
await input.setValue('Red')
expect(wrapper.emitted('update')).toEqual([['name', 'Red']])
})
})

View File

@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import FormTabs from '../../src/components/form/FormTabs.vue'
import type { TabItem } from '../../src/components/form/formState'
import { keydown, resetState } from '../helpers'
const tabs: TabItem[] = [
{ key: 'default', label: 'Podstawowe', errors: 0 },
{ key: 'tab:Details', label: 'Details', errors: 2 },
{ key: 'tab:Members', label: 'Members', errors: 1 },
]
function mountTabs(modelValue = 'default') {
return mount(FormTabs, { props: { tabs, modelValue, idPrefix: 'f' }, attachTo: document.body })
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('form tabs', () => {
it('is a tablist of tabs with roving tabindex, aria-selected and aria-controls', () => {
const wrapper = mountTabs('tab:Details')
expect(wrapper.find('[role="tablist"]').exists()).toBe(true)
const items = wrapper.findAll('[role="tab"]')
expect(items.map((tab) => tab.attributes('aria-selected'))).toEqual(['false', 'true', 'false'])
expect(items.map((tab) => tab.attributes('tabindex'))).toEqual(['-1', '0', '-1'])
expect(items.map((tab) => tab.attributes('id'))).toEqual(['f-tab-0', 'f-tab-1', 'f-tab-2'])
expect(items.map((tab) => tab.attributes('aria-controls'))).toEqual(['f-panel-0', 'f-panel-1', 'f-panel-2'])
})
it('badges tabs with invalid fields with a plural label', () => {
const wrapper = mountTabs()
const badges = wrapper.findAll('[data-tab-errors]')
expect(badges.map((badge) => [badge.text(), badge.attributes('aria-label')])).toEqual([
['2', '2 błędy'],
['1', '1 błąd'],
])
})
it('selects on click and moves with the arrow keys, wrapping and moving focus', async () => {
const wrapper = mountTabs()
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Members'])
const first = wrapper.findAll('[role="tab"]')[0]!.element
expect(keydown(first, 'ArrowLeft').defaultPrevented).toBe(true)
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Members'])
expect(document.activeElement?.id).toBe('f-tab-2')
keydown(first, 'ArrowRight')
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Details'])
expect(document.activeElement?.id).toBe('f-tab-1')
expect(keydown(first, 'Enter').defaultPrevented).toBe(false)
})
})

View File

@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import FormView from '../../src/views/FormView.vue'
import { recordCrumb } from '../../src/state/useBreadcrumbs'
import { toasts } from '../../src/state/useToasts'
import { clone, formSchemaFixture, recordFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: formSchemaFixture },
[`GET ${RECORD}`]: { body: recordFixture },
...overrides,
}
}
/** A form schema of plain, untabbed fields. */
function flatSchema() {
const envelope = clone(formSchemaFixture)
envelope.data.fields = envelope.data.fields.filter((field) => ['name', 'code', 'weight'].includes(field.name))
return { body: envelope }
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('form view', () => {
it('is the component of the record and create routes', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
expect(wrapper.findComponent(FormView).exists()).toBe(true)
})
it('titles a record by its first text field, with the update message as subtitle, and publishes the crumb', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
expect(wrapper.find('h1').text()).toBe('Blue widget')
expect(wrapper.find('header p').text()).toBe('Editing widget')
expect(recordCrumb.value).toBe('Blue widget')
wrapper.unmount()
expect(recordCrumb.value).toBe('')
})
it('titles a new record by the create message and offers no delete', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/create', routes())
expect(wrapper.find('h1').text()).toBe('New widget')
expect(wrapper.find('header p').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
expect(recordCrumb.value).toBe('New widget')
})
it('makes the form the tab panel of the active tab', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
const form = wrapper.find('form')
expect(form.attributes('role')).toBe('tabpanel')
expect(form.attributes('id')).toBe('field-panel-0')
expect(form.attributes('aria-labelledby')).toBe('field-tab-0')
expect(wrapper.find('#field-tab-0').attributes('aria-controls')).toBe('field-panel-0')
})
it('has no tabs and no tab panel role for an untabbed form', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema() }))
expect(wrapper.find('[role="tablist"]').exists()).toBe(false)
expect(wrapper.find('form').attributes('role')).toBeUndefined()
expect(wrapper.findAll('[data-field]').map((field) => field.attributes('data-field'))).toEqual(['name', 'code', 'weight'])
})
it.each([
['the record', { [`GET ${RECORD}`]: failure(404, 'Not found') }],
['the schema', { [`GET ${BASE}/schema/form`]: failure(500, 'Boom') }],
])('announces a failed load of %s and disables saving', async (_label, override) => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes(override))
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
expect(wrapper.find('form').exists()).toBe(false)
if (_label === 'the schema') {
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
}
})
it('toasts the generic error when the save cannot reach the server and re-enables saving', async () => {
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`PUT ${RECORD}`]: () => {
throw new TypeError('offline')
},
}),
)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
expect(wrapper.find('[data-action="save"]').text()).toBe('Zapisz')
})
it('toasts the server message when a delete is refused and stays on the record', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/1',
routes({ [`DELETE ${RECORD}`]: failure(409, 'Widget is in use') }),
{ attach: true },
)
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Widget is in use', tone: 'danger' })
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
})
it('keeps a create on the form when the save fails', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/create',
routes({ [`POST ${BASE}`]: failure(500, 'Boom') }),
)
await wrapper.find('#field-name').setValue('New one')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/create')
expect(toasts.value[0]).toMatchObject({ text: 'Boom', tone: 'danger' })
})
it('follows the default redirect when the form declares no create redirect', async () => {
const envelope = clone(formSchemaFixture)
envelope.data.redirects.create.redirect = ''
envelope.data.redirects.default = 'acme/demo/widgets/update/:id'
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/create',
routes({
[`GET ${BASE}/schema/form`]: { body: envelope },
[`POST ${BASE}`]: { status: 201, body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
[`GET ${BASE}/5`]: { body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
}),
)
await wrapper.find('#field-name').setValue('X')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/5')
})
})

View File

@@ -0,0 +1,243 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import RelationField from '../../src/components/form/fields/RelationField.vue'
import { formField, optionsFixture, recordFixture } from '../fixtures/typed'
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const MAKER = `${API}/acme/demo/widgets/fields/maker/options`
const TAGS = `${API}/acme/demo/widgets/fields/tags/options`
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${MAKER}`]: (request) => {
const query = queryOf(request)
if (query.get('search') === 'acme') {
return { body: optionsFixture.makerFieldSearch }
}
return { body: query.get('page') === '2' ? optionsFixture.makerFieldPage2 : optionsFixture.makerField }
},
[`GET ${TAGS}`]: { body: optionsFixture.tagsField },
...overrides,
}
}
function mountField(name: string, modelValue: unknown, extra: Record<string, unknown> = {}) {
return mount(RelationField, {
props: {
field: formField(name),
modelValue,
controlId: `r-${name}`,
labels: recordFixture.meta.labels[name],
source,
...extra,
},
attachTo: document.body,
})
}
const combobox = (wrapper: VueWrapper) => wrapper.find('[role="combobox"]')
const optionTexts = (wrapper: VueWrapper) => wrapper.findAll('[role="option"]').map((option) => option.text())
async function openList(wrapper: VueWrapper) {
await combobox(wrapper).trigger('click')
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('read-only relation (D-26)', () => {
it('shows only the label from meta.labels, with no control', () => {
const wrapper = mountField('owner', 5, { describedBy: 'r-owner-comment' })
const box = wrapper.find('[data-relation-readonly]')
expect(box.text()).toBe('dana@example.test')
expect(box.attributes('id')).toBe('r-owner')
expect(box.attributes('aria-describedby')).toBe('r-owner-comment')
expect(box.classes()).toContain('text-text')
expect(wrapper.find('input').exists()).toBe(false)
})
it('falls back to the emptyOption, then a muted dash', () => {
const withEmpty = mountField('owner', null, { labels: [], field: { ...formField('owner'), emptyOption: 'Nobody' } })
expect(withEmpty.find('[data-relation-readonly]').text()).toBe('Nobody')
const bare = mountField('owner', null, { labels: undefined, field: { ...formField('owner'), emptyOption: undefined } })
expect(bare.find('[data-relation-readonly]').text()).toBe('—')
expect(bare.find('[data-relation-readonly]').classes()).toContain('text-muted')
})
})
describe('single relation (D-17)', () => {
it('is a collapsed combobox showing the current label', () => {
mockApi(routes())
const wrapper = mountField('maker', 2)
const input = combobox(wrapper)
expect(input.attributes('aria-expanded')).toBe('false')
expect(input.attributes('aria-controls')).toBe('r-maker-listbox')
expect(input.attributes('aria-autocomplete')).toBe('list')
expect((input.element as HTMLInputElement).value).toBe('Bolt & Co')
expect(wrapper.find('[role="listbox"]').attributes('id')).toBe('r-maker-listbox')
})
it('loads the first page of 20 options on open, emptyOption first and muted', async () => {
const calls = mockApi(routes())
const wrapper = mountField('maker', 2)
await openList(wrapper)
const [request] = requestsTo(calls, 'GET', MAKER)
expect(queryOf(request!).get('page')).toBe('1')
expect(queryOf(request!).get('per_page')).toBe('20')
expect(queryOf(request!).has('search')).toBe(false)
expect(combobox(wrapper).attributes('aria-expanded')).toBe('true')
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co'])
const options = wrapper.findAll('[role="option"]')
expect(options[0]!.attributes('data-value')).toBe('empty')
expect(options[0]!.classes()).toContain('text-muted')
expect(options.map((option) => option.attributes('aria-selected'))).toEqual(['false', 'false', 'true'])
})
it('chooses an id or null and closes', async () => {
mockApi(routes())
const wrapper = mountField('maker', 2)
await openList(wrapper)
await wrapper.findAll('[role="option"]')[1]!.trigger('mousedown')
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
await openList(wrapper)
await wrapper.findAll('[role="option"]')[0]!.trigger('mousedown')
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([null])
})
it('moves the active option with the arrows, exposes it as aria-activedescendant and picks it with Enter', async () => {
mockApi(routes())
const wrapper = mountField('maker', null)
const input = combobox(wrapper).element
expect(keydown(input, 'ArrowDown').defaultPrevented).toBe(true)
await flushPromises()
expect(combobox(wrapper).attributes('aria-activedescendant')).toBeUndefined()
keydown(input, 'ArrowDown')
keydown(input, 'ArrowDown')
keydown(input, 'ArrowDown')
keydown(input, 'ArrowDown')
await flushPromises()
expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-2')
keydown(input, 'ArrowUp')
await flushPromises()
expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-1')
expect(keydown(input, 'Enter').defaultPrevented).toBe(true)
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
})
it('closes on Escape and blur, and ignores Enter and Escape while closed', async () => {
mockApi(routes())
const wrapper = mountField('maker', 2)
const input = combobox(wrapper).element
expect(keydown(input, 'Enter').defaultPrevented).toBe(false)
expect(keydown(input, 'Escape').defaultPrevented).toBe(false)
await openList(wrapper)
expect(keydown(input, 'Escape').defaultPrevented).toBe(true)
await flushPromises()
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
await openList(wrapper)
await combobox(wrapper).trigger('blur')
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('searches after a 300 ms debounce and loads more on demand', async () => {
const calls = mockApi(routes())
const wrapper = mountField('maker', null)
await openList(wrapper)
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(true)
await wrapper.find('[data-action="more-options"]').trigger('mousedown')
await flushPromises()
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co', 'Cog Ltd'])
expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('page')).toBe('2')
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(false)
await combobox(wrapper).setValue('acme')
await wait(150)
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2)
await wait(200)
await flushPromises()
expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('search')).toBe('acme')
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works'])
})
it('loads more when the list is scrolled to its end', async () => {
const calls = mockApi(routes())
const wrapper = mountField('maker', null)
await openList(wrapper)
const list = wrapper.find('[role="listbox"]').element as HTMLElement
Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 400 })
Object.defineProperty(list, 'clientHeight', { configurable: true, value: 256 })
list.scrollTop = 10
list.dispatchEvent(new Event('scroll'))
await flushPromises()
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(1)
list.scrollTop = 140
list.dispatchEvent(new Event('scroll'))
await flushPromises()
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2)
})
it('shows "no options" for an empty or failed options load', async () => {
mockApi(routes({ [`GET ${MAKER}`]: { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } } }))
const wrapper = mountField('maker', null)
await openList(wrapper)
expect(optionTexts(wrapper)).toEqual(['— none —'])
expect(wrapper.find('[role="listbox"]').text()).toContain('Brak wyników')
})
it('never requests options without a source controller', async () => {
const calls = mockApi(routes())
const wrapper = mountField('maker', null, { source: null })
await openList(wrapper)
expect(calls).toHaveLength(0)
})
})
describe('multiple relation (D-18)', () => {
it('shows ordered chips with initials and labelled remove buttons', async () => {
mockApi(routes())
const wrapper = mountField('tags', [3, 1])
const chips = wrapper.findAll('[data-chip]')
expect(chips.map((chip) => chip.find('.font-semibold').text())).toEqual(['Round', 'Small'])
expect(chips[0]!.find('[aria-hidden="true"]').text()).toBe('R')
expect(chips.map((chip) => chip.find('button').attributes('aria-label'))).toEqual(['Usuń: Round', 'Usuń: Small'])
await chips[0]!.find('button').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[[1]]])
})
it('offers only unselected options and appends the chosen id', async () => {
mockApi(routes())
const wrapper = mountField('tags', [3, 1])
await openList(wrapper)
expect(optionTexts(wrapper)).toEqual(['Large'])
expect(wrapper.find('[role="option"]').attributes('aria-selected')).toBe('false')
await wrapper.find('[role="option"]').trigger('mousedown')
expect(wrapper.emitted('update:modelValue')).toEqual([[[3, 1, 4]]])
})
it('removes the last chip on Backspace with an empty query', async () => {
mockApi(routes())
const wrapper = mountField('tags', [3, '1', 'x'])
keydown(combobox(wrapper).element, 'Backspace')
expect(wrapper.emitted('update:modelValue')).toEqual([[[3]]])
const empty = mountField('tags', [])
keydown(combobox(empty).element, 'Backspace')
expect(empty.emitted('update:modelValue')).toBeUndefined()
})
it('labels an unknown id with the id itself', () => {
mockApi(routes())
const wrapper = mountField('tags', [99], { labels: [] })
expect(wrapper.find('[data-chip] .font-semibold').text()).toBe('99')
})
})

View File

@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import SettingsFormView from '../../src/views/SettingsFormView.vue'
import SettingsIndexView from '../../src/views/SettingsIndexView.vue'
import { setSettings } from '../../src/state/useSettings'
import { toasts } from '../../src/state/useToasts'
import { settingsFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
const PAGE = `${API}/settings/search`
const failure = (status: number, message: string, details: Record<string, unknown> = {}) => ({
status,
body: { error: { code: 'x', message, details } },
})
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${API}/settings`]: { body: settingsFixture.list },
[`GET ${PAGE}/schema`]: { body: settingsFixture.schema },
[`GET ${PAGE}`]: { body: settingsFixture.values },
...overrides,
}
}
async function openPage(overrides: Record<string, Route> = {}) {
setSettings(settingsFixture.list.data)
return mountApp('/settings/search', routes(overrides))
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('settings index (D-21)', () => {
it('says so when no settings page is permitted', async () => {
const { wrapper } = await mountApp('/settings', routes())
expect(wrapper.findComponent(SettingsIndexView).exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Ustawienia')
expect(wrapper.find('section > p').text()).toBe('Brak dostępnych ustawień.')
})
it('links every page by encoded code with its label and description', async () => {
setSettings([{ ...settingsFixture.list.data[0]!, code: 'a b', category: '', description: '' }])
const { wrapper } = await mountApp('/settings', routes())
const link = wrapper.find('[data-settings="a b"]')
expect(link.attributes('href')).toBe('/settings/a%20b')
expect(link.text()).toBe('Search')
expect(wrapper.find('[data-settings-group] h2').exists()).toBe(false)
})
})
describe('settings form (D-21)', () => {
it('titles the page from the entry and links back to the index', async () => {
const { wrapper } = await openPage()
expect(wrapper.findComponent(SettingsFormView).exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Search')
expect(wrapper.find('header p').text()).toBe('Search engine options')
expect(wrapper.find('[data-action="back"]').attributes('href')).toBe('/settings')
expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień')
})
it('falls back to the schema name, then the code, without an entry', async () => {
const { wrapper } = await mountApp('/settings/search', routes())
expect(wrapper.find('h1').text()).toBe('Search')
expect(wrapper.find('header p').exists()).toBe(false)
})
it('announces a failed load and disables saving', async () => {
const { wrapper } = await openPage({ [`GET ${PAGE}`]: failure(500, 'Boom') })
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
expect(wrapper.find('form').exists()).toBe(false)
})
it('toasts the server message for a refused save and the generic one for a network failure', async () => {
const { wrapper } = await openPage({ [`PUT ${PAGE}`]: failure(403, 'Not allowed') })
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
wrapper.unmount()
const offline = await openPage({
[`PUT ${PAGE}`]: () => {
throw new TypeError('offline')
},
})
await offline.wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
expect(offline.wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
})
it('maps a 422 onto fields, lists unknown keys in the banner, focuses the field and clears it on change', async () => {
const { wrapper, calls } = await openPage({
[`PUT ${PAGE}`]: failure(422, 'Invalid', { engine_host: ['Unreachable'], other: ['Global problem'] }),
})
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', PAGE)).toHaveLength(1)
const input = wrapper.find('#setting-engine_host')
expect(input.attributes('aria-invalid')).toBe('true')
expect(wrapper.find('[data-error-banner]').text()).toContain('Global problem')
await input.setValue('ok.local')
expect(wrapper.find('#setting-engine_host').attributes('aria-invalid')).toBeUndefined()
})
})

View File

@@ -0,0 +1,215 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import CheckboxField from '../../src/components/form/fields/CheckboxField.vue'
import DropdownField from '../../src/components/form/fields/DropdownField.vue'
import NumberField from '../../src/components/form/fields/NumberField.vue'
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
import TextField from '../../src/components/form/fields/TextField.vue'
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
import { setBundle } from '../../src/app/i18n'
import type { FormField } from '../../src/api/types'
import { formField } from '../fixtures/typed'
import { resetState } from '../helpers'
const common = { controlId: 'c', describedBy: 'c-comment' }
beforeEach(() => {
resetState()
})
describe('text field', () => {
it('is a required-aware text input that emits its value', async () => {
const field = { ...formField('name'), attributes: { placeholder: 'Type', onclick: 'x' } }
const wrapper = mount(TextField, { props: { field, modelValue: 'Blue', invalid: true, ...common } })
const input = wrapper.find('input')
expect(input.attributes()).toMatchObject({
id: 'c',
name: 'name',
type: 'text',
'aria-required': 'true',
'aria-invalid': 'true',
'aria-describedby': 'c-comment',
placeholder: 'Type',
})
expect(input.attributes('required')).toBeDefined()
expect(input.attributes('onclick')).toBeUndefined()
expect(input.classes()).toContain('border-danger')
await input.setValue('Red')
expect(wrapper.emitted('update:modelValue')).toEqual([['Red']])
})
it('shows an empty input for null and stringifies numbers', () => {
const optional = formField('code')
expect((mount(TextField, { props: { field: optional, modelValue: null, controlId: 'c' } }).find('input').element as HTMLInputElement).value).toBe('')
const input = mount(TextField, { props: { field: optional, modelValue: 12, controlId: 'c' } }).find('input')
expect((input.element as HTMLInputElement).value).toBe('12')
expect(input.attributes('aria-required')).toBeUndefined()
expect(input.attributes('required')).toBeUndefined()
expect(input.attributes('aria-describedby')).toBeUndefined()
})
})
describe('textarea field', () => {
it.each([
['tiny', '2'],
['small', '4'],
['large', '8'],
['huge', '12'],
['giant', '18'],
[undefined, '5'],
['odd', '5'],
])('maps size %s to %s rows', (size, rows) => {
const field: FormField = { ...formField('notes'), size }
expect(mount(TextareaField, { props: { field, modelValue: 'x', controlId: 't' } }).find('textarea').attributes('rows')).toBe(rows)
})
it('emits its text', async () => {
const wrapper = mount(TextareaField, { props: { field: formField('notes'), modelValue: undefined, controlId: 't' } })
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('')
await wrapper.find('textarea').setValue('Long text')
expect(wrapper.emitted('update:modelValue')).toEqual([['Long text']])
})
})
describe('number field', () => {
it('emits numbers and null for an empty input', async () => {
const wrapper = mount(NumberField, { props: { field: formField('weight'), modelValue: 12, controlId: 'n' } })
const input = wrapper.find('input')
expect(input.attributes('type')).toBe('number')
expect(input.attributes('inputmode')).toBe('decimal')
expect((input.element as HTMLInputElement).value).toBe('12')
await input.setValue('3.5')
await input.setValue('')
await input.setValue(' ')
expect(wrapper.emitted('update:modelValue')).toEqual([[3.5], [null], [null]])
})
it('emits null for a value that is not a finite number', () => {
const wrapper = mount(NumberField, { props: { field: formField('weight'), modelValue: null, controlId: 'n' } })
const input = wrapper.find('input').element as HTMLInputElement
Object.defineProperty(input, 'value', { configurable: true, get: () => 'Infinity' })
input.dispatchEvent(new Event('input'))
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
})
})
describe('dropdown field', () => {
const typed: FormField = {
...formField('size'),
options: [
{ value: 's', label: 'Small' },
{ value: 2, label: 'Two' },
{ value: true, label: 'Yes' },
],
}
it('offers the emptyOption first and sends null for it', async () => {
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: null, controlId: 'd' } })
const select = wrapper.find('select')
expect(select.findAll('option').map((option) => option.text())).toEqual(['— none —', 'Small', 'Two', 'Yes'])
expect((select.element as HTMLSelectElement).value).toBe('empty')
expect(select.classes()).toContain('text-muted')
await select.setValue('empty')
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
})
it('keeps the JSON type of the chosen option value', async () => {
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: 's', controlId: 'd' } })
const select = wrapper.find('select')
expect((select.element as HTMLSelectElement).value).toBe('0')
expect(select.classes()).not.toContain('text-muted')
await select.setValue('1')
await select.setValue('2')
expect(wrapper.emitted('update:modelValue')).toEqual([[2], [true]])
})
it('matches a stored value by its string form', () => {
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: '2', controlId: 'd' } })
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('1')
})
it('shows a disabled placeholder without an emptyOption or a matching value', () => {
const field: FormField = { ...typed, emptyOption: undefined, required: true }
const wrapper = mount(DropdownField, { props: { field, modelValue: 'unknown', controlId: 'd', invalid: true } })
const placeholder = wrapper.find('option[value="placeholder"]')
expect(placeholder.text()).toBe('Wybierz…')
expect(placeholder.attributes('disabled')).toBeDefined()
expect(wrapper.find('select').attributes('aria-required')).toBe('true')
expect(wrapper.find('select').attributes('aria-invalid')).toBe('true')
expect(mount(DropdownField, { props: { field, modelValue: 's', controlId: 'd' } }).find('option[value="placeholder"]').exists()).toBe(false)
})
it('shows the placeholder, not the empty option, for a stored value it does not know', () => {
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: 'zzz', controlId: 'd' } })
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('placeholder')
})
})
describe('toggle cards', () => {
it('renders a switch with role=switch, aria-checked, its own label and comment', async () => {
const wrapper = mount(SwitchField, { props: { field: formField('active'), modelValue: true, controlId: 's', describedBy: 's-comment' } })
expect(wrapper.find('[data-toggle-card]').exists()).toBe(true)
expect(wrapper.find('label').attributes('for')).toBe('s')
expect(wrapper.find('label').text()).toBe('Active')
expect(wrapper.find('#s-comment').text()).toBe('Shown in the catalogue')
const control = wrapper.find('[role="switch"]')
expect(control.attributes('id')).toBe('s')
expect(control.attributes('type')).toBe('button')
expect(control.attributes('aria-checked')).toBe('true')
expect(control.attributes('aria-describedby')).toBe('s-comment')
await control.trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('keeps a numeric switch value numeric and marks it invalid', async () => {
const field = { ...formField('active'), required: true, label: '' }
const wrapper = mount(SwitchField, { props: { field, modelValue: 0, controlId: 's', invalid: true } })
expect(wrapper.find('[role="switch"]').attributes('aria-checked')).toBe('false')
expect(wrapper.find('[role="switch"]').attributes('aria-invalid')).toBe('true')
expect(wrapper.find('[data-toggle-card]').classes()).toContain('border-danger')
expect(wrapper.find('label').text()).toBe('active *')
await wrapper.find('[role="switch"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
})
it('renders a checkbox card that emits its checked state', async () => {
const wrapper = mount(CheckboxField, { props: { field: formField('featured'), modelValue: '1', controlId: 'k' } })
const box = wrapper.find('input[type="checkbox"]')
expect((box.element as HTMLInputElement).checked).toBe(true)
expect(wrapper.find('label').attributes('for')).toBe('k')
expect(wrapper.find('#k-comment').text()).toBe('Pinned to the top')
await box.setValue(false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
const numeric = mount(CheckboxField, { props: { field: formField('featured'), modelValue: 0, controlId: 'k', invalid: true } })
expect(numeric.find('input').attributes('aria-invalid')).toBe('true')
await numeric.find('input').setValue(true)
expect(numeric.emitted('update:modelValue')).toEqual([[1]])
})
})
describe('unsupported field (D-05)', () => {
it('names the type as code inside the translated sentence', () => {
const wrapper = mount(UnsupportedField, { props: { field: formField('color'), modelValue: '#fff', controlId: 'u', describedBy: 'u-error' } })
const box = wrapper.find('[data-unsupported-field]')
expect(box.attributes('id')).toBe('u')
expect(box.attributes('aria-describedby')).toBe('u-error')
expect(box.text()).toBe('Nieobsługiwany typ pola: colorpicker')
expect(box.find('code').text()).toBe('colorpicker')
})
it('appends the type when the sentence has no :type placeholder', () => {
setBundle({ 'backend::lang.form.unsupported_field': { other: 'Unsupported field' } }, 'en')
const wrapper = mount(UnsupportedField, { props: { field: formField('color'), modelValue: null, controlId: 'u' } })
expect(wrapper.text()).toBe('Unsupported field colorpicker')
expect(wrapper.find('code').text()).toBe('colorpicker')
})
it('renders a hostile type name as text', () => {
const field = { ...formField('color'), type: '<img src=x onerror=alert(1)>' }
const wrapper = mount(UnsupportedField, { props: { field, modelValue: null, controlId: 'u' } })
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.find('code').text()).toBe('<img src=x onerror=alert(1)>')
})
})

View File

@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
contextAllows,
DEFAULT_TAB,
editablePayload,
fieldErrors,
focusField,
initialValues,
panelDomId,
snapshot,
tabDomId,
tabOf,
} from '../../src/components/form/formState'
import { formField, formSchemaFixture } from '../fixtures/typed'
afterEach(() => {
document.body.innerHTML = ''
})
describe('form state helpers', () => {
it('keys tabs by label with a default tab for untabbed fields', () => {
expect(tabOf(formField('name'))).toBe(DEFAULT_TAB)
expect(tabOf(formField('serial'))).toBe('tab:Details')
expect(tabDomId('field', 2)).toBe('field-tab-2')
expect(panelDomId('field', 2)).toBe('field-panel-2')
})
it('applies Winter context semantics', () => {
expect(contextAllows(formField('name'), 'create')).toBe(true)
expect(contextAllows(formField('serial'), 'update')).toBe(true)
expect(contextAllows(formField('serial'), 'create')).toBe(false)
expect(contextAllows(formField('batch'), 'create')).toBe(true)
expect(contextAllows(formField('batch'), 'update')).toBe(false)
expect(contextAllows({ ...formField('name'), context: [] }, 'update')).toBe(true)
expect(contextAllows({ ...formField('name'), context: null as unknown as undefined }, 'update')).toBe(true)
})
it('sends only editable, registered fields that hold a value', () => {
const fields = formSchemaFixture.data.fields
const body = editablePayload(fields, {
name: 'Blue',
code: undefined,
color: '#fff',
owner: 5,
members: [1],
tags: [3],
active: false,
unknown_key: 'x',
})
expect(body).toEqual({ name: 'Blue', tags: [3], active: false })
})
it('starts a new record from defaults, toggles off and empty multiple relations', () => {
const fields = [
{ ...formField('name'), default: 'Unnamed' },
formField('active'),
formField('featured'),
formField('tags'),
formField('maker'),
{ ...formField('weight'), default: 0 },
]
expect(initialValues(fields)).toEqual({ name: 'Unnamed', active: false, featured: false, tags: [], weight: 0 })
})
it('normalizes 422 details into string lists and drops empty entries', () => {
expect(
fieldErrors({
name: ['Required', '', 3],
code: 'Taken',
empty: [],
blank: '',
nested: { a: 1 },
}),
).toEqual({ name: ['Required'], code: ['Taken'] })
expect(fieldErrors(undefined)).toEqual({})
})
it('snapshots records independently of key order', () => {
expect(snapshot({ a: 1, b: [2] })).toBe(snapshot({ b: [2], a: 1 }))
expect(snapshot({ a: 1 })).not.toBe(snapshot({ a: 2 }))
})
it('focuses a control by its id, making a non-control focusable first', () => {
document.body.innerHTML = '<input id="field-name"><div id="field-members"></div>'
expect(focusField('field', 'name')).toBe(true)
expect(document.activeElement?.id).toBe('field-name')
expect(focusField('field', 'members')).toBe(true)
expect(document.getElementById('field-members')?.getAttribute('tabindex')).toBe('-1')
expect(document.activeElement?.id).toBe('field-members')
expect(focusField('field', 'missing')).toBe(false)
})
})

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import CheckboxField from '../../src/components/form/fields/CheckboxField.vue'
import DropdownField from '../../src/components/form/fields/DropdownField.vue'
import NumberField from '../../src/components/form/fields/NumberField.vue'
import RelationField from '../../src/components/form/fields/RelationField.vue'
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
import TextField from '../../src/components/form/fields/TextField.vue'
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
import RelationManager from '../../src/components/relation/RelationManager.vue'
import {
controlAttributes,
controlClass,
isRegistered,
needsRecord,
ownsLabel,
RELATION_MANAGER,
rendererFor,
toggleOn,
toggleValue,
} from '../../src/components/form/registry'
import * as control from '../../src/components/form/control'
import { formField } from '../fixtures/typed'
describe('field renderer registry (D-05)', () => {
it.each([
['text', TextField],
['textarea', TextareaField],
['number', NumberField],
['dropdown', DropdownField],
['switch', SwitchField],
['checkbox', CheckboxField],
['relation', RelationField],
[RELATION_MANAGER, RelationManager],
])('renders %s with its control', (type, component) => {
expect(rendererFor(type)).toBe(component)
})
it.each(['colorpicker', 'fileupload', '', 'TEXT', 'constructor'])('falls back to UnsupportedField for %j', (type) => {
expect(rendererFor(type)).toBe(UnsupportedField)
expect(isRegistered(type)).toBe(false)
})
it('edits values of every control type except the record-bound relation manager', () => {
expect(RELATION_MANAGER).toBe('relation-manager')
for (const type of ['text', 'textarea', 'number', 'dropdown', 'switch', 'checkbox', 'relation']) {
expect(isRegistered(type), type).toBe(true)
expect(needsRecord(type), type).toBe(false)
}
expect(isRegistered(RELATION_MANAGER)).toBe(false)
expect(needsRecord(RELATION_MANAGER)).toBe(true)
})
it('lets toggle cards and the relation manager render their own label', () => {
expect(ownsLabel('switch')).toBe(true)
expect(ownsLabel('checkbox')).toBe(true)
expect(ownsLabel(RELATION_MANAGER)).toBe(true)
expect(ownsLabel('text')).toBe(false)
expect(ownsLabel('relation')).toBe(false)
})
})
describe('control attributes', () => {
it('passes only allow-listed Winter attributes, lower-cased, and drops null and false', () => {
const field = formField('name')
field.attributes = {
placeholder: 'Type…',
MaxLength: 20,
readonly: true,
spellcheck: false,
pattern: null,
onclick: 'alert(1)',
onfocus: 'x',
style: 'color:red',
class: 'evil',
id: 'hijack',
autofocus: true,
}
expect(controlAttributes(field)).toEqual({ placeholder: 'Type…', maxlength: 20, readonly: true })
})
it('answers no attributes without a map', () => {
expect(controlAttributes(formField('code'))).toEqual({})
})
it('switches the border to danger when invalid', () => {
expect(controlClass(true)).toContain('border-danger')
expect(controlClass(false)).toContain('border-border-strong')
expect(controlClass(undefined)).not.toContain('border-danger')
})
})
describe('toggle values', () => {
it.each([
[true, true],
[1, true],
['1', true],
['true', true],
['yes', true],
[false, false],
[0, false],
['0', false],
['', false],
['False', false],
[null, false],
[undefined, false],
])('%j is %s', (value, on) => {
expect(toggleOn(value)).toBe(on)
})
it('keeps a numeric stored value numeric and sends booleans otherwise', () => {
expect(toggleValue(0, true)).toBe(1)
expect(toggleValue(1, false)).toBe(0)
expect(toggleValue(false, true)).toBe(true)
expect(toggleValue('1', false)).toBe(false)
expect(toggleValue(undefined, true)).toBe(true)
})
})
describe('control module', () => {
it('is what the registry re-exports, so field controls never import the registry', () => {
expect(controlAttributes).toBe(control.controlAttributes)
expect(controlClass).toBe(control.controlClass)
expect(toggleOn).toBe(control.toggleOn)
expect(toggleValue).toBe(control.toggleValue)
})
})

View File

@@ -6,9 +6,15 @@ import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory } from 'vue-router' import { createMemoryHistory } from 'vue-router'
import App from '../src/App.vue' import App from '../src/App.vue'
import { createAdminRouter } from '../src/app/router' import { createAdminRouter } from '../src/app/router'
import { me } from '../src/state/useAuth' import { setBundle } from '../src/app/i18n'
import { loadNavigation } from '../src/state/useNavigation' import { clearUser, me } from '../src/state/useAuth'
import { clearRecordCrumb } from '../src/state/useBreadcrumbs'
import { loadNavigation, setNavigation } from '../src/state/useNavigation'
import { setSettings } from '../src/state/useSettings'
import { watchViewport } from '../src/state/useSidebar'
import { clearToasts } from '../src/state/useToasts'
import navigation from './fixtures/navigation.json' import navigation from './fixtures/navigation.json'
import { langFixture } from './fixtures/typed'
export const API = '/admin-test/api/v1' export const API = '/admin-test/api/v1'
@@ -85,3 +91,81 @@ export async function mountApp(path: string, routes: Record<string, Route>, opti
await flushPromises() await flushPromises()
return { wrapper, calls, router } return { wrapper, calls, router }
} }
/** Signs the profile in over the mocked API; extra routes join the mock. */
export async function signIn(routes: Record<string, Route> = {}): Promise<Request[]> {
const calls = mockApi({ [`GET ${API}/auth/me`]: { body: profile }, ...routes })
await me()
return calls
}
/** The real admin router on memory history, already at path. */
export async function routerAt(path: string) {
const router = createAdminRouter(createMemoryHistory())
await router.push(path)
await router.isReady()
return router
}
/** Dispatches a bubbling, cancelable keydown and returns the event. */
export function keydown(target: Element, key: string): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
target.dispatchEvent(event)
return event
}
export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type MediaListener = (event: MediaQueryListEvent) => void
/** A matchMedia whose answers the test controls, with change events. */
export function fakeMedia(initial: Record<string, boolean>) {
const state = { ...initial }
const listeners = new Map<string, Set<MediaListener>>()
const of = (query: string): Set<MediaListener> => {
let set = listeners.get(query)
if (!set) {
set = new Set()
listeners.set(query, set)
}
return set
}
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string) =>
({
get matches() {
return state[query] ?? false
},
media: query,
onchange: null,
addEventListener: (_type: string, listener: MediaListener) => of(query).add(listener),
removeEventListener: (_type: string, listener: MediaListener) => of(query).delete(listener),
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
)
return {
change(query: string, matches: boolean): void {
state[query] = matches
for (const listener of of(query)) {
listener({ matches, media: query } as MediaQueryListEvent)
}
},
listeners: (query: string) => of(query).size,
}
}
/** Module state back to a signed-out, Polish-strings start. */
export function resetState(): void {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
clearRecordCrumb()
setBundle(langFixture.data, 'pl')
window.localStorage.clear()
// Re-reads the (cleared) preference and the desktop viewport of setup.ts,
// so a narrow or collapsed sidebar never leaks into the next test.
watchViewport()()
}

View File

@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import CellValue from '../../src/components/list/CellValue.vue'
import type { ListColumn } from '../../src/api/types'
import { listColumn } from '../fixtures/typed'
import { resetState } from '../helpers'
function cell(column: ListColumn, value: unknown) {
return mount({ components: { CellValue }, props: [], template: '<span data-cell><CellValue :column="column" :value="value" /></span>', data: () => ({ column, value }) })
}
const text = listColumn('name')
const datetime = listColumn('created_at')
const toggle = listColumn('active')
beforeEach(() => {
resetState()
})
describe('cell value (D-12)', () => {
it.each([
['a string', 'Blue', 'Blue'],
['a number', 12, '12'],
['an array', ['small', 3, null, ''], 'small, 3'],
['a boolean true', true, 'Tak'],
['a boolean false', false, 'Nie'],
])('renders %s as plain text', (_label, value, expected) => {
const wrapper = cell(text, value)
expect(wrapper.text()).toBe(expected)
expect(wrapper.find('[data-empty]').exists()).toBe(false)
})
it.each([
['null', null],
['undefined', undefined],
['an empty string', ''],
['an object', { nested: true }],
['an empty array', []],
])('renders %s as a muted dash', (_label, value) => {
const wrapper = cell(text, value)
expect(wrapper.find('[data-empty]').text()).toBe('—')
})
it('never renders markup from a value', () => {
const wrapper = cell(text, '<img src=x onerror=alert(1)>')
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.text()).toBe('<img src=x onerror=alert(1)>')
})
it('formats datetimes as YYYY-MM-DD HH:mm in tabular numbers', () => {
const wrapper = cell(datetime, '2026-09-21T14:32:00')
expect(wrapper.text()).toBe('2026-09-21 14:32')
expect(wrapper.find('span.tabular-nums').exists()).toBe(true)
expect(cell(datetime, 'not a date').text()).toBe('not a date')
expect(cell(datetime, null).find('[data-empty]').exists()).toBe(true)
expect(cell(datetime, true).find('[data-empty]').exists()).toBe(true)
})
it.each([
[true, 'true', 'Tak'],
[1, 'true', 'Tak'],
['yes', 'true', 'Tak'],
[false, 'false', 'Nie'],
[0, 'false', 'Nie'],
['0', 'false', 'Nie'],
['FALSE', 'false', 'Nie'],
])('renders the switch value %j as the %s pill', (value, state, label) => {
const wrapper = cell(toggle, value)
expect(wrapper.find('[data-switch]').attributes('data-switch')).toBe(state)
expect(wrapper.text()).toBe(label)
})
it('renders an empty switch value as a dash', () => {
expect(cell(toggle, null).find('[data-empty]').exists()).toBe(true)
expect(cell(toggle, null).find('[data-switch]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import DataTable from '../../src/components/list/DataTable.vue'
import type { AdminRecord, ListColumn } from '../../src/api/types'
import type { RowId } from '../../src/app/listQuery'
import { linkedFixture, listColumn, listFixture } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
const columns: ListColumn[] = [listColumn('name'), listColumn('code'), listColumn('tags')]
async function mountTable(props: Record<string, unknown>) {
await signIn()
const router = await routerAt('/acme/demo/widgets')
const wrapper = mount(DataTable, {
props: { columns, rows: listFixture.data, ...props },
global: { plugins: [router] },
attachTo: document.body,
})
await flushPromises()
return { wrapper, router }
}
const rowLink = (row: AdminRecord) => `/acme/demo/widgets/${String(row.id)}`
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('data table', () => {
it('renders exactly the schema columns and each record through its column keys', async () => {
const { wrapper } = await mountTable({})
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
expect(wrapper.findAll('th').every((th) => th.attributes('scope') === 'col')).toBe(true)
const cells = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
expect(cells).toEqual([
['Blue widget', 'W-01', 'small, round'],
['Green widget', 'W-02', '—'],
])
expect(wrapper.find('tbody tr').attributes('aria-selected')).toBeUndefined()
expect(wrapper.find('th').attributes('aria-sort')).toBeUndefined()
})
it('marks sortable headers with aria-sort none, ascending or descending and emits the column', async () => {
const { wrapper } = await mountTable({ sortable: true, sort: { column: 'code', dir: 'desc' } })
const headers = wrapper.findAll('th')
expect(headers.map((th) => th.attributes('aria-sort'))).toEqual(['none', 'descending', undefined])
expect(headers[2]!.find('button').exists()).toBe(false)
await wrapper.setProps({ sort: { column: 'name', dir: 'asc' } })
expect(wrapper.findAll('th').map((th) => th.attributes('aria-sort'))).toEqual(['ascending', 'none', undefined])
await wrapper.find('[data-sort="code"]').trigger('click')
expect(wrapper.emitted('sort')).toEqual([['code']])
})
it('selects rows through a tri-state header checkbox over the current page', async () => {
const { wrapper } = await mountTable({ selectable: true, selected: [] as RowId[] })
const header = () => wrapper.find('th[data-select] [role="checkbox"]')
expect(header().attributes('aria-checked')).toBe('false')
expect(header().attributes('aria-label')).toBe('Zaznacz wszystkie na stronie')
expect(wrapper.findAll('tbody tr').map((tr) => tr.attributes('aria-selected'))).toEqual(['false', 'false'])
await header().trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[1, 2]])
await wrapper.setProps({ selected: ['2'] })
expect(header().attributes('aria-checked')).toBe('mixed')
expect(wrapper.findAll('tbody tr').map((tr) => tr.attributes('aria-selected'))).toEqual(['false', 'true'])
await header().trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[1, 2]])
await wrapper.setProps({ selected: [1, 2] })
expect(header().attributes('aria-checked')).toBe('true')
await header().trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[]])
})
it('toggles single rows by id', async () => {
const { wrapper } = await mountTable({ selectable: true, selected: [2] as RowId[] })
const boxes = wrapper.findAll('tbody [role="checkbox"]')
expect(boxes.map((box) => box.attributes('aria-checked'))).toEqual(['false', 'true'])
expect(boxes[0]!.attributes('aria-label')).toBe('Zaznacz wiersz')
await boxes[0]!.trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[2, 1]])
await boxes[1]!.trigger('click')
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[]])
})
it('opens the row link on a row click but not from its controls', async () => {
const { wrapper, router } = await mountTable({ selectable: true, rowLink })
expect(wrapper.find('tbody tr td:nth-child(2) a').attributes('href')).toBe('/acme/demo/widgets/1')
await wrapper.find('tbody tr [role="checkbox"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
await wrapper.findAll('tbody tr')[1]!.findAll('td')[2]!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/2')
})
it('does nothing on a row click without a row link and keeps rows without ids unselectable', async () => {
const { wrapper, router } = await mountTable({
selectable: true,
rows: [{ name: 'No id', code: 'X', tags: [] }],
})
await wrapper.find('tbody td:nth-child(2)').trigger('click')
await wrapper.find('tbody [role="checkbox"]').trigger('click')
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
expect(wrapper.emitted('update:selected')).toBeUndefined()
expect(wrapper.find('th [role="checkbox"]').attributes('disabled')).toBeDefined()
})
it('shows eight skeleton rows with aria-busy while loading and the empty slot without rows', async () => {
const { wrapper } = await mountTable({ loading: true, selectable: true })
expect(wrapper.find('tbody').attributes('aria-busy')).toBe('true')
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(8)
expect(wrapper.find('th [role="checkbox"]').attributes('disabled')).toBeDefined()
await wrapper.setProps({ loading: false, rows: [] })
expect(wrapper.find('tbody').attributes('aria-busy')).toBeUndefined()
expect(wrapper.find('tbody td').attributes('colspan')).toBe('4')
expect(wrapper.find('tbody td').text()).toBe('Brak rekordów w tym widoku.')
})
it('renders the relation variant with 56px rows, initials avatars and three skeleton rows', async () => {
const relationColumns = [
{ key: 'name', label: 'Name', searchable: true, sortable: true },
{ key: 'email', label: 'E-mail', searchable: true, sortable: true },
]
const { wrapper } = await mountTable({ variant: 'relation', columns: relationColumns, rows: linkedFixture.all.data })
const first = wrapper.find('tbody tr')
expect(first.classes()).toContain('h-[56px]')
expect(first.find('[data-avatar]').text()).toBe('AB')
expect(first.find('[data-avatar]').attributes('aria-hidden')).toBe('true')
expect(first.findAll('td')[1]!.classes()).toContain('text-muted')
await wrapper.setProps({ loading: true })
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(3)
})
})

View File

@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import FilterBar from '../../src/components/list/FilterBar.vue'
import type { ListFilter } from '../../src/api/types'
import { listFilter, optionsFixture } from '../fixtures/typed'
import { API, mockApi, queryOf, requestsTo, resetState } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const MAKER = `${API}/acme/demo/widgets/filters/maker/options`
const filters: ListFilter[] = [listFilter('active'), listFilter('created'), listFilter('maker')]
async function mountBar(values: Record<string, string> = {}, extra: ListFilter[] = []) {
const calls = mockApi({ [`GET ${MAKER}`]: { body: optionsFixture.maker } })
const wrapper = mount(FilterBar, { props: { filters: [...filters, ...extra], values, source } })
await flushPromises()
return { wrapper, calls }
}
function options(select: ReturnType<ReturnType<typeof mount>['find']>) {
return select.findAll('option').map((option) => [option.attributes('value'), option.text()])
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('filter bar (D-22, D-27)', () => {
it('is a labelled group with one control per filter shape', async () => {
const { wrapper } = await mountBar()
expect(wrapper.find('[role="group"]').attributes('aria-label')).toBe('Filtry')
expect(wrapper.find('select[data-filter="active"]').exists()).toBe(true)
expect(wrapper.find('fieldset legend').text()).toBe('Created')
expect(wrapper.find('[data-filter="created-from"]').attributes('aria-label')).toBe('Od')
expect(wrapper.find('[data-filter="created-to"]').attributes('aria-label')).toBe('Do')
expect(wrapper.find('select[data-filter="maker"]').exists()).toBe(true)
})
it('offers switch choices as JSON of trueValue and falseValue, or of the declared options', async () => {
const withOptions: ListFilter = {
name: 'size',
type: 'switch',
options: [
{ value: 's', label: 'Small' },
{ value: 2, label: 'Two' },
],
}
const { wrapper } = await mountBar({}, [withOptions])
expect(options(wrapper.find('select[data-filter="active"]'))).toEqual([
['', 'Wszystkie'],
['true', 'Tak'],
['false', 'Nie'],
])
expect(options(wrapper.find('select[data-filter="size"]'))).toEqual([
['', 'Wszystkie'],
['"s"', 'Small'],
['2', 'Two'],
])
expect(wrapper.find('select[data-filter="size"]').element.closest('label')?.textContent).toContain('size')
await wrapper.find('select[data-filter="active"]').setValue('false')
await wrapper.find('select[data-filter="active"]').setValue('')
expect(wrapper.emitted('update')).toEqual([
['active', 'false'],
['active', null],
])
})
it('loads scope choices once from the filter options endpoint and selects the URL value', async () => {
const { wrapper, calls } = await mountBar({ maker: '2' })
const requests = requestsTo(calls, 'GET', MAKER)
expect(requests).toHaveLength(1)
expect([...queryOf(requests[0]!).keys()]).toEqual([])
const select = wrapper.find('select[data-filter="maker"]')
expect(options(select)).toEqual([
['', 'Wszystkie'],
['1', 'Acme Works'],
['2', 'Bolt & Co'],
])
expect((select.element as HTMLSelectElement).value).toBe('2')
await select.setValue('1')
expect(wrapper.emitted('update')).toEqual([['maker', '1']])
})
it('shows no scope choices when the options request fails', async () => {
mockApi({ [`GET ${MAKER}`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } } })
const wrapper = mount(FilterBar, { props: { filters, values: {}, source } })
await flushPromises()
expect(options(wrapper.find('select[data-filter="maker"]'))).toEqual([['', 'Wszystkie']])
})
it('writes a date range only when both ends are set and removes it when either is cleared', async () => {
const { wrapper } = await mountBar()
const from = wrapper.find('[data-filter="created-from"]')
const to = wrapper.find('[data-filter="created-to"]')
await from.setValue('2026-01-01')
expect(wrapper.emitted('update')).toBeUndefined()
await to.setValue('2026-02-01')
expect(wrapper.emitted('update')).toEqual([['created', '2026-01-01..2026-02-01']])
await wrapper.setProps({ values: { created: '2026-01-01..2026-02-01' } })
await to.setValue('')
expect(wrapper.emitted('update')!.at(-1)).toEqual(['created', null])
await wrapper.setProps({ values: {} })
// The half-filled pair stays on screen until both ends are cleared.
expect((from.element as HTMLInputElement).value).toBe('2026-01-01')
await from.setValue('')
expect(wrapper.emitted('update')!.at(-1)).toEqual(['created', null])
})
it('fills the range inputs from the URL value', async () => {
const { wrapper } = await mountBar({ created: '2026-03-01..2026-03-31' })
expect((wrapper.find('[data-filter="created-from"]').element as HTMLInputElement).value).toBe('2026-03-01')
expect((wrapper.find('[data-filter="created-to"]').element as HTMLInputElement).value).toBe('2026-03-31')
})
})

View File

@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ListToolbar from '../../src/components/list/ListToolbar.vue'
import { resetState } from '../helpers'
const base = {
showSearch: true,
search: 'blue',
searchPrompt: 'Search widgets…',
buttons: ['delete'],
selectedCount: 0,
selectedLabel: 'Selected 0',
deleteLabel: 'Delete selected',
}
beforeEach(() => {
resetState()
})
describe('list toolbar (D-14)', () => {
it('has a labelled search input that emits every change', async () => {
const wrapper = mount(ListToolbar, { props: base })
const input = wrapper.find('[data-list-search]')
expect(input.attributes('type')).toBe('search')
expect(input.attributes('placeholder')).toBe('Search widgets…')
expect((input.element as HTMLInputElement).value).toBe('blue')
expect(wrapper.find('label .sr-only').text()).toBe('Szukaj')
await input.setValue('red')
expect(wrapper.emitted('update:search')).toEqual([['red']])
})
it('hides the search when the schema turns it off', () => {
const wrapper = mount(ListToolbar, { props: { ...base, showSearch: false } })
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
})
it('disables delete without a selection and shows the pill and danger button with one', async () => {
const wrapper = mount(ListToolbar, { props: base })
const button = () => wrapper.find('[data-action="delete"]')
expect(button().attributes('disabled')).toBeDefined()
expect(button().classes()).toContain('border-border-strong')
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
await wrapper.setProps({ selectedCount: 2, selectedLabel: 'Selected 2' })
expect(button().attributes('disabled')).toBeUndefined()
expect(button().classes()).toContain('text-danger')
expect(button().text()).toBe('Delete selected')
expect(wrapper.find('[data-selected-pill]').text()).toBe('Selected 2')
await button().trigger('click')
expect(wrapper.emitted('delete')).toHaveLength(1)
})
it('renders only the declared toolbar buttons', () => {
const wrapper = mount(ListToolbar, { props: { ...base, buttons: [] } })
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import ListView from '../../src/views/ListView.vue'
import { toasts } from '../../src/state/useToasts'
import { clone, listFixture, listSchemaFixture } from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/list`]: { body: listSchemaFixture },
[`GET ${BASE}`]: { body: listFixture },
...overrides,
}
}
function schemaWith(change: (schema: typeof listSchemaFixture.data) => void) {
const envelope = clone(listSchemaFixture)
change(envelope.data)
return { body: envelope }
}
async function selectAllAndDelete(wrapper: Awaited<ReturnType<typeof mountApp>>['wrapper']) {
await wrapper.find('th [role="checkbox"]').trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('list view', () => {
it('is the component of the list route', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
expect(wrapper.findComponent(ListView).exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets')
})
it('sends the URL query to the list endpoint', async () => {
const { calls } = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&page=2&filter[active]=true', routes())
const [request] = requestsTo(calls, 'GET', BASE)
const query = queryOf(request!)
expect(query.get('search')).toBe('blue')
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('page')).toBe('2')
expect(query.get('filter[active]')).toBe('true')
})
it('announces a failed list load as an alert and keeps the toolbar', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
expect(wrapper.find('table').exists()).toBe(false)
expect(wrapper.find('[data-list-search]').exists()).toBe(true)
})
it('announces a failed schema load and falls back to the navigation label', async () => {
const { wrapper } = await mountApp('/acme/demo/gadgets', {
[`GET ${API}/acme/demo/gadgets/schema/list`]: serverError,
[`GET ${API}/acme/demo/gadgets`]: { body: listFixture },
})
expect(wrapper.find('h1').text()).toBe('Gadgets')
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
})
it('shows the create button only when the toolbar declares it', async () => {
const withCreate = await mountApp('/acme/demo/widgets', routes())
expect(withCreate.wrapper.find('[data-action="create"]').attributes('href')).toBe('/acme/demo/widgets/create')
withCreate.wrapper.unmount()
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`GET ${BASE}/schema/list`]: schemaWith((schema) => (schema.toolbarButtons = ['delete'])) }),
)
expect(wrapper.find('[data-action="create"]').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
})
it('links no rows without a recordUrl and shows no checkboxes or sorting when turned off', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`GET ${BASE}/schema/list`]: schemaWith((schema) => {
delete schema.recordUrl
schema.showCheckboxes = false
schema.showSorting = false
}),
}),
)
expect(wrapper.find('tbody a').exists()).toBe(false)
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
expect(wrapper.find('[data-sort]').exists()).toBe(false)
})
it('toasts the server message when a bulk delete is refused', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`POST ${BASE}/bulk-delete`]: { status: 403, body: { error: { code: 'forbidden', message: 'Not allowed', details: {} } } } }),
{ attach: true },
)
await selectAllAndDelete(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
})
it('toasts the generic failure when the bulk delete cannot reach the server', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`POST ${BASE}/bulk-delete`]: () => {
throw new TypeError('offline')
},
}),
{ attach: true },
)
await selectAllAndDelete(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się usunąć zaznaczonych rekordów.', tone: 'danger' })
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeUndefined()
})
})

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import Pagination from '../../src/components/list/Pagination.vue'
import type { ListMeta } from '../../src/api/types'
import { resetState } from '../helpers'
function meta(page: number, lastPage: number, total = lastPage * 20, perPage = 20): ListMeta {
return { page, last_page: lastPage, total, per_page: perPage }
}
function pager(page: number, lastPage: number) {
const wrapper = mount(Pagination, { props: { meta: meta(page, lastPage), loading: false, perPageOptions: [20], perPage: 20 } })
return wrapper
.findAll('nav > *')
.filter((element) => !['prev', 'next'].includes(element.attributes('data-page') ?? ''))
.map((element) => element.text())
.join(' ')
}
beforeEach(() => {
resetState()
})
describe('pagination', () => {
it('shows the range of the page and a labelled pager', () => {
const wrapper = mount(Pagination, { props: { meta: meta(2, 3, 45), loading: false, perPageOptions: [20], perPage: 20 } })
expect(wrapper.find('[data-range]').text()).toBe('21–40 z 45')
expect(wrapper.find('nav').attributes('aria-label')).toBe('Stronicowanie')
expect(wrapper.find('[data-page="2"]').attributes('aria-current')).toBe('page')
expect(wrapper.find('[data-page="1"]').attributes('aria-current')).toBeUndefined()
})
it.each([
[1, 1, '1'],
[1, 5, '1 2 3 … 5'],
[3, 5, '1 2 3 4 5'],
[5, 10, '1 … 4 5 6 … 10'],
[10, 10, '1 … 8 9 10'],
[2, 3, '1 2 3'],
])('page %i of %i shows %s', (page, last, expected) => {
expect(pager(page, last)).toBe(expected)
})
it('disables prev on the first and next on the last page and emits pages', async () => {
const wrapper = mount(Pagination, { props: { meta: meta(1, 3), loading: false, perPageOptions: [20], perPage: 20 } })
expect(wrapper.find('[data-page="prev"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeUndefined()
await wrapper.find('[data-page="next"]').trigger('click')
await wrapper.find('[data-page="3"]').trigger('click')
await wrapper.find('[data-page="1"]').trigger('click')
expect(wrapper.emitted('page')).toEqual([[2], [3]])
await wrapper.setProps({ meta: meta(3, 3) })
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeDefined()
await wrapper.find('[data-page="prev"]').trigger('click')
expect(wrapper.emitted('page')!.at(-1)).toEqual([2])
})
it('shows the plural zero-results message and no pager without rows', () => {
const wrapper = mount(Pagination, { props: { meta: meta(1, 0, 0), loading: false, perPageOptions: [20], perPage: 20 } })
expect(wrapper.find('[data-range]').text()).toBe('0 wyników')
expect(wrapper.find('nav').exists()).toBe(false)
const none = mount(Pagination, { props: { meta: null, loading: false, perPageOptions: [20], perPage: 20 } })
expect(none.find('[data-range]').text()).toBe('0 wyników')
})
it('shows the loading text and hides the pager while loading', () => {
const wrapper = mount(Pagination, { props: { meta: meta(1, 2), loading: true, perPageOptions: [20], perPage: 20 } })
expect(wrapper.find('[data-range]').text()).toBe('Wczytywanie…')
expect(wrapper.find('nav').exists()).toBe(false)
})
it('offers the per-page select only with more than one option and emits a valid choice', async () => {
const one = mount(Pagination, { props: { meta: meta(1, 2), loading: false, perPageOptions: [20], perPage: 20 } })
expect(one.find('[data-per-page]').exists()).toBe(false)
const wrapper = mount(Pagination, { props: { meta: meta(1, 2), loading: false, perPageOptions: [10, 20, 50], perPage: 20 } })
const select = wrapper.find('[data-per-page]')
expect(select.findAll('option').map((option) => option.text())).toEqual(['10', '20', '50'])
expect((select.element as HTMLSelectElement).value).toBe('20')
await select.setValue('50')
expect(wrapper.emitted('perPage')).toEqual([[50]])
})
})

View File

@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory } from 'vue-router'
import RelationManager from '../../src/components/relation/RelationManager.vue'
import { createAdminRouter } from '../../src/app/router'
import { toasts } from '../../src/state/useToasts'
import { clone, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
import { API, mockApi, queryOf, requestsTo, resetState, type Route } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const SCHEMA = `${API}/acme/demo/widgets/schema/relation/members`
const LINKED = `${API}/acme/demo/widgets/1/relations/members`
const UNLINK = `${LINKED}/unlink`
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${SCHEMA}`]: { body: relationSchemaFixture },
[`GET ${LINKED}`]: { body: linkedFixture.all },
...overrides,
}
}
async function mountManager(overrides: Record<string, Route> = {}, props: Record<string, unknown> = {}) {
const calls = mockApi(routes(overrides))
const wrapper = mount(RelationManager, {
props: { field: formField('members'), modelValue: undefined, controlId: 'field-members', source, recordId: 1, ...props },
// DataTable injects the router for its row links.
global: { plugins: [createAdminRouter(createMemoryHistory())] },
attachTo: document.body,
})
await flushPromises()
return { wrapper, calls }
}
async function selectFirstAndUnlink(wrapper: VueWrapper) {
await wrapper.find('tbody [role="checkbox"]').trigger('click')
await wrapper.find('[data-action="unlink"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('relation manager (D-05, design screen 5)', () => {
it('renders nothing and requests nothing without a saved record or a source', async () => {
for (const props of [{ recordId: null }, { source: null }]) {
const { wrapper, calls } = await mountManager({}, props)
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
expect(calls).toHaveLength(0)
wrapper.unmount()
}
})
it('is a region labelled by its heading and described by its comment', async () => {
const { wrapper } = await mountManager()
const region = wrapper.find('[data-relation-manager]')
expect(region.element.tagName).toBe('SECTION')
expect(region.attributes('id')).toBe('field-members')
expect(region.attributes('tabindex')).toBe('-1')
expect(region.attributes('aria-labelledby')).toBe('field-members-heading')
expect(wrapper.find('#field-members-heading').text()).toBe('Members')
expect(region.attributes('aria-describedby')).toBe('field-members-comment')
expect(wrapper.find('#field-members-comment').text()).toBe('People who may edit this widget')
expect(wrapper.find('[data-action="link"]').attributes('aria-haspopup')).toBe('dialog')
})
it('uses the relation key of the field, not its name, for the endpoints', async () => {
const field = { ...formField('members'), name: 'team', relation: 'members' }
const { wrapper, calls } = await mountManager({}, { field })
expect(wrapper.find('[data-relation-manager]').attributes('data-relation-name')).toBe('members')
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1)
})
it('announces a failed schema or list load as an alert', async () => {
const schemaDown = await mountManager({ [`GET ${SCHEMA}`]: failure(500, 'Boom') })
expect(schemaDown.wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać powiązanych rekordów.')
expect(requestsTo(schemaDown.calls, 'GET', LINKED)).toHaveLength(0)
schemaDown.wrapper.unmount()
const listDown = await mountManager({ [`GET ${LINKED}`]: failure(403, 'Forbidden') })
expect(listDown.wrapper.find('[role="alert"]').exists()).toBe(true)
expect(listDown.wrapper.find('table').exists()).toBe(false)
})
it('cycles sorting asc, desc, none through the linked query and resets the page', async () => {
const { wrapper, calls } = await mountManager()
const header = () => wrapper.find('[data-sort="email"]')
await header().trigger('click')
await flushPromises()
await header().trigger('click')
await flushPromises()
await header().trigger('click')
await flushPromises()
const queries = requestsTo(calls, 'GET', LINKED).map((request) => queryOf(request))
expect(queries.map((query) => [query.get('sort'), query.get('dir'), query.get('page')])).toEqual([
[null, null, '1'],
['email', 'asc', '1'],
['email', 'desc', '1'],
[null, null, '1'],
])
expect(wrapper.find('th[aria-sort]').attributes('aria-sort')).toBe('none')
})
it('pages the linked rows only when there is more than one page', async () => {
expect((await mountManager()).wrapper.find('[data-page="next"]').exists()).toBe(false)
const paged = clone(linkedFixture.all)
paged.meta = { page: 1, per_page: 3, total: 5, last_page: 2 }
const { wrapper, calls } = await mountManager({ [`GET ${LINKED}`]: { body: paged } })
await wrapper.find('[data-page="next"]').trigger('click')
await flushPromises()
expect(queryOf(requestsTo(calls, 'GET', LINKED).at(-1)!).get('page')).toBe('2')
})
it('shows no unlink column or button when the view does not declare unlink', async () => {
const schema = clone(relationSchemaFixture)
schema.data.view.toolbarButtons = ['link']
schema.data.view.showSearch = false
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: schema } })
expect(wrapper.find('[data-action="unlink"]').exists()).toBe(false)
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
expect(wrapper.find('[data-relation-search]').exists()).toBe(false)
})
it('toasts the server message when an unlink is refused and keeps the selection', async () => {
const { wrapper } = await mountManager({ [`POST ${UNLINK}`]: failure(409, 'Owner cannot be removed') })
await selectFirstAndUnlink(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Owner cannot be removed', tone: 'danger' })
expect(wrapper.find('tbody tr').attributes('aria-selected')).toBe('true')
})
it('toasts the generic error when the unlink cannot reach the server', async () => {
const { wrapper } = await mountManager({
[`POST ${UNLINK}`]: () => {
throw new TypeError('offline')
},
})
await selectFirstAndUnlink(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
expect(wrapper.find('[data-action="unlink"]').attributes('disabled')).toBeUndefined()
})
})

View File

@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import RelationPickerModal from '../../src/components/relation/RelationPickerModal.vue'
import { toasts } from '../../src/state/useToasts'
import { candidatesFixture, relationSchemaFixture } from '../fixtures/typed'
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
const CANDIDATES = `${API}/acme/demo/widgets/1/relations/members/candidates`
const LINK = `${API}/acme/demo/widgets/1/relations/members/link`
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${CANDIDATES}`]: (request) => ({
body: queryOf(request).get('page') === '2' ? candidatesFixture.page2 : candidatesFixture.page1,
}),
...overrides,
}
}
async function openModal(overrides: Record<string, Route> = {}) {
const calls = mockApi(routes(overrides))
const wrapper = mount(RelationPickerModal, {
props: { open: true, source, recordId: 1, relation: 'members', schema: relationSchemaFixture.data },
attachTo: document.body,
})
await flushPromises()
await wait(0)
await flushPromises()
return { wrapper, calls }
}
const dialog = () => document.body.querySelector<HTMLElement>('[data-relation-picker]')
const options = () => [...(dialog()?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])]
const confirmButton = () => dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm-link"]')!
async function click(element: Element | null | undefined) {
;(element as HTMLElement).click()
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('relation picker modal', () => {
it('is a titled, described modal dialog with a labelled multi-select listbox', async () => {
await openModal()
const element = dialog()!
expect(element.getAttribute('role')).toBe('dialog')
expect(element.getAttribute('aria-modal')).toBe('true')
const title = document.getElementById(element.getAttribute('aria-labelledby')!)
expect(title?.textContent).toBe('Add member')
const description = document.getElementById(element.getAttribute('aria-describedby')!)
expect(description?.textContent).toContain('The owner is not listed.')
const listbox = element.querySelector('[role="listbox"]')!
expect(listbox.getAttribute('aria-multiselectable')).toBe('true')
expect(listbox.getAttribute('aria-label')).toBe('Add member')
expect(element.querySelector('[data-candidate-search]')!.getAttribute('aria-controls')).toBe(listbox.id)
expect(element.querySelector('[data-action="close"]')!.getAttribute('aria-label')).toBe('Zamknij')
expect(options().every((option) => option.getAttribute('tabindex') === '0')).toBe(true)
})
it('shows initials and both columns for each candidate as served', async () => {
await openModal()
const first = options()[0]!
expect(first.querySelector('[aria-hidden="true"]')!.textContent).toBe('AT')
expect(first.textContent).toContain('alan.turing@example.test')
})
it('disables prev on the first page and next on the last', async () => {
await openModal()
expect(dialog()!.querySelector<HTMLButtonElement>('[data-page="prev"]')!.disabled).toBe(true)
await click(dialog()!.querySelector('[data-page="next"]'))
expect(dialog()!.querySelector<HTMLButtonElement>('[data-page="next"]')!.disabled).toBe(true)
})
it('toggles with Enter and moves between options with the arrow keys', async () => {
await openModal()
const [first, second] = options()
first!.focus()
expect(keydown(first!, 'Enter').defaultPrevented).toBe(true)
await flushPromises()
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
keydown(first!, 'ArrowDown')
expect(document.activeElement).toBe(second)
keydown(second!, 'ArrowUp')
expect(document.activeElement).toBe(first)
keydown(first!, 'ArrowUp')
expect(document.activeElement).toBe(first)
keydown(first!, 'Enter')
await flushPromises()
expect(options()[0]!.getAttribute('aria-selected')).toBe('false')
expect(keydown(first!, 'x').defaultPrevented).toBe(false)
})
it('says so when no candidate is left', async () => {
await openModal({ [`GET ${CANDIDATES}`]: { body: candidatesFixture.empty } })
expect(dialog()!.querySelector('[data-candidates-empty]')!.textContent?.trim()).toBe('Brak rekordów do dodania.')
expect(dialog()!.querySelector('nav')).toBeNull()
})
it('treats a failed candidates load as empty', async () => {
await openModal({ [`GET ${CANDIDATES}`]: failure(500, 'Boom') })
expect(options()).toHaveLength(0)
expect(dialog()!.querySelector('[data-candidates-empty]')).not.toBeNull()
})
it('toasts the server message when the link is refused and stays open', async () => {
const { wrapper, calls } = await openModal({ [`POST ${LINK}`]: failure(422, 'Already a member') })
await click(options()[0])
await click(confirmButton())
expect(requestsTo(calls, 'POST', LINK)).toHaveLength(1)
expect(toasts.value[0]).toMatchObject({ text: 'Already a member', tone: 'danger' })
expect(dialog()).not.toBeNull()
expect(wrapper.emitted('linked')).toBeUndefined()
})
it('toasts the generic error when the link cannot reach the server', async () => {
await openModal({
[`POST ${LINK}`]: () => {
throw new TypeError('offline')
},
})
await click(options()[1])
await click(confirmButton())
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
expect(confirmButton().disabled).toBe(false)
})
it('emits the linked count and closes after a successful link', async () => {
const { wrapper } = await openModal({ [`POST ${LINK}`]: { body: { data: {}, meta: {} } } })
await click(options()[0])
await click(options()[2])
await click(confirmButton())
expect(wrapper.emitted('update:open')).toEqual([[false]])
// Without a server count the selection size is reported.
expect(wrapper.emitted('linked')).toEqual([[2]])
})
it('clears the search, page and selection when it closes', async () => {
const { wrapper, calls } = await openModal()
await click(options()[0])
await wrapper.setProps({ open: false })
await flushPromises()
await wrapper.setProps({ open: true })
await flushPromises()
await wait(0)
await flushPromises()
expect(confirmButton().textContent?.trim()).toBe('Dodaj (0)')
expect(queryOf(requestsTo(calls, 'GET', CANDIDATES).at(-1)!).get('page')).toBe('1')
})
})

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import AppShell from '../../src/components/shell/AppShell.vue'
import { setNavigation } from '../../src/state/useNavigation'
import { NARROW_QUERY, setCollapsed } from '../../src/state/useSidebar'
import { showToast } from '../../src/state/useToasts'
import { navigationFixture } from '../fixtures/typed'
import { fakeMedia, resetState, routerAt, signIn } from '../helpers'
async function mountShell(path: string) {
await signIn()
setNavigation(navigationFixture.data)
const router = await routerAt(path)
const wrapper = mount(AppShell, {
slots: { default: '<p data-page>content</p>' },
global: { plugins: [router] },
attachTo: document.body,
})
await flushPromises()
return wrapper
}
beforeEach(() => {
resetState()
setCollapsed(false)
})
afterEach(() => {
setCollapsed(false)
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('app shell', () => {
it('renders rail, section panel, header landmarks and the page in main', async () => {
const wrapper = await mountShell('/acme/demo/widgets')
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(true)
expect(wrapper.find('aside[aria-label="Sekcje"]').exists()).toBe(true)
expect(wrapper.find('header nav[aria-label="Ścieżka nawigacji"]').exists()).toBe(true)
expect(wrapper.find('header [data-user-menu]').exists()).toBe(true)
expect(wrapper.find('main [data-page]').text()).toBe('content')
})
it('has no section panel outside a permitted plugin', async () => {
const wrapper = await mountShell('/settings')
expect(wrapper.find('aside').exists()).toBe(false)
})
it('hides the section panel when collapsed by the admin or by the viewport', async () => {
const wrapper = await mountShell('/acme/demo/widgets')
setCollapsed(true)
await flushPromises()
expect(wrapper.find('aside').exists()).toBe(false)
setCollapsed(false)
await flushPromises()
expect(wrapper.find('aside').exists()).toBe(true)
})
it('follows the viewport while mounted and stops listening on unmount', async () => {
const media = fakeMedia({ [NARROW_QUERY]: false })
const wrapper = await mountShell('/acme/demo/widgets')
expect(media.listeners(NARROW_QUERY)).toBe(1)
media.change(NARROW_QUERY, true)
await flushPromises()
expect(wrapper.find('aside').exists()).toBe(false)
wrapper.unmount()
expect(media.listeners(NARROW_QUERY)).toBe(0)
})
it('mounts the toast queue with status and alert roles', async () => {
const wrapper = await mountShell('/acme/demo/widgets')
showToast('Saved')
showToast('Broken', 'danger')
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain('Saved')
expect(wrapper.find('[role="alert"]').text()).toContain('Broken')
})
})

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import Breadcrumbs from '../../src/components/shell/Breadcrumbs.vue'
import { setRecordCrumb } from '../../src/state/useBreadcrumbs'
import { setNavigation } from '../../src/state/useNavigation'
import { setSettings } from '../../src/state/useSettings'
import { navigationFixture, settingsFixture } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
async function crumbsAt(path: string) {
await signIn()
setNavigation(navigationFixture.data)
setSettings(settingsFixture.list.data)
const router = await routerAt(path)
const wrapper = mount(Breadcrumbs, { global: { plugins: [router] } })
await flushPromises()
const items = wrapper.findAll('[data-crumb]')
return {
wrapper,
labels: items.map((item) => item.text()),
links: items.map((item) => (item.find('a').exists() ? item.find('a').attributes('href') : null)),
current: items.map((item) => item.find('[aria-current="page"]').exists()),
}
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('breadcrumbs', () => {
it('is a labelled nav listing plugin and controller, the last one current', async () => {
const { wrapper, labels, links, current } = await crumbsAt('/acme/demo/gadgets')
expect(wrapper.find('nav').attributes('aria-label')).toBe('Ścieżka nawigacji')
expect(wrapper.find('ol').exists()).toBe(true)
expect(labels).toEqual(['Demo', 'Gadgets'])
expect(links).toEqual(['/acme/demo/widgets', null])
expect(current).toEqual([false, true])
expect(wrapper.findAll('svg[aria-hidden="true"]')).toHaveLength(1)
})
it('adds the record title on record and create routes only', async () => {
setRecordCrumb('Blue widget')
let crumbs = await crumbsAt('/acme/demo/widgets/1')
expect(crumbs.labels).toEqual(['Demo', 'Widgets', 'Blue widget'])
expect(crumbs.links).toEqual(['/acme/demo/widgets', '/acme/demo/widgets', null])
expect(crumbs.current).toEqual([false, false, true])
setRecordCrumb('New widget')
crumbs = await crumbsAt('/acme/demo/widgets/create')
expect(crumbs.labels).toEqual(['Demo', 'Widgets', 'New widget'])
crumbs = await crumbsAt('/acme/demo/widgets')
expect(crumbs.labels).toEqual(['Demo', 'Widgets'])
})
it('leaves out an empty record title', async () => {
const { labels } = await crumbsAt('/acme/demo/widgets/1')
expect(labels).toEqual(['Demo', 'Widgets'])
})
it('shows Ustawienia and the page label on settings routes', async () => {
expect((await crumbsAt('/settings')).labels).toEqual(['Ustawienia'])
const page = await crumbsAt('/settings/mail')
expect(page.labels).toEqual(['Ustawienia', 'Mail'])
expect(page.links).toEqual(['/settings', null])
expect((await crumbsAt('/settings/unknown')).labels).toEqual(['Ustawienia'])
})
it('is empty outside any permitted plugin', async () => {
expect((await crumbsAt('/acme/secret/things')).labels).toEqual([])
expect((await crumbsAt('/acme/demo/unlisted')).labels).toEqual(['Demo'])
})
})

View File

@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import PluginRail from '../../src/components/shell/PluginRail.vue'
import { setNavigation } from '../../src/state/useNavigation'
import { setSettings } from '../../src/state/useSettings'
import { NARROW_QUERY, SIDEBAR_KEY, setCollapsed, watchViewport } from '../../src/state/useSidebar'
import { navigationFixture, settingsFixture } from '../fixtures/typed'
import { fakeMedia, keydown, resetState, routerAt, signIn, wait } from '../helpers'
async function mountRail(path: string) {
await signIn()
setNavigation(navigationFixture.data)
const router = await routerAt(path)
const wrapper = mount(PluginRail, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return { wrapper, router }
}
let stopViewport: (() => void) | null = null
beforeEach(() => {
resetState()
setCollapsed(false)
stopViewport = null
})
afterEach(() => {
stopViewport?.()
setCollapsed(false)
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('plugin rail', () => {
it('is a labelled navigation of the permitted plugins in order, the active one aria-current', async () => {
const { wrapper } = await mountRail('/acme/demo/gadgets')
const nav = wrapper.find('nav')
expect(nav.attributes('aria-label')).toBe('Wtyczki')
const items = wrapper.findAll('[data-rail-item]')
expect(items.map((item) => item.attributes('data-rail-item'))).toEqual(['demo', 'tools'])
expect(items.map((item) => item.text())).toEqual(['Demo', 'Tools'])
expect(items[0]!.attributes('aria-current')).toBe('page')
expect(items[1]!.attributes('aria-current')).toBeUndefined()
expect(items[0]!.attributes('href')).toBe('/acme/demo/widgets')
expect(items[1]!.attributes('href')).toBe('/acme/tools/hammers')
// Expanded: no popup semantics on the items.
expect(items[0]!.attributes('aria-haspopup')).toBeUndefined()
expect(items[0]!.attributes('aria-expanded')).toBeUndefined()
})
it('shows Ustawienia only with a permitted settings page, current on settings routes', async () => {
const { wrapper } = await mountRail('/settings/mail')
expect(wrapper.find('[data-rail-settings]').exists()).toBe(false)
setSettings(settingsFixture.list.data)
await flushPromises()
const link = wrapper.find('[data-rail-settings]')
expect(link.text()).toBe('Ustawienia')
expect(link.attributes('aria-current')).toBe('page')
})
it('offers menu semantics and the expand button after a manual collapse', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
setCollapsed(true)
await flushPromises()
const item = wrapper.find('[data-rail-item="demo"]')
expect(item.attributes('aria-haspopup')).toBe('menu')
expect(item.attributes('aria-expanded')).toBe('false')
const expand = wrapper.find('[data-sidebar-expand]')
expect(expand.attributes('aria-label')).toBe('Rozwiń menu')
await expand.trigger('click')
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
})
it('has no expand button when the viewport forces the collapse', async () => {
fakeMedia({ [NARROW_QUERY]: true })
stopViewport = watchViewport()
const { wrapper } = await mountRail('/acme/demo/widgets')
expect(wrapper.find('[data-rail-item="demo"]').attributes('aria-haspopup')).toBe('menu')
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
})
it('opens the flyout on hover, links it by aria-controls and closes it on expand', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
setCollapsed(true)
await flushPromises()
const item = wrapper.find('[data-rail-item="tools"]')
await item.trigger('mouseenter')
const flyout = document.querySelector('[data-section-flyout]')
expect(flyout?.getAttribute('aria-label')).toBe('Tools')
expect(item.attributes('aria-expanded')).toBe('true')
expect(item.attributes('aria-controls')).toBe(flyout?.id)
setCollapsed(false)
await flushPromises()
expect(document.querySelector('[data-section-flyout]')).toBeNull()
})
it('ignores keys while expanded and closes an open flyout on Escape', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
const item = wrapper.find('[data-rail-item="demo"]').element
expect(keydown(item, 'Enter').defaultPrevented).toBe(false)
setCollapsed(true)
await flushPromises()
expect(keydown(item, 'ArrowDown').defaultPrevented).toBe(true)
await flushPromises()
await wait(0)
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
expect(keydown(item, 'Escape').defaultPrevented).toBe(true)
await flushPromises()
expect(document.querySelector('[data-section-flyout]')).toBeNull()
expect(keydown(item, 'Escape').defaultPrevented).toBe(false)
})
it('keeps the flyout open while focus stays on its rail item', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
setCollapsed(true)
await flushPromises()
const item = wrapper.find('[data-rail-item="demo"]')
;(item.element as HTMLElement).focus()
await item.trigger('focus')
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
await item.trigger('mouseleave')
await wait(250)
expect(document.querySelector('[data-section-flyout]')).not.toBeNull()
;(item.element as HTMLElement).blur()
await item.trigger('blur')
await wait(250)
await flushPromises()
expect(document.querySelector('[data-section-flyout]')).toBeNull()
})
})

View File

@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import SectionFlyout from '../../src/components/shell/SectionFlyout.vue'
import type { NavigationEntry } from '../../src/api/types'
import { clone, navigationFixture } from '../fixtures/typed'
import { keydown, resetState, routerAt, signIn } from '../helpers'
function entryWithThree(): NavigationEntry {
const entry = clone(navigationFixture.data[0]!)
entry.sideMenu.push({ code: 'gizmos', label: 'Gizmos', icon: 'list', order: 0, controller: 'acme.demo.gizmos', sideMenu: [] })
entry.sideMenu.push({ code: 'broken', label: 'Broken', icon: 'list', order: 0, controller: 'bad', sideMenu: [] })
return entry
}
async function mountFlyout(path = '/acme/demo/gadgets') {
await signIn()
const router = await routerAt(path)
const wrapper = mount(SectionFlyout, {
props: { entry: entryWithThree(), id: 'section-flyout-demo' },
global: { plugins: [router] },
attachTo: document.body,
})
await flushPromises()
return wrapper
}
const active = () => document.activeElement?.textContent?.trim()
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('section flyout', () => {
it('is a labelled menu of menuitems outside the tab order, the current one aria-current', async () => {
const wrapper = await mountFlyout()
const menu = wrapper.find('[role="menu"]')
expect(menu.attributes('id')).toBe('section-flyout-demo')
expect(menu.attributes('aria-label')).toBe('Demo')
const items = wrapper.findAll('[role="menuitem"]')
expect(items.map((item) => item.text())).toEqual(['Widgets', 'Gadgets', 'Gizmos'])
expect(items.every((item) => item.attributes('tabindex') === '-1')).toBe(true)
expect(items[1]!.attributes('aria-current')).toBe('page')
expect(items[0]!.attributes('aria-current')).toBeUndefined()
})
it('focuses the first item and moves with arrows, Home and End, wrapping around', async () => {
const wrapper = await mountFlyout()
const menu = wrapper.find('[role="menu"]').element
await (wrapper.vm as unknown as { focusFirst: () => Promise<void> }).focusFirst()
expect(active()).toBe('Widgets')
keydown(document.activeElement!, 'ArrowDown')
expect(active()).toBe('Gadgets')
keydown(document.activeElement!, 'End')
expect(active()).toBe('Gizmos')
keydown(document.activeElement!, 'ArrowDown')
expect(active()).toBe('Widgets')
keydown(document.activeElement!, 'ArrowUp')
expect(active()).toBe('Gizmos')
keydown(document.activeElement!, 'Home')
expect(active()).toBe('Widgets')
expect(keydown(menu, 'x').defaultPrevented).toBe(false)
})
it('asks the rail to close on Escape and reports Tab and navigation', async () => {
const wrapper = await mountFlyout()
const menu = wrapper.find('[role="menu"]').element
expect(keydown(menu, 'Escape').defaultPrevented).toBe(true)
keydown(menu, 'Tab')
await wrapper.findAll('[role="menuitem"]')[2]!.trigger('click')
expect(wrapper.emitted('escape')).toHaveLength(1)
expect(wrapper.emitted('leave')).toHaveLength(1)
expect(wrapper.emitted('navigate')).toHaveLength(1)
})
it('tells whether an element sits inside it', async () => {
const wrapper = await mountFlyout()
const vm = wrapper.vm as unknown as { contains: (element: Element | null) => boolean }
expect(vm.contains(wrapper.find('[role="menuitem"]').element)).toBe(true)
expect(vm.contains(document.body)).toBe(false)
expect(vm.contains(null)).toBe(false)
})
})

View File

@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import SectionPanel from '../../src/components/shell/SectionPanel.vue'
import { SIDEBAR_KEY, setCollapsed, sidebarCollapsed } from '../../src/state/useSidebar'
import { clone, navigationFixture } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
beforeEach(() => {
resetState()
setCollapsed(false)
})
afterEach(() => {
setCollapsed(false)
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
async function mountPanel(path: string, entry = navigationFixture.data[0]!) {
await signIn()
const router = await routerAt(path)
const wrapper = mount(SectionPanel, { props: { entry }, global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return wrapper
}
describe('section panel', () => {
it('is a labelled region with the plugin label and its permitted side menu', async () => {
const wrapper = await mountPanel('/acme/demo/gadgets/4')
expect(wrapper.find('aside').attributes('aria-label')).toBe('Sekcje')
expect(wrapper.find('aside span').text()).toBe('Demo')
const links = wrapper.findAll('a')
expect(links.map((link) => [link.text(), link.attributes('href')])).toEqual([
['Widgets', '/acme/demo/widgets'],
['Gadgets', '/acme/demo/gadgets'],
])
expect(links[0]!.attributes('aria-current')).toBeUndefined()
expect(links[1]!.attributes('aria-current')).toBe('page')
})
it('skips side-menu items whose controller id is invalid', async () => {
const entry = clone(navigationFixture.data[0]!)
entry.sideMenu[0]!.controller = 'not a controller'
const wrapper = await mountPanel('/acme/demo/gadgets', entry)
expect(wrapper.findAll('a').map((link) => link.text())).toEqual(['Gadgets'])
})
it('collapses on "Zwiń menu" and persists the choice', async () => {
const wrapper = await mountPanel('/acme/demo/widgets')
const button = wrapper.find('[data-sidebar-collapse]')
expect(button.attributes('aria-label')).toBe('Zwiń menu')
expect(button.attributes('type')).toBe('button')
await button.trigger('click')
expect(sidebarCollapsed.value).toBe(true)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
})
})

View File

@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import UserMenu from '../../src/components/shell/UserMenu.vue'
import { currentUser } from '../../src/state/useAuth'
import { API, profile, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
async function mountMenu(user: Partial<typeof profile.data> | null) {
let calls: Request[] = []
if (user) {
calls = await signIn({
[`GET ${API}/auth/me`]: { body: { data: { ...profile.data, ...user }, meta: {} } },
[`POST ${API}/auth/logout`]: { body: { data: { status: 'logged_out' }, meta: {} } },
})
}
const router = await routerAt('/acme/demo/widgets')
const wrapper = mount(UserMenu, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return { wrapper, router, calls }
}
async function open(wrapper: ReturnType<typeof mount>): Promise<HTMLElement> {
const trigger = wrapper.find('[data-user-menu]')
await trigger.trigger('keydown', { key: 'Enter' })
await flushPromises()
const content = document.querySelector<HTMLElement>('[data-user-menu-content]')
expect(content).not.toBeNull()
return content!
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('user menu', () => {
it('labels the trigger with the name and shows initials, name and role', async () => {
const { wrapper } = await mountMenu({})
const trigger = wrapper.find('[data-user-menu]')
expect(trigger.attributes('aria-label')).toBe('Menu użytkownika: Dana Dev')
expect(trigger.attributes('aria-haspopup')).toBe('menu')
expect(trigger.attributes('disabled')).toBeUndefined()
expect(wrapper.find('[data-user-initials]').text()).toBe('DD')
expect(wrapper.find('[data-user-initials]').attributes('aria-hidden')).toBe('true')
expect(wrapper.find('[data-user-role]').text()).toBe('Developer')
})
it('falls back to the login for the name and initials and hides a missing role', async () => {
const { wrapper } = await mountMenu({ first_name: ' ', last_name: '', login: 'root', role: undefined })
expect(wrapper.find('[data-user-identity]').text()).toBe('root')
expect(wrapper.find('[data-user-initials]').text()).toBe('RO')
expect(wrapper.find('[data-user-role]').exists()).toBe(false)
})
it('uses a single initial for a single name', async () => {
const { wrapper } = await mountMenu({ first_name: 'ada', last_name: '' })
expect(wrapper.find('[data-user-initials]').text()).toBe('A')
})
it('is disabled without a signed-in admin', async () => {
const { wrapper } = await mountMenu(null)
expect(currentUser.value).toBeNull()
expect(wrapper.find('[data-user-menu]').attributes('disabled')).toBeDefined()
expect(wrapper.find('[data-user-initials]').text()).toBe('')
})
it('opens a menu with the e-mail and logs out to the login route', async () => {
const { wrapper, router, calls } = await mountMenu({})
const content = await open(wrapper)
expect(content.getAttribute('role')).toBe('menu')
expect(content.textContent).toContain('dev@example.test')
const item = content.querySelector<HTMLElement>('[data-action="logout"]')!
expect(item.getAttribute('role')).toBe('menuitem')
expect(item.textContent?.trim()).toBe('Wyloguj')
item.click()
await flushPromises()
await wait(0)
await flushPromises()
expect(requestsTo(calls, 'POST', `${API}/auth/logout`)).toHaveLength(1)
expect(currentUser.value).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
})

View File

@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearUser, currentUser, login, logout, me, useAuth } from '../../src/state/useAuth'
import { navigation, setNavigation } from '../../src/state/useNavigation'
import { setSettings, settings } from '../../src/state/useSettings'
import { navigationFixture, settingsFixture } from '../fixtures/typed'
import { API, mockApi, profile, requestsTo, resetState } from '../helpers'
const loginOk = (expiresIn: unknown) => ({ body: { data: { token_type: 'cookie', expires_in: expiresIn }, meta: {} } })
const refreshOk = { body: { data: { token_type: 'cookie', expires_in: 50 }, meta: {} } }
const unauthorized = { status: 401, body: { error: { code: 'unauthenticated', message: 'no', details: {} } } }
beforeEach(() => {
resetState()
})
afterEach(() => {
clearUser()
vi.useRealTimers()
})
describe('login', () => {
it('posts the credentials and schedules the proactive refresh at 80 percent', async () => {
vi.useFakeTimers()
const calls = mockApi({ [`POST ${API}/auth/login`]: loginOk(100), [`POST ${API}/auth/refresh`]: refreshOk })
expect(await login('dev', 'secret')).toBe(true)
const body = (await requestsTo(calls, 'POST', `${API}/auth/login`)[0]!.clone().json()) as Record<string, string>
expect(body).toEqual({ login: 'dev', password: 'secret' })
expect(useAuth().expiresIn.value).toBe(100)
await vi.advanceTimersByTimeAsync(79_999)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
await vi.advanceTimersByTimeAsync(1)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
// The refresh reschedules from its own lifetime (50 s -> 40 s).
expect(useAuth().expiresIn.value).toBe(50)
await vi.advanceTimersByTimeAsync(40_000)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(2)
})
it('answers false on rejected credentials and schedules nothing', async () => {
vi.useFakeTimers()
const calls = mockApi({ [`POST ${API}/auth/login`]: unauthorized, [`POST ${API}/auth/refresh`]: refreshOk })
expect(await login('dev', 'bad')).toBe(false)
await vi.advanceTimersByTimeAsync(10_000_000)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
})
it.each([0, -5, 'soon', null])('schedules no refresh for the lifetime %j', async (expiresIn) => {
vi.useFakeTimers()
const calls = mockApi({ [`POST ${API}/auth/login`]: loginOk(expiresIn), [`POST ${API}/auth/refresh`]: refreshOk })
expect(await login('dev', 'secret')).toBe(true)
await vi.advanceTimersByTimeAsync(10_000_000)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
})
})
describe('me and clearUser', () => {
it('loads the profile, and clears it on 401', async () => {
mockApi({ [`GET ${API}/auth/me`]: { body: profile } })
expect((await me())?.login).toBe('dev')
expect(currentUser.value?.role?.name).toBe('Developer')
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
expect(await me()).toBeNull()
expect(currentUser.value).toBeNull()
})
it('clearUser forgets the user and cancels the pending refresh', async () => {
vi.useFakeTimers()
const calls = mockApi({
[`GET ${API}/auth/me`]: { body: profile },
[`POST ${API}/auth/login`]: loginOk(10),
[`POST ${API}/auth/refresh`]: refreshOk,
})
await login('dev', 'secret')
await me()
clearUser()
expect(currentUser.value).toBeNull()
expect(useAuth().expiresIn.value).toBeNull()
await vi.advanceTimersByTimeAsync(60_000)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
})
})
describe('logout (T-10-23)', () => {
async function signedIn(logoutRoute: Parameters<typeof mockApi>[0][string] | 'throw') {
const routes: Parameters<typeof mockApi>[0] = { [`GET ${API}/auth/me`]: { body: profile } }
if (logoutRoute !== 'throw') {
routes[`POST ${API}/auth/logout`] = logoutRoute
}
const calls = mockApi(routes)
await me()
setNavigation(navigationFixture.data)
setSettings(settingsFixture.list.data)
if (logoutRoute === 'throw') {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
}
return calls
}
it.each([
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }],
['fails on the network', 'throw' as const],
])('clears the user, navigation and settings and routes to login when the call %s', async (_label, route) => {
const calls = await signedIn(route)
const router = { replace: vi.fn().mockResolvedValue(undefined) }
await logout(router)
expect(currentUser.value).toBeNull()
expect(navigation.value).toEqual([])
expect(settings.value).toEqual([])
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
if (route !== 'throw') {
const posted = requestsTo(calls, 'POST', `${API}/auth/logout`)
expect(posted).toHaveLength(1)
expect(posted[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
}
})
it('works without a router and swallows a failed navigation', async () => {
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
await expect(logout()).resolves.toBeUndefined()
expect(currentUser.value).toBeNull()
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
await expect(logout({ replace: vi.fn().mockRejectedValue(new Error('aborted')) })).resolves.toBeUndefined()
expect(currentUser.value).toBeNull()
})
it('exposes the session API through useAuth', () => {
const auth = useAuth()
expect(auth.user).toBe(currentUser)
expect(auth.login).toBe(login)
expect(auth.logout).toBe(logout)
expect(auth.me).toBe(me)
expect(auth.clearUser).toBe(clearUser)
})
})

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { clearRecordCrumb, recordCrumb, setRecordCrumb } from '../../src/state/useBreadcrumbs'
describe('record crumb', () => {
it('publishes and clears the record title', () => {
expect(recordCrumb.value).toBe('')
setRecordCrumb('Blue widget')
expect(recordCrumb.value).toBe('Blue widget')
clearRecordCrumb()
expect(recordCrumb.value).toBe('')
})
})

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { NavigationEntry } from '../../src/api/types'
import {
activeEntry,
firstControllerPath,
homePath,
loadNavigation,
navigation,
railEntries,
setNavigation,
useNavigation,
} from '../../src/state/useNavigation'
import { clone, navigationFixture } from '../fixtures/typed'
import { API, mockApi, requestsTo, resetState } from '../helpers'
function entry(code: string, order: number, controllers: string[], controller = controllers[0] ?? 'x.y.z'): NavigationEntry {
return {
code,
label: code,
icon: 'tags',
order,
controller,
sideMenu: controllers.map((id, index) => ({ code: `${code}-${index}`, label: id, icon: 'tags', order: 0, controller: id, sideMenu: [] })),
}
}
beforeEach(() => {
resetState()
})
describe('loadNavigation', () => {
it('stores the server-filtered entries', async () => {
const calls = mockApi({ [`GET ${API}/navigation`]: { body: navigationFixture } })
expect(await loadNavigation()).toHaveLength(3)
expect(navigation.value.map((item) => item.code)).toEqual(['demo', 'tools', 'hidden'])
expect(requestsTo(calls, 'GET', `${API}/navigation`)).toHaveLength(1)
})
it('empties the navigation when the request fails', async () => {
setNavigation(navigationFixture.data)
mockApi({ [`GET ${API}/navigation`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } } })
expect(await loadNavigation()).toEqual([])
expect(navigation.value).toEqual([])
})
})
describe('rail entries', () => {
it('omits plugins without a permitted side-menu item and orders by order, then position', () => {
setNavigation([entry('b', 20, ['a.b.c']), entry('empty', 1, []), entry('a', 10, ['a.a.c']), entry('c', 20, ['a.c.c'])])
expect(railEntries.value.map((item) => item.code)).toEqual(['a', 'b', 'c'])
})
it('opens the first valid side-menu controller, else the entry controller', () => {
expect(firstControllerPath(entry('p', 0, ['bad id', 'acme.demo.gadgets']))).toBe('/acme/demo/gadgets')
expect(firstControllerPath({ ...entry('p', 0, ['bad id']), controller: 'acme.demo.widgets' })).toBe('/acme/demo/widgets')
expect(firstControllerPath({ ...entry('p', 0, []), controller: 'nope' })).toBeNull()
})
it('finds the active plugin by its own controller or any side-menu controller', () => {
setNavigation(navigationFixture.data)
expect(activeEntry('acme', 'demo')?.code).toBe('demo')
expect(activeEntry('acme', 'tools')?.code).toBe('tools')
expect(activeEntry('acme', 'hidden')).toBeNull()
expect(activeEntry('acme', 'missing')).toBeNull()
const shared = clone(navigationFixture.data)
shared[0]!.controller = 'other.root.index'
setNavigation(shared)
expect(activeEntry('acme', 'demo')?.code).toBe('demo')
})
it('goes home to the first rail plugin, skipping plugins without a valid path', () => {
expect(homePath()).toBeNull()
setNavigation([{ ...entry('broken', 1, ['bad id']), controller: 'bad' }, entry('ok', 2, ['acme.tools.hammers'])])
expect(homePath()).toBe('/acme/tools/hammers')
setNavigation(navigationFixture.data)
expect(homePath()).toBe('/acme/demo/widgets')
expect(useNavigation().homePath).toBe(homePath)
})
})

View File

@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
hasSettings,
loadSettings,
setSettings,
settings,
settingsEntry,
settingsGroups,
useSettings,
} from '../../src/state/useSettings'
import { settingsFixture } from '../fixtures/typed'
import { API, mockApi, resetState } from '../helpers'
beforeEach(() => {
resetState()
})
describe('useSettings (D-21)', () => {
it('is hidden until at least one settings page is permitted', async () => {
expect(hasSettings.value).toBe(false)
mockApi({ [`GET ${API}/settings`]: { body: settingsFixture.list } })
expect(await loadSettings()).toHaveLength(3)
expect(hasSettings.value).toBe(true)
expect(useSettings().settings).toBe(settings)
})
it('hides settings again when the request fails', async () => {
setSettings(settingsFixture.list.data)
mockApi({ [`GET ${API}/settings`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } } })
expect(await loadSettings()).toEqual([])
expect(hasSettings.value).toBe(false)
})
it('finds an entry by code', () => {
setSettings(settingsFixture.list.data)
expect(settingsEntry('mail')?.label).toBe('Mail')
expect(settingsEntry('nope')).toBeNull()
})
it('groups by category in first-seen order after sorting by order', () => {
setSettings(settingsFixture.list.data)
expect(settingsGroups.value.map((group) => [group.category, group.entries.map((entry) => entry.code)])).toEqual([
['Appearance', ['branding']],
['System', ['mail', 'search']],
])
})
})

View File

@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NARROW_QUERY, SIDEBAR_KEY, setCollapsed, sidebarCollapsed, useSidebar, watchViewport } from '../../src/state/useSidebar'
import { fakeMedia, resetState } from '../helpers'
let stop: (() => void) | null = null
beforeEach(() => {
resetState()
stop = null
})
afterEach(() => {
stop?.()
setCollapsed(false)
window.localStorage.clear()
})
describe('useSidebar (D-06, T-10-22)', () => {
it('uses the documented storage key and breakpoint', () => {
expect(SIDEBAR_KEY).toBe('summer-admin.sidebar')
expect(NARROW_QUERY).toBe('(max-width: 1099px)')
})
it('restores the persisted manual choice on a wide viewport', () => {
fakeMedia({ [NARROW_QUERY]: false })
window.localStorage.setItem(SIDEBAR_KEY, 'true')
stop = watchViewport()
expect(sidebarCollapsed.value).toBe(true)
expect(useSidebar().manual.value).toBe(true)
expect(useSidebar().narrow.value).toBe(false)
window.localStorage.setItem(SIDEBAR_KEY, 'yes')
stop()
stop = watchViewport()
expect(sidebarCollapsed.value).toBe(false)
})
it('collapses below the breakpoint without writing the stored choice', () => {
const media = fakeMedia({ [NARROW_QUERY]: true })
stop = watchViewport()
expect(sidebarCollapsed.value).toBe(true)
expect(useSidebar().narrow.value).toBe(true)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBeNull()
media.change(NARROW_QUERY, false)
expect(sidebarCollapsed.value).toBe(false)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBeNull()
stop()
stop = null
expect(media.listeners(NARROW_QUERY)).toBe(0)
media.change(NARROW_QUERY, true)
expect(useSidebar().narrow.value).toBe(false)
})
it("stores only 'true' or 'false' and nothing else", () => {
fakeMedia({})
stop = watchViewport()
setCollapsed(true)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
expect(sidebarCollapsed.value).toBe(true)
setCollapsed(false)
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
expect(sidebarCollapsed.value).toBe(false)
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
})
it('keeps the choice for the session when storage is unavailable', () => {
fakeMedia({})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError')
})
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('denied', 'SecurityError')
})
stop = watchViewport()
expect(sidebarCollapsed.value).toBe(false)
setCollapsed(true)
expect(sidebarCollapsed.value).toBe(true)
})
it('never collapses by viewport when matchMedia is missing', () => {
const original = window.matchMedia
// @ts-expect-error simulating a browser without matchMedia
window.matchMedia = undefined
try {
stop = watchViewport()
expect(useSidebar().narrow.value).toBe(false)
expect(stop()).toBeUndefined()
stop = null
} finally {
window.matchMedia = original
}
})
})

View File

@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearToasts, dismissToast, showToast, TOAST_TIMEOUT, toasts, useToasts } from '../../src/state/useToasts'
beforeEach(() => {
vi.useFakeTimers()
clearToasts()
})
afterEach(() => {
clearToasts()
vi.useRealTimers()
})
describe('toast queue', () => {
it('queues newest first with success as the default tone', () => {
const first = showToast('Saved')
const second = showToast('Failed', 'danger')
expect(second).toBeGreaterThan(first)
expect(toasts.value.map((item) => [item.text, item.tone])).toEqual([
['Failed', 'danger'],
['Saved', 'success'],
])
})
it('dismisses each toast after about 4 seconds', () => {
expect(TOAST_TIMEOUT).toBe(4000)
showToast('one')
vi.advanceTimersByTime(1000)
showToast('two')
vi.advanceTimersByTime(TOAST_TIMEOUT - 1000)
expect(toasts.value.map((item) => item.text)).toEqual(['two'])
vi.advanceTimersByTime(1000)
expect(toasts.value).toEqual([])
})
it('dismisses on request and cancels that toast timer', () => {
const id = showToast('bye')
const other = showToast('stay')
dismissToast(id)
expect(toasts.value.map((item) => item.id)).toEqual([other])
dismissToast(id)
dismissToast(999_999)
expect(vi.getTimerCount()).toBe(1)
})
it('clears every toast and timer', () => {
showToast('a')
showToast('b')
clearToasts()
expect(toasts.value).toEqual([])
expect(vi.getTimerCount()).toBe(0)
expect(useToasts().showToast).toBe(showToast)
})
})

155
admin/tests/ui/ui.test.ts Normal file
View File

@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { Trash2 } from '@lucide/vue'
import Button from '../../src/components/ui/Button.vue'
import ConfirmDialog from '../../src/components/ui/ConfirmDialog.vue'
import Toast from '../../src/components/ui/Toast.vue'
import { useConfirm } from '../../src/components/ui/confirm'
import { showToast, toasts } from '../../src/state/useToasts'
import { resetState, routerAt, wait } from '../helpers'
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('button', () => {
it('is a type=button outline button by default and can be disabled', () => {
const wrapper = mount(Button, { slots: { default: 'Go' } })
const button = wrapper.find('button')
expect(button.attributes('type')).toBe('button')
expect(button.classes()).toContain('border-border-strong')
expect(button.classes()).toContain('h-button')
expect(button.text()).toBe('Go')
const disabled = mount(Button, { props: { disabled: true, type: 'submit', size: 'sm', variant: 'primary' } })
expect(disabled.find('button').attributes('disabled')).toBeDefined()
expect(disabled.find('button').attributes('type')).toBe('submit')
expect(disabled.find('button').classes()).toContain('h-[38px]')
expect(disabled.find('button').classes()).toContain('bg-primary')
})
it('renders its icon hidden from assistive tech and the danger and ghost variants', () => {
const danger = mount(Button, { props: { variant: 'danger', icon: Trash2 }, slots: { default: 'Delete' } })
expect(danger.find('svg').attributes('aria-hidden')).toBe('true')
expect(danger.find('button').classes()).toContain('text-danger')
expect(mount(Button, { props: { variant: 'ghost' } }).find('button').classes()).toContain('text-muted')
})
it('renders a router link with the same styling when given a target', async () => {
const router = await routerAt('/login')
const wrapper = mount(Button, { props: { to: '/settings', variant: 'primary' }, slots: { default: 'Open' }, global: { plugins: [router] } })
const link = wrapper.find('a')
expect(link.attributes('href')).toBe('/settings')
expect(link.classes()).toContain('bg-primary')
expect(wrapper.find('button').exists()).toBe(false)
})
})
describe('confirm dialog', () => {
function dialog() {
return document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
}
it('is an alertdialog with the default title and labels, and confirms', async () => {
const wrapper = mount(ConfirmDialog, { props: { open: true, message: 'Delete 2 widgets?' }, attachTo: document.body })
await flushPromises()
const element = dialog()!
expect(element.getAttribute('role')).toBe('alertdialog')
expect(document.getElementById(element.getAttribute('aria-labelledby')!)?.textContent?.trim()).toBe('Potwierdź')
expect(document.getElementById(element.getAttribute('aria-describedby')!)?.textContent).toBe('Delete 2 widgets?')
expect(element.querySelector('[data-action="cancel"]')!.textContent?.trim()).toBe('Anuluj')
const confirm = element.querySelector<HTMLElement>('[data-action="confirm"]')!
expect(confirm.textContent?.trim()).toBe('Potwierdź')
expect(confirm.className).toContain('bg-primary')
confirm.click()
await flushPromises()
expect(wrapper.emitted('confirm')).toHaveLength(1)
expect(wrapper.emitted('update:open')).toEqual([[false]])
expect(wrapper.emitted('cancel')).toBeUndefined()
})
it('uses custom labels, the danger style and cancels on Anuluj', async () => {
const wrapper = mount(ConfirmDialog, {
props: { open: true, message: 'Sure?', title: 'Remove', confirmLabel: 'Remove', cancelLabel: 'Keep', danger: true },
attachTo: document.body,
})
await flushPromises()
const element = dialog()!
expect(element.textContent).toContain('Remove')
expect(element.querySelector('[data-action="confirm"]')!.className).toContain('bg-danger')
;(element.querySelector('[data-action="cancel"]') as HTMLElement).click()
await flushPromises()
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('confirm')).toBeUndefined()
})
it('renders a message with markup as text', async () => {
mount(ConfirmDialog, { props: { open: true, message: '<b>bold</b>' }, attachTo: document.body })
await flushPromises()
expect(dialog()!.querySelector('b')).toBeNull()
expect(dialog()!.textContent).toContain('<b>bold</b>')
})
it('renders nothing while closed', async () => {
mount(ConfirmDialog, { props: { open: false, message: 'x' }, attachTo: document.body })
await flushPromises()
expect(dialog()).toBeNull()
})
})
describe('useConfirm', () => {
it('resolves true on confirm and false on cancel, and clears the request', async () => {
const { request, ask, confirm, cancel } = useConfirm()
const yes = ask({ message: 'A?' })
expect(request.value).toEqual({ message: 'A?' })
confirm()
expect(await yes).toBe(true)
expect(request.value).toBeNull()
const no = ask({ message: 'B?', danger: true })
cancel()
expect(await no).toBe(false)
})
it('cancels a pending request when a second one is asked', async () => {
const { request, ask, confirm } = useConfirm()
const first = ask({ message: 'first' })
const second = ask({ message: 'second' })
expect(await first).toBe(false)
expect(request.value?.message).toBe('second')
confirm()
expect(await second).toBe(true)
confirm()
})
})
describe('toast stack', () => {
it('shows success as status and danger as alert, newest first, with a labelled close button', async () => {
const wrapper = mount(Toast, { attachTo: document.body })
showToast('Saved')
showToast('Failed', 'danger')
await flushPromises()
const items = wrapper.findAll('[data-tone]')
expect(items.map((item) => [item.attributes('role'), item.text()])).toEqual([
['alert', 'Failed'],
['status', 'Saved'],
])
const close = items[0]!.find('button')
expect(close.attributes('aria-label')).toBe('Zamknij')
await close.trigger('click')
await wait(0)
expect(toasts.value.map((item) => item.text)).toEqual(['Saved'])
})
it('renders toast text without markup', async () => {
const wrapper = mount(Toast, { attachTo: document.body })
showToast('<img src=x onerror=alert(1)>')
await flushPromises()
expect(wrapper.find('img').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import App from '../../src/App.vue'
import NotFoundView from '../../src/views/NotFoundView.vue'
import { setNavigation } from '../../src/state/useNavigation'
import { navigationFixture } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
async function mountAt(path: string) {
const router = await routerAt(path)
const wrapper = mount(App, { global: { plugins: [router] } })
await flushPromises()
return { wrapper, router }
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('app root', () => {
it('renders the login route outside the shell', async () => {
const { wrapper } = await mountAt('/login')
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(false)
expect(wrapper.find('form').exists()).toBe(true)
})
it('renders shell routes inside the shell and an unknown path as not found', async () => {
await signIn()
setNavigation(navigationFixture.data)
const { wrapper } = await mountAt('/nowhere')
expect(wrapper.find('nav[aria-label="Wtyczki"]').exists()).toBe(true)
expect(wrapper.findComponent(NotFoundView).exists()).toBe(true)
expect(wrapper.find('main h1').text()).toBe('Nie znaleziono strony.')
})
it('says so when the admin may open no plugin', async () => {
await signIn()
const { wrapper } = await mountAt('/')
expect(wrapper.find('main h1').text()).toBe('Brak dostępnych sekcji.')
})
})
describe('not found view', () => {
it('switches its message by the home prop', () => {
expect(mount(NotFoundView).find('h1').text()).toBe('Nie znaleziono strony.')
expect(mount(NotFoundView, { props: { home: true } }).find('h1').text()).toBe('Brak dostępnych sekcji.')
})
})

View File

@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory } from 'vue-router'
import LoginView from '../../src/views/LoginView.vue'
import { createAdminRouter } from '../../src/app/router'
import { currentUser } from '../../src/state/useAuth'
import { navigation } from '../../src/state/useNavigation'
import { settings } from '../../src/state/useSettings'
import { navigationFixture, settingsFixture } from '../fixtures/typed'
import { API, mockApi, profile, requestsTo, resetState, type Route } from '../helpers'
const loginOk = { body: { data: { token_type: 'cookie', expires_in: 900 }, meta: {} } }
const invalid = { status: 401, body: { error: { code: 'invalid_credentials', message: 'Invalid', details: {} } } }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`POST ${API}/auth/login`]: loginOk,
[`GET ${API}/auth/me`]: { body: profile },
[`GET ${API}/navigation`]: { body: navigationFixture },
[`GET ${API}/settings`]: { body: settingsFixture.list },
...overrides,
}
}
async function mountLogin(query: Record<string, string> = {}, overrides: Record<string, Route> = {}) {
const calls = mockApi(routes(overrides))
const router = createAdminRouter(createMemoryHistory())
await router.push({ path: '/login', query })
const wrapper = mount(LoginView, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return { wrapper, router, calls }
}
async function submit(wrapper: ReturnType<typeof mount>, login = ' dev ', password = 'secret') {
await wrapper.find('input[name="login"]').setValue(login)
await wrapper.find('input[name="password"]').setValue(password)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('login view', () => {
it('labels both inputs and uses the browser credential autocomplete', async () => {
const { wrapper } = await mountLogin()
expect(wrapper.find('h1').text()).toBe('Witaj ponownie')
const [loginLabel, passwordLabel] = wrapper.findAll('label')
expect(loginLabel!.text()).toBe('Login lub e-mail')
expect(loginLabel!.find('input').attributes('autocomplete')).toBe('username')
expect(passwordLabel!.text()).toBe('Hasło')
expect(passwordLabel!.find('input').attributes('type')).toBe('password')
expect(passwordLabel!.find('input').attributes('autocomplete')).toBe('current-password')
expect(wrapper.find('form').attributes('novalidate')).toBeDefined()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
it('signs in with the trimmed login, loads navigation and settings, clears the password and redirects', async () => {
const { wrapper, router, calls } = await mountLogin({ redirect: '/acme/demo/gadgets' })
await submit(wrapper)
const [post] = requestsTo(calls, 'POST', `${API}/auth/login`)
expect(await post!.json()).toEqual({ login: 'dev', password: 'secret' })
expect(currentUser.value?.login).toBe('dev')
expect(navigation.value).toHaveLength(3)
expect(settings.value).toHaveLength(3)
expect((wrapper.find('input[name="password"]').element as HTMLInputElement).value).toBe('')
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/gadgets')
})
it.each(['//evil.example/x', 'https://evil.example/'])('ignores the unsafe redirect %j', async (redirect) => {
const { wrapper, router } = await mountLogin({ redirect })
await submit(wrapper)
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
})
it('shows the alert, marks the password invalid and stays on login for bad credentials', async () => {
const { wrapper, router, calls } = await mountLogin({}, { [`POST ${API}/auth/login`]: invalid })
await submit(wrapper, 'dev', 'wrong')
expect(wrapper.find('[role="alert"]').text()).toBe('Nieprawidłowe dane logowania')
expect(wrapper.find('input[name="password"]').attributes('aria-invalid')).toBe('true')
expect(requestsTo(calls, 'GET', `${API}/auth/me`)).toHaveLength(0)
expect(router.currentRoute.value.name).toBe('login')
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined()
})
it('treats a session the profile call cannot confirm as a failed login', async () => {
const { wrapper, router } = await mountLogin({}, { [`GET ${API}/auth/me`]: invalid })
await submit(wrapper)
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(router.currentRoute.value.name).toBe('login')
})
it('still signs in when the settings list cannot be loaded', async () => {
const { wrapper, router } = await mountLogin({}, { [`GET ${API}/settings`]: invalid })
await submit(wrapper)
expect(settings.value).toEqual([])
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
})
it('disables the submit button while signing in and ignores a second submit', async () => {
let release: (() => void) | null = null
const { wrapper, calls } = await mountLogin(
{},
{
[`POST ${API}/auth/login`]: () =>
new Promise((resolve) => {
release = () => resolve(loginOk)
}),
},
)
await wrapper.find('input[name="login"]').setValue('dev')
await wrapper.find('input[name="password"]').setValue('secret')
await wrapper.find('form').trigger('submit')
await wrapper.find('form').trigger('submit')
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined()
release!()
await flushPromises()
expect(requestsTo(calls, 'POST', `${API}/auth/login`)).toHaveLength(1)
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" /> <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title> <title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BQShvn7O.js"></script> <script type="module" crossorigin src="./assets/index-X1ye7ftl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BBZKA2Yr.css"> <link rel="stylesheet" crossorigin href="./assets/index-BBZKA2Yr.css">
</head> </head>
<body> <body>