Archived
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params / summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts` (与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema); 迁移 0035_activity_ops;《API文档-活动模块》按契约回写。 设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*` 整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。 2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
182 lines
9.4 KiB
TypeScript
182 lines
9.4 KiB
TypeScript
/**
|
|
* 沙箱看板的取数与作用域:声明怎么求值、行集怎么摊平 —— 零 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<string, ViewRow[]> = {
|
|
'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<string, ViewRow[]>, broken: ReadonlySet<string> = 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('');
|
|
});
|
|
});
|