feat(admin-ui): Dictionary Catalog v2 (URL filters + sort + density)
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Unit tests для catalog v2 pure helpers (`parseScopeFilter` + `sortDicts`).
|
||||
* Component-level interactions tested через RTL отдельным test file (followup).
|
||||
*
|
||||
* См. design_handoff_dictionary_catalog/review.md "Test plan" для полного
|
||||
* matrix'а сценариев.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DictionaryDefinition } from '@/api/client'
|
||||
import { parseScopeFilter, sortDicts, matchesQuery } from './dictionaries.index'
|
||||
|
||||
const mkDict = (overrides: Partial<DictionaryDefinition>): DictionaryDefinition => ({
|
||||
id: 'id-' + (overrides.name ?? 'x'),
|
||||
name: overrides.name ?? 'test',
|
||||
displayName: overrides.displayName,
|
||||
description: overrides.description,
|
||||
scope: overrides.scope ?? 'PUBLIC',
|
||||
schemaVersion: overrides.schemaVersion ?? '1.0.0',
|
||||
bundle: overrides.bundle ?? 'cuod',
|
||||
supportedLocales: overrides.supportedLocales ?? ['ru-RU'],
|
||||
defaultLocale: overrides.defaultLocale ?? 'ru-RU',
|
||||
redisProjectionEnabled: overrides.redisProjectionEnabled ?? false,
|
||||
approvalRequired: overrides.approvalRequired ?? false,
|
||||
approvalMinRole: overrides.approvalMinRole ?? null,
|
||||
recordCount: overrides.recordCount,
|
||||
createdAt: overrides.createdAt ?? '2026-01-01T00:00:00Z',
|
||||
updatedAt: overrides.updatedAt ?? '2026-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
describe('parseScopeFilter', () => {
|
||||
it('returns empty Set for undefined / empty input', () => {
|
||||
expect(parseScopeFilter(undefined).size).toBe(0)
|
||||
expect(parseScopeFilter('').size).toBe(0)
|
||||
})
|
||||
|
||||
it('parses single valid scope', () => {
|
||||
const out = parseScopeFilter('PUBLIC')
|
||||
expect(out.size).toBe(1)
|
||||
expect(out.has('PUBLIC')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses CSV with multiple scopes', () => {
|
||||
const out = parseScopeFilter('PUBLIC,INTERNAL,RESTRICTED')
|
||||
expect(out.size).toBe(3)
|
||||
})
|
||||
|
||||
it('case-insensitive — lowercase + mixed case normalized to UPPER', () => {
|
||||
const out = parseScopeFilter('public,Internal')
|
||||
expect(out.has('PUBLIC')).toBe(true)
|
||||
expect(out.has('INTERNAL')).toBe(true)
|
||||
})
|
||||
|
||||
it('skips invalid scopes (bookmark protection)', () => {
|
||||
const out = parseScopeFilter('FOO,PUBLIC,BAR')
|
||||
expect(out.size).toBe(1)
|
||||
expect(out.has('PUBLIC')).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates repeated scopes via Set', () => {
|
||||
const out = parseScopeFilter('PUBLIC,PUBLIC,public')
|
||||
expect(out.size).toBe(1)
|
||||
})
|
||||
|
||||
it('handles whitespace', () => {
|
||||
const out = parseScopeFilter(' PUBLIC , INTERNAL ')
|
||||
expect(out.size).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortDicts', () => {
|
||||
const a = mkDict({ name: 'alpha', displayName: 'Альфа', recordCount: 5, updatedAt: '2026-01-01T00:00:00Z' })
|
||||
const b = mkDict({ name: 'beta', displayName: 'Бета', recordCount: 100, updatedAt: '2026-03-01T00:00:00Z' })
|
||||
const c = mkDict({ name: 'gamma', displayName: 'Гамма', recordCount: 0, updatedAt: '2026-02-01T00:00:00Z' })
|
||||
|
||||
it('sort=name — alphabetical (RU localeCompare)', () => {
|
||||
const out = sortDicts([c, a, b], 'name')
|
||||
expect(out.map((d) => d.name)).toEqual(['alpha', 'beta', 'gamma'])
|
||||
})
|
||||
|
||||
it('sort=name — uses displayName if present, fallback name', () => {
|
||||
const noDisplay = mkDict({ name: 'zebra' }) // displayName=undefined → name used
|
||||
const out = sortDicts([noDisplay, a, b], 'name')
|
||||
// Default Intl collation сортирует latin до cyrillic — `zebra` идёт перед
|
||||
// `Альфа`/`Бета`. Эта зависимость от Node-version'а / Intl backend'а.
|
||||
// Проверяем стабильность сорт'а — порядок detereministic, не конкретный.
|
||||
const sortedNames = out.map((d) => d.name)
|
||||
expect(sortedNames).toContain('zebra')
|
||||
expect(sortedNames).toContain('alpha')
|
||||
expect(sortedNames).toContain('beta')
|
||||
// displayName присутствует на a/b — sorted relatively к друг другу
|
||||
const idxAlpha = sortedNames.indexOf('alpha')
|
||||
const idxBeta = sortedNames.indexOf('beta')
|
||||
expect(idxAlpha).toBeLessThan(idxBeta) // Альфа < Бета (RU collation)
|
||||
})
|
||||
|
||||
it('sort=recordCount — descending, missing = 0', () => {
|
||||
const noCount = mkDict({ name: 'omega', recordCount: undefined })
|
||||
const out = sortDicts([a, noCount, b, c], 'recordCount')
|
||||
expect(out.map((d) => d.recordCount ?? 0)).toEqual([100, 5, 0, 0])
|
||||
})
|
||||
|
||||
it('sort=updated — descending по ISO timestamp', () => {
|
||||
const out = sortDicts([a, b, c], 'updated')
|
||||
expect(out.map((d) => d.updatedAt)).toEqual([
|
||||
'2026-03-01T00:00:00Z',
|
||||
'2026-02-01T00:00:00Z',
|
||||
'2026-01-01T00:00:00Z',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not mutate input array', () => {
|
||||
const input = [c, a, b]
|
||||
sortDicts(input, 'name')
|
||||
expect(input.map((d) => d.name)).toEqual(['gamma', 'alpha', 'beta'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesQuery', () => {
|
||||
const dict = mkDict({
|
||||
name: 'spacecraft',
|
||||
displayName: 'Космические аппараты',
|
||||
description: 'Реестр спутников',
|
||||
})
|
||||
|
||||
it('empty query matches anything', () => {
|
||||
expect(matchesQuery(dict, '')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches name substring', () => {
|
||||
expect(matchesQuery(dict, 'space')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches displayName (RU)', () => {
|
||||
expect(matchesQuery(dict, 'космич')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches description', () => {
|
||||
expect(matchesQuery(dict, 'спутник')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false on miss', () => {
|
||||
expect(matchesQuery(dict, 'абракадабра')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user