/** * 沙箱看板的取数与作用域:声明怎么求值、行集怎么摊平 —— 零 IO,读表用一张内存表替。 */ import { describe, expect, it } from 'vitest'; import type { ViewData } from '@contracts'; import { caseStamp, openedAtText } from '../case-stamp.ts'; import { resolveViewData, viewScopeOf, slugOf, localMinute } from '../activity-ops/view-scope.ts'; import type { ViewRead, ViewRow } from '../activity-ops/view-scope.ts'; const SPEC: ViewData = { case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' }, rows: [ { as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } }, { as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }] }, { as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } }, { as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } }, { as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: 'sub_eval.verdict.request_id' } }, { as: 'validation.history', ledger: 'validation.record', by: { column: 'validation_internal_pn', from: 'sub_eval.pn_check.internal_pn' }, not: { column: 'request_id', from: 'sub_eval.verdict.request_id' }, }, ], }; const TABLES: Record = { 'sub_eval.verdict': [ { eval_ref: 'W05-20260827215103', request_id: 'REQ-20260827215103', original_internal_pn: 'LM2596S-5.0' }, { eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000', original_internal_pn: 'X' }, ], 'sub_eval.pn_check': [ { eval_ref: 'W05-20260827215103', internal_pn: 'XL2596S-5.0', role: 'candidate', exists_status: 'created' }, { eval_ref: 'W05-20260827215103', internal_pn: 'LM2596S-5.0', role: 'original', exists_status: 'found' }, { eval_ref: 'W05-20260827000000', internal_pn: 'X', role: 'original', exists_status: 'found' }, ], 'parts.profile': [ { internal_pn: 'LM2596S-5.0', brand_name: 'TI', lifecycle_status: 'nrnd' }, { internal_pn: 'XL2596S-5.0', brand_name: 'XLSEMI', lifecycle_status: 'active' }, { internal_pn: 'X', brand_name: '?', lifecycle_status: 'active' }, ], 'substitution.relation': [ { substitution_relation_id: 'REL-1', eval_ref: 'W05-20260827215103', recommendation_level: 'conditional' }, { substitution_relation_id: 'REL-9', eval_ref: 'W05-20260827000000', recommendation_level: 'alternate' }, ], 'validation.record': [ { validation_id: 'VAL-A', request_id: 'REQ-20260827215103', validation_internal_pn: 'XL2596S-5.0' }, { validation_id: 'VAL-OLD', request_id: 'REQ-20260101000000', validation_internal_pn: 'XL2596S-5.0' }, { validation_id: 'VAL-OTHER', request_id: 'REQ-20260101000001', validation_internal_pn: 'Z' }, ], }; /** 内存表:等值过滤 + 单列排序;记下每一次读 */ function reader(tables: Record, broken: ReadonlySet = new Set()): { read: ViewRead; calls: string[] } { const calls: string[] = []; const read: ViewRead = async (address, where, orderBy) => { const name = `${address.domain}.${address.object}`; calls.push(`${name} ${JSON.stringify(where)}`); if (broken.has(name)) return undefined; const rows = (tables[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => String(r[k]) === v)); const order = orderBy[0]; if (order === undefined) return rows; return [...rows].sort((a, b) => String(a[order.field]).localeCompare(String(b[order.field])) * (order.dir === 'asc' ? 1 : -1)); }; return { read, calls }; } const DECLARED = [ { domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] }, { domain: 'substitution', object: 'relation', fields: [{ name: 'recommendation_level', tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }] }, ]; describe('caseStamp / openedAtText', () => { it('同一串:文本带分隔符、案号只留 14 位数字;认不出的时刻两者都是空串', () => { const iso = new Date(2026, 7, 27, 21, 51, 3).toISOString(); expect(openedAtText(iso)).toBe('2026-08-27 21:51:03'); expect(caseStamp(iso)).toBe('20260827215103'); expect(openedAtText('not-a-date')).toBe(''); expect(caseStamp('')).toBe(''); }); }); describe('resolveViewData', () => { it('按序链式求值:案号 → 核对表 → 料号圈主数据;行序随 from 值的顺序(原 PN 排第一)', async () => { const { read, calls } = reader(TABLES); const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read }); expect(out.caseFound).toBe(true); expect(out.skipped).toEqual([]); expect(out.sets['sub_eval.verdict']).toHaveLength(1); expect(out.sets['sub_eval.pn_check']?.map((r) => r['internal_pn'])).toEqual(['LM2596S-5.0', 'XL2596S-5.0']); expect(out.sets['parts.profile']?.map((r) => r['brand_name'])).toEqual(['TI', 'XLSEMI']); expect(out.sets['substitution.relation']?.map((r) => r['substitution_relation_id'])).toEqual(['REL-1']); // 每个值单独一次等值读:别单的行一条都不进来 expect(calls).toContain('parts.profile {"internal_pn":"LM2596S-5.0"}'); expect(calls).toContain('parts.profile {"internal_pn":"XL2596S-5.0"}'); expect(calls.filter((c) => c.startsWith('parts.profile'))).toHaveLength(2); }); it('not 排除:同料号的历史验证记录不含本单挂的那条;别的料号不进来', async () => { const { read } = reader(TABLES); const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read }); expect(out.sets['validation.record']?.map((r) => r['validation_id'])).toEqual(['VAL-A']); expect(out.sets['validation.history']?.map((r) => r['validation_id'])).toEqual(['VAL-OLD']); }); it('案号查无 → caseFound=false,下游集合全空、一次多余的读都不发', async () => { const { read, calls } = reader(TABLES); const out = await resolveViewData(SPEC, { caseRef: 'W05-19990101000000', read }); expect(out.caseFound).toBe(false); expect(out.sets['parts.profile']).toEqual([]); expect(calls.some((c) => c.startsWith('parts.profile'))).toBe(false); }); it('某张表读不了:那个集合为空并点名,别的集合照常', async () => { const { read } = reader(TABLES, new Set(['parts.profile'])); const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read }); expect(out.sets['parts.profile']).toEqual([]); expect(out.sets['substitution.relation']).toHaveLength(1); expect(out.skipped).toEqual(['parts.profile']); }); it('from 指向没定义过的集合 → 点名、集合为空(声明写错不炸)', async () => { const { read } = reader(TABLES); const spec: ViewData = { ...SPEC, rows: [{ as: 'x', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'nope.internal_pn' } }] }; const out = await resolveViewData(spec, { caseRef: 'W05-20260827215103', read }); expect(out.sets['x']).toEqual([]); expect(out.skipped).toEqual(['x']); }); }); describe('viewScopeOf', () => { it('行数组 / 首行标量 / 行数 / tally 计数(先铺 0)/ 实例事实 / 三态', () => { const sets = { 'parts.profile': [ { internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' }, { internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' }, ], 'substitution.relation': [], }; const scope = viewScopeOf({ spec: SPEC, sets, declared: DECLARED, instance: { no: 'INS-20', startedAt: new Date(2026, 7, 27, 21, 51, 3).toISOString(), finishedAt: new Date(2026, 7, 27, 21, 57, 56).toISOString(), owner: '柴浩' }, caseRef: 'W05-20260827215103', state: 'ready', }); expect(scope['board:state']).toBe('ready'); expect(scope['case:instanceNo']).toBe('INS-20'); expect(scope['case:openedAt']).toBe('2026-08-27 21:51'); expect(scope['case:releasedAt']).toBe('2026-08-27 21:57'); expect(scope['case:owner']).toBe('柴浩'); expect(scope['case:ref']).toBe('W05-20260827215103'); expect(scope['parts.profile']).toBe(sets['parts.profile']); expect(scope['parts.profile.internal_pn']).toBe('LM2596S-5.0'); expect(scope['parts_profile_rows']).toBe(2); expect(scope['parts_profile_lifecycle_status_nrnd']).toBe(1); expect(scope['parts_profile_lifecycle_status_active']).toBe(1); expect(scope['parts_profile_lifecycle_status_eol']).toBe(0); expect(scope['substitution_relation_rows']).toBe(0); expect(scope['substitution_relation_recommendation_level_not_recommended']).toBe(0); // 声明里没求出来的集合也铺空数组:模版的 data-rows 不会对着 undefined expect(scope['sub_eval.verdict']).toEqual([]); }); it('没有已放行实例:none 态,实例事实全空串', () => { const scope = viewScopeOf({ spec: SPEC, sets: {}, declared: DECLARED, state: 'none' }); expect(scope['board:state']).toBe('none'); expect(scope['case:instanceNo']).toBe(''); expect(scope['case:releasedAt']).toBe(''); expect(scope['parts_profile_rows']).toBe(0); }); it('slug 与时刻', () => { expect(slugOf('sub_eval.verdict')).toBe('sub_eval_verdict'); expect(slugOf('validation.history')).toBe('validation_history'); expect(slugOf('not_recommended')).toBe('not_recommended'); expect(localMinute(null)).toBe(''); expect(localMinute('garbage')).toBe(''); }); });