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

@@ -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')
})
})