V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isKbxProblem, kbxApiCatalog } from '@kbx/contracts'
|
||||
|
||||
describe('KBX API contract', () => {
|
||||
it('keeps operation IDs and routes unique', () => {
|
||||
const operations = Object.values(kbxApiCatalog)
|
||||
expect(new Set(operations.map(x => x.id)).size).toBe(operations.length)
|
||||
expect(new Set(operations.map(x => `${x.method} ${x.path}`)).size).toBe(operations.length)
|
||||
})
|
||||
|
||||
it('requires a stable key only for mutation operations', () => {
|
||||
const required = Object.values(kbxApiCatalog).filter(x => x.idempotency === 'required')
|
||||
expect(required.length).toBeGreaterThan(0)
|
||||
expect(required.every(x => x.method !== 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes the public problem discriminator family', () => {
|
||||
for (const type of ['validation','business-rule','conflict','permission','not-found','integration','system']) {
|
||||
expect(isKbxProblem({ type, title: 'test' })).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isKbxBarcodeDuplicate, normalizeKbxBarcode } from '@kbx/ui'
|
||||
|
||||
describe('KbxBarcodeCapture contract', () => {
|
||||
it('normalizes scanner text but never invents another barcode', () => {
|
||||
expect(normalizeKbxBarcode(' 8801234567890 ', 'keyboard-wedge', 3, 256, 1)).toEqual({
|
||||
rawValue: ' 8801234567890 ',
|
||||
normalizedValue: '8801234567890',
|
||||
source: 'keyboard-wedge',
|
||||
occurredAt: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects implausibly short and oversized scanner buffers', () => {
|
||||
expect(normalizeKbxBarcode('A', 'keyboard-wedge', 3, 256, 1)).toBeNull()
|
||||
expect(normalizeKbxBarcode('123456', 'keyboard-wedge', 3, 5, 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores only the same barcode inside the short device debounce window', () => {
|
||||
const last=normalizeKbxBarcode('ABC001','keyboard-wedge',3,256,1000)!
|
||||
const duplicate=normalizeKbxBarcode('ABC001','keyboard-wedge',3,256,1100)!
|
||||
const legitimateLaterScan=normalizeKbxBarcode('ABC001','keyboard-wedge',3,256,1400)!
|
||||
expect(isKbxBarcodeDuplicate(duplicate,last,180)).toBe(true)
|
||||
expect(isKbxBarcodeDuplicate(legitimateLaterScan,last,180)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxComponentCatalog, kbxComponentManifest } from '@kbx/ui'
|
||||
|
||||
const requiredCore=[
|
||||
'KbxButton','KbxInput','KbxNumberField','KbxMoneyField','KbxQuantityField','KbxDateField','KbxDateRange','KbxSelect','KbxLookup','KbxCheckbox','KbxRadio','KbxTextarea','KbxTabs','KbxBadge','KbxTooltip','KbxBarcodeField',
|
||||
'KbxPageHeader','KbxCommandBar','KbxSearchPanel','KbxFormSection','KbxSectionHeader','KbxDataGrid','KbxBulkActionBar','KbxQuickFilterBar','KbxSummaryBar','KbxStatus',
|
||||
'KbxDialog','KbxDrawer','KbxToast','KbxConfirm','KbxExcelMenu','KbxExcelImport','KbxJobProgress','KbxAuditTrail','KbxHelpPanel','KbxAiPanel','KbxProposalPanel',
|
||||
'KbxListPage','KbxMasterPage','KbxTransactionPage','KbxMasterDetailPage','KbxQueuePage','KbxReconcilePage','KbxWmsMobilePage',
|
||||
]
|
||||
|
||||
describe('KBX source-standard core component contract',()=>{
|
||||
it('publishes every v1 core component',()=>{
|
||||
const names=new Set(kbxComponentManifest.map(x=>x.name))
|
||||
for(const name of requiredCore) expect(names.has(name),name).toBe(true)
|
||||
})
|
||||
it('catalogs the highest-risk interactive components',()=>{
|
||||
const catalogued=new Set(kbxComponentCatalog.map(x=>x.component))
|
||||
for(const name of ['KbxInput','KbxLookup','KbxDataGrid','KbxCommandBar','KbxExcelImport','KbxWmsMobilePage','KbxApplicationShell']) expect(catalogued.has(name),name).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxExperiments, kbxFeatureFlags } from '../../packages/kbx-contracts/src/generated/experimentCatalog'
|
||||
describe('KBX experiment contract',()=>{
|
||||
it('ships reference experiment disabled by default',()=>{const exp=kbxExperiments['exp.oms.order-list.exception-summary-v2'];expect(exp.state).toBe('draft');expect(exp.rolloutPercent).toBe(0);expect(kbxFeatureFlags[exp.flagId].killSwitch).toBe(true)})
|
||||
it('keeps variants balanced and has control',()=>{const variants=kbxExperiments['exp.oms.order-list.exception-summary-v2'].variants;expect(variants.reduce((n,x)=>n+x.weight,0)).toBe(100);expect(variants.some(x=>x.key==='control')).toBe(true)})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getKbxField, kbxImportField, kbxFieldCatalog } from '@kbx/contracts'
|
||||
|
||||
describe('KBX canonical field dictionary', () => {
|
||||
it('keeps sensitive fields maskable', () => {
|
||||
const sensitive = Object.values(kbxFieldCatalog).filter(field => field.sensitive)
|
||||
expect(sensitive.length).toBeGreaterThan(0)
|
||||
expect(sensitive.every(field => 'masking' in field && Boolean(field.masking))).toBe(true)
|
||||
})
|
||||
|
||||
it('uses orderQty as the OMS order quantity field', () => {
|
||||
expect(getKbxField('orderQty').dataType).toBe('quantity')
|
||||
expect(kbxImportField('orderQty').required).toBe(true)
|
||||
})
|
||||
|
||||
it('does not make server/domain rules part of metadata', () => {
|
||||
const field = getKbxField('orderQty') as Record<string, unknown>
|
||||
expect(field.allowedStatuses).toBeUndefined()
|
||||
expect(field.stockPolicy).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxGridColumn, KbxSelectionState } from '@kbx/contracts'
|
||||
import { normalizeKbxGridClipboardData, toKbxBulkSelectionRequest } from '@kbx/ui'
|
||||
|
||||
type Row={code:string;qty:number;price:number;date:string;active:boolean}
|
||||
const columns:KbxGridColumn<Row>[]=[
|
||||
{field:'code',header:'코드',type:'code'},
|
||||
{field:'qty',header:'수량',type:'quantity'},
|
||||
{field:'price',header:'단가',type:'money'},
|
||||
{field:'date',header:'일자',type:'date'},
|
||||
{field:'active',header:'사용',type:'boolean'},
|
||||
]
|
||||
|
||||
describe('KBX grid productivity contract',()=>{
|
||||
it('normalizes Excel-like pasted values before cell validation',()=>{
|
||||
const {data,result}=normalizeKbxGridClipboardData([[' ABC001 ','1,200','3,500','20260808','Y']],columns,0)
|
||||
expect(data[0]).toEqual(['ABC001',1200,3500,'2026-08-08',true])
|
||||
expect(result.cellCount).toBe(5)
|
||||
expect(result.rejectedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps invalid pasted values visible for inline validation',()=>{
|
||||
const {data,result}=normalizeKbxGridClipboardData([['ABC001','not-number']],columns,0)
|
||||
expect(data[0][1]).toBe('not-number')
|
||||
expect(result.issues[0]).toMatchObject({field:'qty',code:'INVALID_NUMBER'})
|
||||
})
|
||||
|
||||
it('does not expand all-filtered selection into browser ids',()=>{
|
||||
const state:KbxSelectionState<string>={mode:'all-filtered',selectedIds:[],excludedIds:['ORDER-9']}
|
||||
expect(toKbxBulkSelectionRequest(state,{status:'READY'})).toEqual({mode:'filter',filter:{status:'READY'},excludedIds:['ORDER-9']})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildKbxHomeAttention, buildKbxHomeAttentionQueue } from '@kbx/ui'
|
||||
|
||||
describe('KBX Home attention queue',()=>{
|
||||
const entries=[{screenId:'OMS-ORD-001',path:'/oms/orders',section:'주문',title:'주문관리',module:'OMS' as const}]
|
||||
|
||||
it('prioritizes dirty, failed work, urgent notifications, running work, then ordinary notifications',()=>{
|
||||
const tabs=[{key:'tab-1',screenId:'OMS-ORD-001',title:'주문관리',path:'/oms/orders',dirty:true,openedAt:'2026-08-08T00:00:00Z',lastActivatedAt:'2026-08-08T00:05:00Z'}]
|
||||
const operations=[{id:'op-f',type:'import',title:'주문 반영',status:'failed' as const,requestedAt:'2026-08-08T00:00:00Z'},{id:'op-r',type:'export',title:'주문 내보내기',status:'running' as const,requestedAt:'2026-08-08T00:00:00Z'}]
|
||||
const notifications=[{id:'n-w',severity:'warning' as const,title:'확인 필요',createdAt:'2026-08-08T00:00:00Z'},{id:'n-i',severity:'info' as const,title:'완료 안내',createdAt:'2026-08-08T00:00:00Z'}]
|
||||
expect(buildKbxHomeAttention(entries,tabs,operations,notifications).map(item=>item.source)).toEqual(['dirty','operation-failed','notification-urgent','operation-running','notification'])
|
||||
})
|
||||
|
||||
it('sorts newest-first within priority and reports overflow without hiding the real total',()=>{
|
||||
const operations=[
|
||||
{id:'old',type:'import',title:'이전 실패',status:'failed' as const,requestedAt:'2026-08-08T00:00:00Z',completedAt:'2026-08-08T00:10:00Z'},
|
||||
{id:'new',type:'import',title:'최근 실패',status:'failed' as const,requestedAt:'2026-08-08T00:20:00Z',completedAt:'2026-08-08T00:30:00Z'},
|
||||
]
|
||||
const queue=buildKbxHomeAttentionQueue(entries,[],operations,[],1)
|
||||
expect(queue.items.map(item=>item.title)).toEqual(['최근 실패'])
|
||||
expect(queue.totalCount).toBe(2)
|
||||
expect(queue.overflowCount).toBe(1)
|
||||
expect(queue.sourceCounts['operation-failed']).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxImportDefinition, KbxImportMapping } from '@kbx/contracts'
|
||||
import { validateKbxImportFileCandidate, validateKbxImportMappings } from '@kbx/ui'
|
||||
|
||||
const definition:KbxImportDefinition={
|
||||
id:'orders',screenId:'OMS-ORD-003',entity:'order',title:'주문 Excel 업로드',allowCreate:true,allowUpdate:true,maxFileSizeBytes:1048576,
|
||||
fields:[
|
||||
{key:'orderNo',label:'주문번호',dataType:'text',required:true,importable:true},
|
||||
{key:'itemCode',label:'품목코드',dataType:'code',required:true,importable:true},
|
||||
{key:'remark',label:'비고',dataType:'text',importable:true},
|
||||
],
|
||||
}
|
||||
|
||||
describe('KbxExcelImport guard contract',()=>{
|
||||
it('rejects missing required targets and duplicate target mappings before server validation',()=>{
|
||||
const mappings:KbxImportMapping[]=[
|
||||
{sourceColumn:'주문번호',targetField:'orderNo',source:'exact'},
|
||||
{sourceColumn:'상품번호',targetField:'orderNo',source:'manual'},
|
||||
]
|
||||
expect(validateKbxImportMappings(definition,mappings).map(x=>x.code)).toEqual(['duplicate-target','required-missing'])
|
||||
})
|
||||
|
||||
it('accepts one-to-one required mappings',()=>{
|
||||
const mappings:KbxImportMapping[]=[
|
||||
{sourceColumn:'주문번호',targetField:'orderNo',source:'exact'},
|
||||
{sourceColumn:'상품번호',targetField:'itemCode',source:'alias'},
|
||||
]
|
||||
expect(validateKbxImportMappings(definition,mappings)).toEqual([])
|
||||
})
|
||||
|
||||
it('preflights xlsx extension and configured upload size without parsing business rows',()=>{
|
||||
expect(validateKbxImportFileCandidate(definition,{name:'orders.csv',size:100})).toContain('.xlsx')
|
||||
expect(validateKbxImportFileCandidate(definition,{name:'orders.xlsx',size:2097152})).toContain('1MB')
|
||||
expect(validateKbxImportFileCandidate(definition,{name:'orders.xlsx',size:512})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxLookupProvider } from '@kbx/contracts'
|
||||
|
||||
const provider: KbxLookupProvider<string> = {
|
||||
async search(request) {
|
||||
return { items: [{ id: '1', code: '10001', displayName: '대한상사' }], totalCount: 1 }
|
||||
},
|
||||
async resolveById(id) {
|
||||
return id === '1' ? { id: '1', code: '10001', displayName: '대한상사' } : null
|
||||
},
|
||||
async resolveByCode(code) {
|
||||
return code === '10001' ? { id: '1', code: '10001', displayName: '대한상사' } : null
|
||||
},
|
||||
}
|
||||
|
||||
describe('KbxLookup provider contract', () => {
|
||||
it('never invents an entity: unknown code resolves null', async () => {
|
||||
expect(await provider.resolveByCode('UNKNOWN')).toBeNull()
|
||||
})
|
||||
|
||||
it('search returns stable domain id + code + displayName', async () => {
|
||||
const result = await provider.search({ query: '대한', page: 1, pageSize: 30 })
|
||||
expect(result.items[0]).toEqual(expect.objectContaining({ id: '1', code: '10001', displayName: '대한상사' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
|
||||
import { buildKbxHomeQuickStart, buildKbxHomeWorkbench, matchKbxRoutePattern, resolveKbxSafeRecentPath, resolveKbxSafeWorkspacePath } from '@kbx/ui'
|
||||
|
||||
const entry=(screenId:string,path:string,homePriority?:number):KbxNavigationResolvedEntry=>({screenId,path,title:screenId,module:'ERP',section:'업무',permissions:[],menuVisible:true,favoriteAllowed:true,recentPolicy:'screen',homePriority})
|
||||
|
||||
describe('KBX navigation hardening',()=>{
|
||||
it('deduplicates home quick start in favorite, tab, recent, priority order',()=>{
|
||||
const a=entry('A','/a',30),b=entry('B','/b',10),c=entry('C','/c',20)
|
||||
const result=buildKbxHomeQuickStart([a,b,c],[a],[{screenId:'B',path:'/b',title:'B',visitedAt:'2026-08-08T10:00:00Z'}],[{key:'C:/c',screenId:'C',title:'C',path:'/c',openedAt:'2026-08-08T09:00:00Z',lastActivatedAt:'2026-08-08T11:00:00Z'}])
|
||||
expect(result.map(x=>x.screenId)).toEqual(['A','C','B'])
|
||||
})
|
||||
|
||||
it('never trusts a persisted route unless route mode and path boundary are both valid',()=>{
|
||||
const base={...entry('A','/erp/items'),recentPolicy:'route' as const}
|
||||
expect(resolveKbxSafeRecentPath(base,'/erp/items/123?tab=stock')).toBe('/erp/items/123')
|
||||
expect(resolveKbxSafeRecentPath(base,'/erp/items-evil/123')).toBe('/erp/items')
|
||||
expect(resolveKbxSafeRecentPath(base,'https://evil.example/x')).toBe('/erp/items')
|
||||
expect(resolveKbxSafeRecentPath({...base,recentPolicy:'screen'},'/erp/items/123')).toBe('/erp/items')
|
||||
})
|
||||
|
||||
it('keeps dirty/open work ahead of favorites and avoids duplicate home launch rows',()=>{
|
||||
const a=entry('A','/a',30),b=entry('B','/b',10)
|
||||
const result=buildKbxHomeWorkbench([a,b],[a],[],[
|
||||
{key:'A:/a',screenId:'A',title:'A',path:'/a',openedAt:'2026-08-08T09:00:00Z',lastActivatedAt:'2026-08-08T11:00:00Z',dirty:true},
|
||||
])
|
||||
expect(result.filter(x=>x.screenId==='A')).toHaveLength(1)
|
||||
expect(result[0]?.source).toBe('dirty')
|
||||
})
|
||||
|
||||
it('validates workspace deep links against the registered route capability',()=>{
|
||||
expect(matchKbxRoutePattern('/oms/orders/:orderId/edit','/oms/orders/123/edit')).toBe(true)
|
||||
expect(matchKbxRoutePattern('/oms/orders/:orderId/edit','/oms/orders/123/delete')).toBe(false)
|
||||
expect(resolveKbxSafeWorkspacePath(['/oms/orders/:orderId/edit'],'/oms/orders/123/edit','/home')).toBe('/oms/orders/123/edit')
|
||||
expect(resolveKbxSafeWorkspacePath(['/oms/orders/:orderId/edit'],'https://evil.example/oms/orders/123/edit','/home')).toBe('/home')
|
||||
})
|
||||
|
||||
it('rejects oversized and control-character workspace paths',()=>{
|
||||
const fallback='/home'
|
||||
expect(resolveKbxSafeWorkspacePath(['/erp/items/:itemId'],`/erp/items/${'a'.repeat(2100)}`,fallback)).toBe(fallback)
|
||||
expect(resolveKbxSafeWorkspacePath(['/erp/items/:itemId'],'/erp/items/abc\n123',fallback)).toBe(fallback)
|
||||
expect(resolveKbxSafeWorkspacePath(['/erp/items/:itemId'],'/erp/items/%2e%2e',fallback)).toBe(fallback)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxWorkItem } from '@kbx/contracts'
|
||||
|
||||
function nextActions(item: KbxWorkItem) {
|
||||
return new Set((item.actions ?? []).map(x => x.kind))
|
||||
}
|
||||
|
||||
describe('KBX operations contract', () => {
|
||||
it('domain exception can navigate without offering unsafe manual resolution', () => {
|
||||
const item: KbxWorkItem = {
|
||||
id: '1', sourceModule: 'WMS', sourceType: 'picking-task', sourceId: 'uuid-p100', referenceNo: 'P-100', sourceScreenId: 'WMS-PICK-001', code: 'PICKING_SHORTAGE',
|
||||
title: '피킹 수량 부족', severity: 'warning', status: 'open', occurredAt: new Date().toISOString(), ageMinutes: 3, version: 1,
|
||||
actions: [{ id: 'navigate', label: '원 업무 보기', kind: 'navigate' }],
|
||||
}
|
||||
expect(nextActions(item).has('navigate')).toBe(true)
|
||||
expect(nextActions(item).has('resolve')).toBe(false)
|
||||
})
|
||||
|
||||
it('retry is explicit rather than inferred from the error text', () => {
|
||||
const item: KbxWorkItem = {
|
||||
id: '2', sourceModule: 'OMS', sourceType: 'shipment', sourceId: 'uuid-s1', referenceNo: 'S-1', sourceScreenId: 'OMS-ORD-001', code: 'LABEL_FAILED',
|
||||
title: '송장 발급 실패', severity: 'warning', status: 'claimed', occurredAt: new Date().toISOString(), ageMinutes: 1, version: 2,
|
||||
actions: [{ id: 'retry', label: '재처리', kind: 'retry' }],
|
||||
}
|
||||
expect(nextActions(item)).toEqual(new Set(['retry']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe,it,expect } from 'vitest'
|
||||
import { kbxFieldReadonly, resolveKbxRecordStatePolicy, type KbxRecordStatePolicy } from '@kbx/contracts'
|
||||
const policies:KbxRecordStatePolicy[]=[{status:'작성',editability:'editable'},{status:'확정',editability:'readonly'},{status:'부분수정',editability:'restricted',editableFields:['remark']}]
|
||||
describe('record lifecycle policy',()=>{
|
||||
it('makes confirmed records read-only',()=>expect(kbxFieldReadonly(resolveKbxRecordStatePolicy(policies,'확정'),'receiverName')).toBe(true))
|
||||
it('allows only explicitly editable fields in restricted state',()=>{const p=resolveKbxRecordStatePolicy(policies,'부분수정');expect(kbxFieldReadonly(p,'remark')).toBe(false);expect(kbxFieldReadonly(p,'quantity')).toBe(true)})
|
||||
it('fails safe for unknown states',()=>expect(resolveKbxRecordStatePolicy(policies,'UNKNOWN').editability).toBe('readonly'))
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxConflictSnapshot, KbxOperationRun, KbxRuntimeNotice } from '@kbx/contracts'
|
||||
|
||||
describe('KBX runtime contract', () => {
|
||||
it('distinguishes degraded mode from normal business state', () => {
|
||||
const notice: KbxRuntimeNotice = { mode: 'degraded', title: '택배 연계 지연', retryAllowed: true }
|
||||
expect(notice.mode).toBe('degraded')
|
||||
})
|
||||
|
||||
it('keeps concurrency conflict explicit instead of silent overwrite', () => {
|
||||
const conflict: KbxConflictSnapshot = {
|
||||
code: 'ORDER_VERSION_CONFLICT', title: '다른 사용자가 변경했습니다.', requestedVersion: 3, currentVersion: 4,
|
||||
changes: [{ field: 'quantity', label: '수량', mine: 10, latest: 8 }],
|
||||
}
|
||||
expect(conflict.currentVersion).toBeGreaterThan(conflict.requestedVersion!)
|
||||
})
|
||||
|
||||
it('can represent partial completion without reporting full success', () => {
|
||||
const run: KbxOperationRun = {
|
||||
id: 'op-1', type: 'bulk-ship', title: '대량 출고지시', status: 'partially-completed', requestedAt: new Date().toISOString(),
|
||||
total: 100, processed: 100, succeeded: 97, failed: 3,
|
||||
}
|
||||
expect(run.status).toBe('partially-completed')
|
||||
expect(run.failed).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxScreenRecipeCatalog, kbxScreenRecipeVerificationCatalog } from '@kbx/contracts'
|
||||
import { kbxTemplateManifest } from '@kbx/ui'
|
||||
|
||||
describe('KBX screen recipes',()=>{
|
||||
it('closes every T01-T09 template to an implementation and verification recipe',()=>{
|
||||
expect(Object.keys(kbxScreenRecipeCatalog)).toHaveLength(9)
|
||||
for(const template of kbxTemplateManifest){
|
||||
const recipe=kbxScreenRecipeCatalog[template.code]
|
||||
const verification=kbxScreenRecipeVerificationCatalog[template.code]
|
||||
expect(recipe.type).toBe(template.type)
|
||||
expect(recipe.templateComponent).toBe(template.component)
|
||||
expect(recipe.requiredPolicies.length).toBeGreaterThan(0)
|
||||
expect(recipe.recoveryPolicies.length).toBeGreaterThan(0)
|
||||
expect(recipe.securityPolicies.length).toBeGreaterThan(0)
|
||||
expect(recipe.canonicalScenarioIds.length).toBeGreaterThan(0)
|
||||
expect(recipe.testProfile.requiredChecks.length).toBeGreaterThan(0)
|
||||
expect(recipe.testProfile.requiredScenarioKinds).toContain('e2e')
|
||||
expect(verification.complete).toBe(true)
|
||||
expect(verification.missingScenarioKinds).toEqual([])
|
||||
expect(verification.missingTags).toEqual([])
|
||||
expect(verification.missingEvidence).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getKbxShortcutDebugSnapshot, registerKbxShortcuts } from '@kbx/ui'
|
||||
|
||||
describe('KBX shortcut manager contract',()=>{
|
||||
it('rejects protected browser shortcuts',()=>{
|
||||
for(const key of ['F5','Ctrl+L','Ctrl+T','Ctrl+W','Ctrl+R']){
|
||||
expect(()=>registerKbxShortcuts([{key,scope:'application',execute(){}}])).toThrow(/protected/)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers semantic scope without page-level window ownership',()=>{
|
||||
const unregister=registerKbxShortcuts([{key:'F8',scope:'page',priority:10,execute(){}}])
|
||||
expect(getKbxShortcutDebugSnapshot()).toEqual(expect.arrayContaining([expect.objectContaining({key:'F8',scope:'page',priority:10})]))
|
||||
unregister()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe,expect,it } from 'vitest'
|
||||
import { kbxTelemetryCatalog,kbxUxMetricCatalog } from '@kbx/contracts'
|
||||
|
||||
describe('KBX telemetry contract',()=>{
|
||||
it('contains Manual Intervention Rate',()=>expect(kbxUxMetricCatalog.manual_intervention_rate).toBeTruthy())
|
||||
it('never allows raw identity attributes',()=>{for(const event of Object.values(kbxTelemetryCatalog)){expect(event.allowedAttributes.join('|')).not.toMatch(/phone|address|orderNo|customer|barcode|keyword|query|entityId/i)}})
|
||||
it('requires duration for completion metrics',()=>expect(kbxTelemetryCatalog['task.complete'].requiresDuration).toBe(true))
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxTemplateManifest } from '@kbx/ui'
|
||||
|
||||
describe('KBX template state matrix',()=>{
|
||||
it('requires explicit idle/runtime/permission states where applicable',()=>{
|
||||
const byCode=new Map(kbxTemplateManifest.map(item=>[item.code,item]))
|
||||
expect(byCode.get('T01')?.runtimeStates).toContain('idle')
|
||||
for(const template of kbxTemplateManifest)expect(template.stateCapabilities).toContain('permission')
|
||||
expect(byCode.get('T02')?.stateCapabilities).toEqual(expect.arrayContaining(['dirty','conflict']))
|
||||
expect(byCode.get('T03')?.stateCapabilities).toEqual(expect.arrayContaining(['dirty','conflict','validation']))
|
||||
expect(byCode.get('T04')?.stateCapabilities).toContain('validation')
|
||||
expect(byCode.get('T08')?.stateCapabilities).toContain('job')
|
||||
expect(byCode.get('T09')?.stateCapabilities).toContain('network')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
kbxTestScenarioCatalog,
|
||||
kbxTestFixtureSetCatalog,
|
||||
kbxTestFixtureRefs,
|
||||
kbxTestReferenceClock,
|
||||
} from '@kbx/contracts'
|
||||
|
||||
describe('KBX canonical test scenario contract', () => {
|
||||
it('covers the three golden screens', () => {
|
||||
const screens = new Set(Object.values(kbxTestScenarioCatalog).map(x => x.screenId))
|
||||
expect(screens.has('OMS-ORD-001')).toBe(true)
|
||||
expect(screens.has('OMS-ORD-002')).toBe(true)
|
||||
expect(screens.has('WMS-PICK-001')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps deterministic synthetic references', () => {
|
||||
expect(kbxTestReferenceClock).toBe('2026-08-08T09:00:00Z')
|
||||
expect(Object.keys(kbxTestFixtureSetCatalog).length).toBeGreaterThan(0)
|
||||
expect(kbxTestFixtureRefs['order.ship.ready.1'].orderNo).toMatch(/^TEST-/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
import { describe,it,expect } from 'vitest'
|
||||
import { allowedKbxTransitions, type KbxWorkflowDefinition } from '@kbx/contracts'
|
||||
const wf:KbxWorkflowDefinition={id:'x',version:'1',states:[{value:'DRAFT',label:'작성',semantic:'draft'},{value:'DONE',label:'완료',semantic:'completed'}],transitions:[{id:'done',from:['DRAFT'],to:'DONE',label:'완료'}]}
|
||||
describe('workflow contract',()=>{it('returns only transitions allowed from current state',()=>expect(allowedKbxTransitions(wf,'DRAFT').map(x=>x.id)).toEqual(['done']));it('returns none from terminal-like state',()=>expect(allowedKbxTransitions(wf,'DONE')).toHaveLength(0))})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { searchNavigationEntries } from '../../packages/kbx-ui/src/shell/navigationSearch'
|
||||
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
|
||||
const entries:KbxNavigationResolvedEntry[]=[
|
||||
{screenId:'OMS-ORD-001',title:'주문관리',module:'OMS',section:'주문',path:'/oms/orders',keywords:['출고대기','주문조회']},
|
||||
{screenId:'ERP-INV-001',title:'재고현황',module:'ERP',section:'재고',path:'/erp/inventory',keywords:['현재고','가용재고']},
|
||||
]
|
||||
describe('navigation search',()=>{
|
||||
it('finds by Korean business keyword',()=>expect(searchNavigationEntries(entries,'출고')[0]?.screenId).toBe('OMS-ORD-001'))
|
||||
it('finds by screen id',()=>expect(searchNavigationEntries(entries,'ERP-INV-001')[0]?.screenId).toBe('ERP-INV-001'))
|
||||
it('finds familiar menu title without command syntax',()=>expect(searchNavigationEntries(entries,'주문관리')[0]?.title).toBe('주문관리'))
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxWmsScanCommand } from '@kbx/contracts'
|
||||
|
||||
function replayOrder(commands: KbxWmsScanCommand[]) {
|
||||
// Scanner commands are sequence-sensitive and must never be parallelized.
|
||||
return [...commands].sort((a, b) => a.occurredAt.localeCompare(b.occurredAt))
|
||||
}
|
||||
|
||||
describe('WMS scanner retry contract', () => {
|
||||
it('preserves the same idempotency key across transport retry', () => {
|
||||
const command: KbxWmsScanCommand = {
|
||||
taskId: 'task-1',
|
||||
barcode: 'LOC-A-01',
|
||||
source: 'keyboard-wedge',
|
||||
idempotencyKey: 'task-1:abc',
|
||||
expectedVersion: 7,
|
||||
occurredAt: '2026-08-08T07:00:00Z',
|
||||
}
|
||||
const retried = { ...command }
|
||||
expect(retried.idempotencyKey).toBe(command.idempotencyKey)
|
||||
})
|
||||
|
||||
it('replays sequential scans in occurrence order', () => {
|
||||
const make = (key: string, occurredAt: string): KbxWmsScanCommand => ({
|
||||
taskId: 'task-1', barcode: key, source: 'keyboard-wedge', idempotencyKey: key,
|
||||
expectedVersion: 7, occurredAt,
|
||||
})
|
||||
expect(replayOrder([
|
||||
make('B', '2026-08-08T07:00:02Z'),
|
||||
make('A', '2026-08-08T07:00:01Z'),
|
||||
]).map(x => x.idempotencyKey)).toEqual(['A', 'B'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user