Archived
feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位
`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
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
/** 实例五态:对 task 运行态 / 终态的如实映射(含「驳回是环内回转」那一段) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { instanceStateOf, verdictFactOf } from '../activity-ops/instance-state.ts';
|
||||
|
||||
const base = { terminal: undefined, reason: undefined, runtime: 'running', usedSteps: 3, lastVerdict: undefined, pending: undefined };
|
||||
|
||||
describe('instanceStateOf', () => {
|
||||
it('终态:released 完成;terminated 失败且 kind=terminated;其余终态失败带原话', () => {
|
||||
expect(instanceStateOf({ ...base, terminal: 'released' })).toEqual({ state: 'completed', reason: null });
|
||||
expect(instanceStateOf({ ...base, terminal: 'terminated', reason: '由 柴浩 手动终止' })).toEqual({ state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } });
|
||||
// exhausted 存的是预算执行器的 JSON —— 摆给人的要说哪一项用了多少;认不出的形状原样交
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"kind":"exhausted","breaches":[{"item":"steps","used":90,"cap":90},{"item":"activeMs","used":7200000,"cap":7200000}]}' }).reason)
|
||||
.toEqual({ kind: 'failed', text: '预算耗尽:步数 90/90、活跃时长 120/120 分钟' });
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"breaches":[]}' }).reason?.text).toBe('{"breaches":[]}');
|
||||
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: undefined }).reason?.text).toBe('预算耗尽');
|
||||
expect(instanceStateOf({ ...base, terminal: 'stalled' }).reason).toEqual({ kind: 'failed', text: '停滞熔断' });
|
||||
});
|
||||
|
||||
it('待执行 = 建了单还没被推进器领到;等人回传仍是进行中', () => {
|
||||
expect(instanceStateOf({ ...base, usedSteps: 0 }).state).toBe('pending');
|
||||
expect(instanceStateOf({ ...base, runtime: 'awaiting-human', usedSteps: 0 }).state).toBe('running');
|
||||
expect(instanceStateOf({ ...base, runtime: 'awaiting-event' }).state).toBe('running');
|
||||
});
|
||||
|
||||
it('驳回:裁决刚到(pending.verdict=reject)或重做已被消费(pending 清空)且最近裁决不是 pass;重新提交后回进行中', () => {
|
||||
const redo = { pass: false, comment: '毛利低于阈值,退回重算', byName: '柴浩' };
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: 'reject', comment: redo.comment } })).toEqual({
|
||||
state: 'rejected',
|
||||
reason: { kind: 'rejected', text: '验收驳回:毛利低于阈值,退回重算 —— 负责人 柴浩' },
|
||||
});
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: undefined }).state).toBe('rejected');
|
||||
// 新验收卡已发(pending 立起、还没裁决)→ running
|
||||
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: undefined, comment: undefined } }).state).toBe('running');
|
||||
// 上一次裁决是 pass(落地中)→ running
|
||||
expect(instanceStateOf({ ...base, lastVerdict: { pass: true, comment: '', byName: '柴浩' }, pending: { verdict: 'pass', comment: undefined } }).state).toBe('running');
|
||||
// 没写意见也要说人话
|
||||
expect(instanceStateOf({ ...base, lastVerdict: { pass: false, comment: '', byName: undefined } }).reason?.text).toBe('验收驳回:负责人未写明具体意见,已退回重做');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdictFactOf', () => {
|
||||
it('取最近一条 `裁决:`;落地那一路同 kind 但标题不同,不算', () => {
|
||||
const entries = [
|
||||
{ kind: 'release-verdict', title: '裁决:redo', detail: { by: 's-1', comment: '再核一遍' } },
|
||||
{ kind: 'release-verdict', title: '裁决:pass', detail: { by: 's-1' } },
|
||||
{ kind: 'release-verdict', title: '落地收敛,发射输出口:assessment-released', detail: { ports: ['assessment-released'] } },
|
||||
];
|
||||
expect(verdictFactOf(entries, (id) => (id === 's-1' ? '柴浩' : undefined))).toEqual({ pass: true, comment: '', byName: '柴浩' });
|
||||
expect(verdictFactOf(entries.slice(0, 1))).toEqual({ pass: false, comment: '再核一遍', byName: undefined });
|
||||
expect(verdictFactOf([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 启动参数表:从清单 `input.data` 派生 + 服务端校验。
|
||||
* 夹具照 W05 / E04 的清单形状手写(apps 不得 import activities —— depcruise 在守)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { controlOf, fieldSpecsOf, launchParamOf, launchParamsOf, validateLaunchParams } from '../activity-ops/launch-params.ts';
|
||||
|
||||
/** W05 的四参 + 选填文件,按 2026-08-27 的清单写法 */
|
||||
const W05_INPUT = {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
|
||||
properties: {
|
||||
customerCode: {
|
||||
type: 'string',
|
||||
title: '客户',
|
||||
description: '台账里有就直接选;新客户自己填',
|
||||
examples: ['CU-0142'],
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' },
|
||||
},
|
||||
customerName: { type: 'string', title: '客户名称', examples: ['深圳恒星电源有限公司'] },
|
||||
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea', examples: ['工业电源项目在用 LM2596S-5.0'] },
|
||||
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' } },
|
||||
candidateInternalPns: { type: 'array', title: '候选内部料号', items: { type: 'string' }, examples: [['XL2596S-5.0', 'LM2596S-ADJ']] },
|
||||
datasheets: { type: 'array', format: 'file', title: 'datasheet', items: { type: 'object' } },
|
||||
},
|
||||
};
|
||||
|
||||
/** E04:两级下拉 + 数组(元素形状不声明)+ 选填说明 */
|
||||
const E04_INPUT = {
|
||||
type: 'object',
|
||||
required: ['trigger'],
|
||||
properties: {
|
||||
trigger: { type: 'string', enum: ['monthly', 'event'], enumNames: ['月度例行', '事件驱动'], title: '触发方式', default: 'monthly' },
|
||||
eventType: { type: 'string', enum: ['new_customer', 'major_deal'], title: '事件类型', showWhen: { field: 'trigger', equals: 'event' } },
|
||||
customers: { type: 'array', title: '本轮涉及的客户', examples: [['CU-0142']] },
|
||||
note: { type: 'string', title: '本轮说明' },
|
||||
budget: { type: 'integer', title: '预算' },
|
||||
},
|
||||
};
|
||||
|
||||
const noResolve = async () => undefined;
|
||||
|
||||
describe('fieldSpecsOf / controlOf', () => {
|
||||
it('保属性序;必填从 required[] 认;x-control / x-options / description 都读到', () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets']);
|
||||
expect(specs[0]).toMatchObject({ required: true, control: 'combo', description: '台账里有就直接选;新客户自己填', optionsSource: { ledger: 'customer.profile', value: 'customer_code', label: 'name' } });
|
||||
// label 列没写就用 value 列当标签
|
||||
expect(specs[3]?.optionsSource).toEqual({ ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' });
|
||||
expect(specs[1]?.required).toBe(false);
|
||||
});
|
||||
|
||||
it('控件推断:文件不上表单、字符串数组默认 list、enum 是 select、其余 text;数组标了 combo/select 就摆成单选', () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
expect(specs.map((s) => controlOf(s))).toEqual(['combo', 'text', 'textarea', 'combo', 'list', undefined]);
|
||||
const e04 = fieldSpecsOf(E04_INPUT);
|
||||
expect(e04.map((s) => controlOf(s))).toEqual(['select', 'select', 'list', 'text', 'text']);
|
||||
// 数组字段标 combo → 摆成单选(提交时后端包成数组);标 list 或不标 → list
|
||||
expect(controlOf({ ...specs[4]!, control: 'combo' })).toBe('combo');
|
||||
expect(controlOf({ ...specs[4]!, control: 'list' })).toBe('list');
|
||||
expect(controlOf({ ...specs[4]!, itemType: 'object' })).toBeUndefined();
|
||||
expect(controlOf({ ...specs[1]!, type: 'object' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('x-order', () => {
|
||||
it('声明了次序的按数字排,没声明的殿后保持原序(jsonb 不保键序,书写顺序靠不住)', () => {
|
||||
const specs = fieldSpecsOf({
|
||||
properties: {
|
||||
customerCode: { type: 'string', 'x-order': 1 },
|
||||
customerName: { type: 'string', 'x-order': 2 },
|
||||
originalInternalPn: { type: 'string', 'x-order': 4 },
|
||||
requestDescription: { type: 'string', 'x-order': 3 },
|
||||
zz: { type: 'string' },
|
||||
aa: { type: 'string' },
|
||||
},
|
||||
});
|
||||
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'zz', 'aa']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchParamsOf', () => {
|
||||
it('W05:五参(datasheets 省略);combo 候选来自 resolver;placeholder 取 examples[0]', async () => {
|
||||
const asked: string[] = [];
|
||||
const params = await launchParamsOf(W05_INPUT, async (src) => {
|
||||
asked.push(src.ledger);
|
||||
return src.ledger === 'customer.profile' ? [{ value: 'CU-0142', label: 'Itelma' }] : undefined;
|
||||
});
|
||||
expect(params.map((p) => p.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
|
||||
expect(asked).toEqual(['customer.profile', 'parts.profile']);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'customerCode',
|
||||
label: '客户',
|
||||
type: 'combo',
|
||||
required: true,
|
||||
desc: '台账里有就直接选;新客户自己填',
|
||||
placeholder: 'CU-0142',
|
||||
options: [{ value: 'CU-0142', label: 'Itelma' }],
|
||||
});
|
||||
// 读不到候选的 combo 仍是 combo(自由填),只是没有 options
|
||||
expect(params[3]).toEqual({ key: 'originalInternalPn', label: '原内部料号', type: 'combo', required: true });
|
||||
expect(params[4]).toMatchObject({ type: 'list', placeholder: 'XL2596S-5.0', required: true });
|
||||
expect(params[2]).toMatchObject({ type: 'textarea', placeholder: '工业电源项目在用 LM2596S-5.0' });
|
||||
});
|
||||
|
||||
it('E04:enum → select 带人读名与 default;没 enumNames 拿值当标签;数组元素形状不声明也当 list', async () => {
|
||||
const params = await launchParamsOf(E04_INPUT, noResolve);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'trigger',
|
||||
label: '触发方式',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [{ value: 'monthly', label: '月度例行' }, { value: 'event', label: '事件驱动' }],
|
||||
default: 'monthly',
|
||||
});
|
||||
expect(params[1]?.options).toEqual([{ value: 'new_customer', label: 'new_customer' }, { value: 'major_deal', label: 'major_deal' }]);
|
||||
expect(params[2]).toMatchObject({ type: 'list', placeholder: 'CU-0142' });
|
||||
});
|
||||
|
||||
it('x-control:select 却一个候选都没有 → 降成 text(只能选却无可选等于锁死)', () => {
|
||||
const spec = fieldSpecsOf({ properties: { k: { type: 'string', 'x-control': 'select' } } })[0]!;
|
||||
expect(launchParamOf(spec, undefined)).toEqual({ key: 'k', label: 'k', type: 'text' });
|
||||
expect(launchParamOf(spec, [])?.type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('数组字段摆成单选(2026-08-27 用户定:候选料号与原料号同款下拉,只选一颗)', () => {
|
||||
const INPUT = {
|
||||
required: ['candidateInternalPns'],
|
||||
properties: {
|
||||
candidateInternalPns: {
|
||||
type: 'array',
|
||||
title: '候选内部料号',
|
||||
items: { type: 'string' },
|
||||
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
|
||||
default: ['LM2596S-ADJ'],
|
||||
'x-control': 'combo',
|
||||
'x-options': { ledger: 'parts.profile', value: 'internal_pn' },
|
||||
},
|
||||
tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], 'x-control': 'select' },
|
||||
},
|
||||
};
|
||||
|
||||
it('数组 + x-control:combo → combo 控件,占位与预填取第一颗,候选照读', async () => {
|
||||
const params = await launchParamsOf(INPUT, async () => [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }]);
|
||||
expect(params[0]).toEqual({
|
||||
key: 'candidateInternalPns',
|
||||
label: '候选内部料号',
|
||||
type: 'combo',
|
||||
required: true,
|
||||
placeholder: 'XL2596S-5.0',
|
||||
options: [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }],
|
||||
default: 'LM2596S-ADJ',
|
||||
});
|
||||
expect(params[1]).toMatchObject({ type: 'select', options: [{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }] });
|
||||
});
|
||||
|
||||
it('提交单个字符串包成一元数组;传数组也照收;空的按必填拦;select 逐项对候选', async () => {
|
||||
const specs = fieldSpecsOf(INPUT);
|
||||
const params = await launchParamsOf(INPUT, noResolve);
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: 'XL2596S-5.0' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0'] } });
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: ['XL2596S-5.0', ' LM2596S-ADJ '] }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] } });
|
||||
const empty = validateLaunchParams(params, { candidateInternalPns: ' ' }, specs);
|
||||
expect(empty).toEqual({ ok: false, issues: [{ key: 'candidateInternalPns', message: '必填' }] });
|
||||
const off = validateLaunchParams(params, { candidateInternalPns: 'x', tags: ['a', 'z'] }, specs);
|
||||
expect(off).toEqual({ ok: false, issues: [{ key: 'tags', message: '不在候选里(单选只能选候选)' }] });
|
||||
expect(validateLaunchParams(params, { candidateInternalPns: 'x', tags: 'b' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['x'], tags: ['b'] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLaunchParams', () => {
|
||||
it('必填空 / 未知键 / 类型不符 / select 不在候选 各点名一格;combo 候选之外合法', async () => {
|
||||
const specs = fieldSpecsOf(W05_INPUT);
|
||||
const params = await launchParamsOf(W05_INPUT, async () => [{ value: 'CU-0142', label: 'Itelma' }]);
|
||||
const bad = validateLaunchParams(params, { customerCode: 'CU-NEW', requestDescription: '', candidateInternalPns: [' '], oops: 'x' }, specs);
|
||||
expect(bad.ok).toBe(false);
|
||||
if (!bad.ok) {
|
||||
expect(bad.issues.map((i) => i.key).sort()).toEqual(['candidateInternalPns', 'oops', 'originalInternalPn', 'requestDescription']);
|
||||
}
|
||||
const typed = validateLaunchParams(params, { customerCode: ['CU-1'], requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: 'PN-1' }, specs);
|
||||
expect(typed.ok).toBe(false);
|
||||
// 标量格传数组是错;list 格传单个字符串**照收**(包成一元数组)
|
||||
if (!typed.ok) expect(typed.issues).toEqual([{ key: 'customerCode', message: '要传字符串' }]);
|
||||
const wrapped = validateLaunchParams(params, { customerCode: 'CU-1', requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: ' PN-1 ' }, specs);
|
||||
expect(wrapped).toMatchObject({ ok: true, data: { candidateInternalPns: ['PN-1'] } });
|
||||
|
||||
const good = validateLaunchParams(
|
||||
params,
|
||||
{ customerCode: 'CU-NEW', customerName: '', requestDescription: ' 客户要求验证替代料 ', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', '', ' LM2596S-ADJ '] },
|
||||
specs,
|
||||
);
|
||||
expect(good).toEqual({
|
||||
ok: true,
|
||||
// 可选空串不落入参;list 逐项去空白、丢空项;combo 候选之外原样收
|
||||
data: { customerCode: 'CU-NEW', requestDescription: '客户要求验证替代料', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('select 只认候选;整数按清单类型转回数字', async () => {
|
||||
const specs = fieldSpecsOf(E04_INPUT);
|
||||
const params = await launchParamsOf(E04_INPUT, noResolve);
|
||||
const bad = validateLaunchParams(params, { trigger: 'weekly' }, specs);
|
||||
expect(bad.ok).toBe(false);
|
||||
if (!bad.ok) expect(bad.issues[0]).toMatchObject({ key: 'trigger', message: expect.stringContaining('候选') });
|
||||
expect(validateLaunchParams(params, { trigger: 'event', budget: '12' }, specs)).toEqual({ ok: true, data: { trigger: 'event', budget: 12 } });
|
||||
const nan = validateLaunchParams(params, { trigger: 'event', budget: '1.5' }, specs);
|
||||
expect(nan.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* 活动模块用例:投影 / 启动(幂等三档)/ 实例清单与详情 / 终止 / 禁用。
|
||||
* 依赖全是内存字面量 —— 服务只声明用到的那几面,不用造整本清单。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LaunchInput, TaskId } from '@contracts';
|
||||
import { staffIdSchema, taskIdSchema } from '@contracts';
|
||||
import type { ActivityCard } from '@modules/activity-registry';
|
||||
import { parseCmmn } from '@modules/loop-engine';
|
||||
import type { ItemRuntime } from '@modules/loop-engine';
|
||||
import { StaffNotFoundError } from '@modules/org';
|
||||
import type { StartTaskInput } from '@modules/task';
|
||||
import { createActivityOps, LAUNCH_CLAIM_STALE_MS } from '../activity-ops/service.ts';
|
||||
import type { ActivityOpsDeps, HistoryEntry, LoopFace, ManifestFace } from '../activity-ops/service.ts';
|
||||
import type { ActivityOpsStore, ActivityProfile, LaunchClaim, TaskRow } from '../activity-ops/store.ts';
|
||||
|
||||
const W05 = 'part-substitution-assessment';
|
||||
const OWNER = staffIdSchema.parse('s-owner');
|
||||
const ME = { id: staffIdSchema.parse('s-me'), name: '冯兵' };
|
||||
|
||||
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||
<case id="c" name="试"><casePlanModel id="p" name="p">
|
||||
<humanTask id="S1" name="归一"/>
|
||||
<humanTask id="S2" name="交人确认"><itemControl><manualActivationRule id="m"/></itemControl></humanTask>
|
||||
<humanTask id="T_release" name="放行"/>
|
||||
</casePlanModel></case>
|
||||
</definitions>`;
|
||||
|
||||
const card = (over: Partial<ActivityCard> = {}): ActivityCard => ({
|
||||
activityId: W05,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
category: '需求分析与方案匹配',
|
||||
packageVersions: [
|
||||
{ activityId: W05, version: '1.4.9', installedAt: '2026-08-26T01:00:00.000Z', state: 'active' },
|
||||
{ activityId: W05, version: '1.5.0', installedAt: '2026-08-27T01:00:00.000Z', state: 'active' },
|
||||
],
|
||||
assetVersions: [],
|
||||
enabled: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
const manifest = (over: Partial<ManifestFace> = {}): ManifestFace => ({
|
||||
identity: { name: 'ACT-WORK-05 · 差异评估' },
|
||||
contract: {
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'requestDescription', 'candidateInternalPns'],
|
||||
properties: {
|
||||
customerCode: { type: 'string', title: '客户', 'x-control': 'combo', 'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' } },
|
||||
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea' },
|
||||
candidateInternalPns: { type: 'array', title: '候选料号', items: { type: 'string' } },
|
||||
datasheets: { type: 'array', format: 'file', items: { type: 'object' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
declarations: {
|
||||
models: ['deepseek-v4-flash'],
|
||||
ledger: [
|
||||
{ domain: 'sub_eval', object: 'verdict', fields: [{ name: 'eval_ref' }, { name: 'request_id' }] },
|
||||
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
|
||||
],
|
||||
},
|
||||
assets: { viewTemplates: [{ name: 'activity-ledger', html: '<html><body><h1>一览</h1></body></html>' }] },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** 一张带取数声明的看板模版:案号 → 核对表 → 料号圈主数据,页头摆实例与案号 */
|
||||
const BOARD_HTML = [
|
||||
'<html><body><div class="page" data-class-of="board:state">',
|
||||
'<p><b data-slot="case:instanceNo"></b> · <i data-slot="case:ref"></i> · <u data-slot="case:owner"></u></p>',
|
||||
'<p>需求 <span data-slot="sub_eval.verdict.request_id"></span> · 料号 <span data-slot="parts_profile_rows"></span> 条 · NRND <span data-slot="parts_profile_lifecycle_status_nrnd"></span></p>',
|
||||
'<table><tbody data-rows="parts.profile"><tr><td data-slot="internal_pn"></td><td><span class="st" data-class-of="lifecycle_status"></span></td></tr></tbody></table>',
|
||||
'</div></body></html>',
|
||||
].join('');
|
||||
const BOARD_DATA = {
|
||||
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' } },
|
||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
|
||||
],
|
||||
};
|
||||
const boardManifest = (): ManifestFace => manifest({ assets: { viewTemplates: [{ name: 'activity-ledger', html: BOARD_HTML, data: BOARD_DATA }] } });
|
||||
/** 正式表的一角:两单的行并排躺着,看板只许圈出案号对上的那一单 */
|
||||
const FORMAL: Record<string, Array<Record<string, unknown>>> = {
|
||||
'sub_eval.verdict': [
|
||||
{ eval_ref: 'W05-20260827100000', request_id: 'REQ-20260827100000' },
|
||||
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000' },
|
||||
],
|
||||
'sub_eval.pn_check': [
|
||||
{ eval_ref: 'W05-20260827100000', internal_pn: 'LM2596S-5.0' },
|
||||
{ eval_ref: 'W05-20260827100000', internal_pn: 'XL2596S-5.0' },
|
||||
{ eval_ref: 'W05-20260827000000', internal_pn: 'OTHER' },
|
||||
],
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
|
||||
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
|
||||
{ internal_pn: 'OTHER', lifecycle_status: 'active' },
|
||||
],
|
||||
};
|
||||
const formalLedger: ActivityOpsDeps['ledger'] = {
|
||||
describe: async (address) => (FORMAL[`${address.domain}.${address.object}`] === undefined ? undefined : { columns: [] }),
|
||||
query: async (q) => ({
|
||||
rows: (FORMAL[`${q.address.domain}.${q.address.object}`] ?? []).filter((r) => Object.entries(q.where).every(([k, v]) => String(r[k]) === String(v))),
|
||||
}),
|
||||
};
|
||||
/** 本地时区里 2026-08-27 10:00:00 起的单 —— 案号 W05-20260827100000 */
|
||||
const RELEASED_AT = new Date(2026, 7, 27, 10, 0, 0).toISOString();
|
||||
|
||||
const row = (over: Partial<TaskRow> = {}): TaskRow => ({
|
||||
id: 't-1',
|
||||
no: 'INS-7',
|
||||
activityId: W05,
|
||||
workflowId: null,
|
||||
origin: 'ops',
|
||||
ownerId: OWNER,
|
||||
title: 'ACT-WORK-05 · 差异评估',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
data: { customerCode: 'CU-0142', requestDescription: '工业电源项目在用 LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0'], datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 3, ref: 'u-1' }] },
|
||||
usedSteps: 2,
|
||||
startedAt: '2026-08-27T02:00:00.000Z',
|
||||
finishedAt: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
function memoryStore(rows: TaskRow[] = [], profiles: ActivityProfile[] = []) {
|
||||
const claims = new Map<string, LaunchClaim>();
|
||||
const profileMap = new Map(profiles.map((p) => [p.activityId, p]));
|
||||
const store: ActivityOpsStore = {
|
||||
async countByActivity() {
|
||||
const m = new Map<string, number>();
|
||||
for (const r of rows) m.set(r.activityId, (m.get(r.activityId) ?? 0) + 1);
|
||||
return m;
|
||||
},
|
||||
async listTasks(activityId) {
|
||||
return rows.filter((r) => r.activityId === activityId);
|
||||
},
|
||||
async getTask(activityId, taskId) {
|
||||
return rows.find((r) => r.activityId === activityId && r.id === taskId);
|
||||
},
|
||||
async latestReleased(activityId) {
|
||||
return rows
|
||||
.filter((r) => r.activityId === activityId && r.terminal === 'released')
|
||||
.sort((a, b) => Date.parse(b.finishedAt ?? b.startedAt) - Date.parse(a.finishedAt ?? a.startedAt))[0];
|
||||
},
|
||||
async claimLaunch(input) {
|
||||
const hit = claims.get(input.key);
|
||||
if (hit !== undefined) return hit;
|
||||
claims.set(input.key, { ...input, taskId: null, claimedAt: clock.now().toISOString() });
|
||||
return 'claimed';
|
||||
},
|
||||
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
|
||||
const hit = claims.get(key);
|
||||
if (hit === undefined || hit.taskId !== null || clock.now().getTime() - Date.parse(hit.claimedAt) < staleMs) return false;
|
||||
claims.set(key, { ...hit, ownerId, launchedBy, claimedAt: clock.now().toISOString() });
|
||||
return true;
|
||||
},
|
||||
async bindLaunch(key, taskId) {
|
||||
const hit = claims.get(key);
|
||||
if (hit !== undefined) claims.set(key, { ...hit, taskId });
|
||||
},
|
||||
async launchOf(taskId) {
|
||||
return [...claims.values()].find((c) => c.taskId === taskId);
|
||||
},
|
||||
async profilesOf() {
|
||||
return profileMap;
|
||||
},
|
||||
async upsertProfile(p) {
|
||||
profileMap.set(p.activityId, p);
|
||||
},
|
||||
async workflowOf() {
|
||||
return { subject: '统一报价 · Itelma', source: { kind: 'event' } };
|
||||
},
|
||||
async inboundCommOf(taskId) {
|
||||
return taskId === 't-mail' ? { channel: 'mail', party: 'quotes@itelma.ru', subject: 'RFQ' } : undefined;
|
||||
},
|
||||
};
|
||||
const clock = { at: Date.parse('2026-08-27T03:00:00.000Z'), now: () => new Date(clock.at) };
|
||||
return { store, claims, clock, rows };
|
||||
}
|
||||
|
||||
function harness(
|
||||
opts: {
|
||||
rows?: TaskRow[];
|
||||
profiles?: ActivityProfile[];
|
||||
enabled?: boolean;
|
||||
history?: Record<string, HistoryEntry[]>;
|
||||
loops?: Record<string, LoopFace>;
|
||||
manifest?: ManifestFace;
|
||||
ledger?: ActivityOpsDeps['ledger'];
|
||||
} = {},
|
||||
) {
|
||||
const mem = memoryStore(opts.rows ?? [], opts.profiles ?? []);
|
||||
const started: StartTaskInput[] = [];
|
||||
const controlled: Array<{ taskId: TaskId; reason: string | undefined }> = [];
|
||||
let enabled = opts.enabled ?? true;
|
||||
let n = 0;
|
||||
const staff = [
|
||||
{ id: OWNER, name: '柴浩', state: 'active' },
|
||||
{ id: ME.id, name: ME.name, state: 'active' },
|
||||
{ id: 's-gone', name: '离职者', state: 'disabled' },
|
||||
];
|
||||
const deps: ActivityOpsDeps = {
|
||||
registry: {
|
||||
list: async () => [card({ enabled })],
|
||||
get: async (id) => (id === W05 ? card({ enabled }) : undefined),
|
||||
manifestOf: async () => opts.manifest ?? manifest(),
|
||||
setEnabled: async (_id, on) => {
|
||||
enabled = on;
|
||||
},
|
||||
},
|
||||
task: {
|
||||
start: async (input) => {
|
||||
started.push(input);
|
||||
n += 1;
|
||||
const id = taskIdSchema.parse(`t-new-${n}`);
|
||||
mem.rows.push(row({ id, no: `INS-${100 + n}`, ownerId: input.ownerId, data: input.data, usedSteps: 0, origin: input.origin ?? null }));
|
||||
return { id };
|
||||
},
|
||||
control: async (taskId, _semantic, _by, reason) => {
|
||||
controlled.push({ taskId, reason });
|
||||
const at = mem.rows.findIndex((r) => r.id === taskId);
|
||||
const hit = mem.rows[at];
|
||||
if (hit !== undefined) mem.rows.splice(at, 1, { ...hit, terminal: 'terminated', reason: reason ?? null, finishedAt: mem.clock.now().toISOString() });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
org: {
|
||||
listStaff: async () => ({ staff }),
|
||||
getStaff: async (id) => {
|
||||
const hit = staff.find((s) => s.id === id);
|
||||
if (hit === undefined) throw new StaffNotFoundError(id);
|
||||
return hit;
|
||||
},
|
||||
},
|
||||
memory: { history: async (taskId) => opts.history?.[taskId] ?? [] },
|
||||
ledger: opts.ledger ?? {
|
||||
describe: async (address) => (address.object === 'profile' ? { columns: [{ name: 'customer_code' }, { name: 'name' }] } : undefined),
|
||||
query: async () => ({ rows: [{ customer_code: 'CU-0142', name: 'Itelma' }, { customer_code: 'CU-0001', name: '' }] }),
|
||||
},
|
||||
loopState: { load: async (taskId) => opts.loops?.[taskId] },
|
||||
store: mem.store,
|
||||
files: {
|
||||
readUpload: async (ref) => (ref === 'u-1' ? { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : undefined),
|
||||
listArchived: async (taskId) => (taskId === 't-done' ? [{ address: 'content.asset', key: { id: '1' }, format: 'docx', name: '报告.docx', size: 9 }] : []),
|
||||
readArchived: async () => ({ bytes: new Uint8Array([9]), name: '报告.docx', format: 'docx' }),
|
||||
},
|
||||
clock: mem.clock,
|
||||
warn: () => undefined,
|
||||
};
|
||||
return { ops: createActivityOps(deps), mem, started, controlled };
|
||||
}
|
||||
|
||||
const launchInput = (over: Partial<LaunchInput> = {}): LaunchInput => ({
|
||||
idempotencyKey: 'k-1',
|
||||
ownerId: OWNER,
|
||||
params: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] },
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('活动投影', () => {
|
||||
it('清单:版本取最新一版、创建时刻取最早一版、负责人与图标来自档案、实例按活动数;status 过滤', async () => {
|
||||
const { ops } = harness({
|
||||
rows: [row(), row({ id: 't-2', no: 'INS-8' })],
|
||||
profiles: [{ activityId: W05, ownerId: OWNER, description: '逐项差异评估', ico: 'git-compare', hue: 'teal' }],
|
||||
});
|
||||
const [a] = await ops.listActivities();
|
||||
expect(a).toEqual({
|
||||
id: W05,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
biz: '需求分析与方案匹配',
|
||||
status: 'active',
|
||||
version: '1.5.0',
|
||||
owner: { id: OWNER, name: '柴浩' },
|
||||
llm: 'deepseek-v4-flash',
|
||||
instanceCount: 2,
|
||||
createdAt: '2026-08-26T01:00:00.000Z',
|
||||
ico: 'git-compare',
|
||||
hue: 'teal',
|
||||
});
|
||||
expect(await ops.listActivities('disabled')).toEqual([]);
|
||||
const detail = await ops.getActivity(W05);
|
||||
expect(detail).toMatchObject({ desc: '逐项差异评估', view: { url: `/api/gov/activities/${W05}/view` } });
|
||||
expect(detail).not.toHaveProperty('instanceCount');
|
||||
expect(await ops.getActivity('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('没建档:负责人 null、说明空串、图标色相缺席;色相不在四色里也不摆', async () => {
|
||||
const { ops } = harness({ profiles: [{ activityId: W05, ownerId: null, description: '', ico: null, hue: 'pink' }] });
|
||||
const [a] = await ops.listActivities();
|
||||
expect(a).toMatchObject({ owner: null, instanceCount: 0 });
|
||||
expect(a).not.toHaveProperty('ico');
|
||||
expect(a).not.toHaveProperty('hue');
|
||||
});
|
||||
|
||||
it('禁用 / 激活幂等,回活动详情;视图注入报高脚本、没配视图 404', async () => {
|
||||
const { ops } = harness();
|
||||
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
|
||||
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
|
||||
expect((await ops.setEnabled(W05, true))?.status).toBe('active');
|
||||
expect(await ops.setEnabled('nope', true)).toBeUndefined();
|
||||
const view = await ops.viewHtml(W05);
|
||||
expect(view.kind).toBe('html');
|
||||
if (view.kind === 'html') expect(view.html).toContain('jcai:view-height');
|
||||
expect((await ops.viewHtml('nope')).kind).toBe('no-activity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('沙箱看板:最近一次已放行实例的落账快照', () => {
|
||||
const released = (over: Partial<TaskRow> = {}) =>
|
||||
row({ id: 't-rel', no: 'INS-20', runtime: 'terminal', terminal: 'released', startedAt: RELEASED_AT, finishedAt: new Date(2026, 7, 27, 10, 6, 0).toISOString(), ...over });
|
||||
|
||||
it('按案号只圈这一单的行:页头实例号 / 案号 / 负责人,料号两颗、别单的 OTHER 不进来,徽标按值投影,读数摆出来', async () => {
|
||||
const older = released({ id: 't-old', no: 'INS-3', startedAt: new Date(2026, 7, 27, 0, 0, 0).toISOString(), finishedAt: new Date(2026, 7, 27, 0, 5, 0).toISOString() });
|
||||
const { ops } = harness({ rows: [row({ id: 't-live' }), older, released()], manifest: boardManifest(), ledger: formalLedger });
|
||||
const view = await ops.viewHtml(W05);
|
||||
expect(view.kind).toBe('html');
|
||||
if (view.kind !== 'html') return;
|
||||
expect(view.html).toContain('class="page v-ready"');
|
||||
expect(view.html).toContain('<b>INS-20</b> · <i>W05-20260827100000</i> · <u>柴浩</u>');
|
||||
expect(view.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>2</span> 条 · NRND <span>1</span>');
|
||||
expect(view.html).toContain('<td>LM2596S-5.0</td><td><span class="st v-nrnd"></span></td>');
|
||||
expect(view.html).toContain('<td>XL2596S-5.0</td><td><span class="st v-active"></span></td>');
|
||||
expect(view.html).not.toContain('OTHER');
|
||||
expect(view.html).not.toContain('data-rows=');
|
||||
expect(view.html).toContain('jcai:view-height');
|
||||
});
|
||||
|
||||
it('没放行过 → none 态、实例事实空着;案号在正式表里查无 → missing 态、表全空(不摆别单的行)', async () => {
|
||||
const none = harness({ rows: [row({ id: 't-live' })], manifest: boardManifest(), ledger: formalLedger });
|
||||
const v1 = await none.ops.viewHtml(W05);
|
||||
if (v1.kind === 'html') {
|
||||
expect(v1.html).toContain('class="page v-none"');
|
||||
expect(v1.html).toContain('<b>—</b>');
|
||||
expect(v1.html).toContain('料号 <span>0</span>');
|
||||
} else expect.unreachable();
|
||||
|
||||
const missing = harness({ rows: [released({ startedAt: new Date(2026, 7, 27, 11, 0, 0).toISOString() })], manifest: boardManifest(), ledger: formalLedger });
|
||||
const v2 = await missing.ops.viewHtml(W05);
|
||||
if (v2.kind === 'html') {
|
||||
expect(v2.html).toContain('class="page v-missing"');
|
||||
expect(v2.html).toContain('W05-20260827110000');
|
||||
expect(v2.html).not.toContain('LM2596S-5.0');
|
||||
} else expect.unreachable();
|
||||
});
|
||||
|
||||
it('一张表读不了不拖垮整屏:那一块空着,别的照摆;模版没带 data 就原样出', async () => {
|
||||
const half: ActivityOpsDeps['ledger'] = { ...formalLedger, describe: async (a) => (a.object === 'profile' ? undefined : formalLedger.describe(a)) };
|
||||
const { ops } = harness({ rows: [released()], manifest: boardManifest(), ledger: half });
|
||||
const v = await ops.viewHtml(W05);
|
||||
if (v.kind === 'html') {
|
||||
expect(v.html).toContain('class="page v-ready"');
|
||||
expect(v.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>0</span>');
|
||||
} else expect.unreachable();
|
||||
const plain = harness({ rows: [released()] });
|
||||
const p = await plain.ops.viewHtml(W05);
|
||||
if (p.kind === 'html') expect(p.html).toContain('<h1>一览</h1>');
|
||||
else expect.unreachable();
|
||||
});
|
||||
});
|
||||
|
||||
describe('启动', () => {
|
||||
it('参数表:combo 候选从台账读(空标签退回值);文件字段不上表', async () => {
|
||||
const { ops } = harness();
|
||||
const table = await ops.launchParams(W05);
|
||||
expect(table?.version).toBe('1.5.0');
|
||||
expect(table?.params.map((p) => p.key)).toEqual(['customerCode', 'requestDescription', 'candidateInternalPns']);
|
||||
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
|
||||
});
|
||||
|
||||
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
|
||||
const { ops, started } = harness({ enabled: false });
|
||||
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
|
||||
const live = harness();
|
||||
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-gone') }), ME)).toMatchObject({ kind: 'owner-invalid' });
|
||||
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-nobody') }), ME)).toMatchObject({ kind: 'owner-invalid', message: '责任人不在名册里' });
|
||||
const bad = await live.ops.launch(W05, launchInput({ params: { customerCode: 'CU-0142' } }), ME);
|
||||
expect(bad.kind).toBe('invalid');
|
||||
if (bad.kind === 'invalid') expect(bad.issues.map((i) => i.key)).toEqual(['requestDescription', 'candidateInternalPns']);
|
||||
expect((await live.ops.launch('nope', launchInput(), ME)).kind).toBe('not-found');
|
||||
expect(started).toEqual([]);
|
||||
expect(live.started).toEqual([]);
|
||||
});
|
||||
|
||||
it('起单:origin=ops、标题取清单名、入参归一后交 task.start;同键重放回同一单、不起第二个', async () => {
|
||||
const { ops, started } = harness();
|
||||
const first = await ops.launch(W05, launchInput(), ME);
|
||||
expect(first.kind).toBe('ok');
|
||||
if (first.kind !== 'ok') return;
|
||||
expect(first.created).toBe(true);
|
||||
expect(started).toEqual([
|
||||
{ activityId: W05, ownerId: OWNER, title: 'ACT-WORK-05 · 差异评估', origin: 'ops', data: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] } },
|
||||
]);
|
||||
expect(first.detail).toMatchObject({ id: 't-new-1', state: 'pending', owner: { id: OWNER, name: '柴浩' }, input: { source: { kind: 'manual', label: '手动启动 · 冯兵' } } });
|
||||
|
||||
const again = await ops.launch(W05, launchInput({ params: { customerCode: 'X', requestDescription: 'y', candidateInternalPns: ['z'] } }), ME);
|
||||
expect(again).toMatchObject({ kind: 'ok', created: false, detail: { id: 't-new-1' } });
|
||||
expect(started).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('键占了单还没建成:60 秒内 409;过期后重占再起', async () => {
|
||||
const h = harness();
|
||||
await h.mem.store.claimLaunch({ key: 'k-1', activityId: W05, ownerId: OWNER, launchedBy: 's-x' });
|
||||
expect(await h.ops.launch(W05, launchInput(), ME)).toMatchObject({ kind: 'busy' });
|
||||
h.mem.clock.at += LAUNCH_CLAIM_STALE_MS + 1;
|
||||
const late = await h.ops.launch(W05, launchInput(), ME);
|
||||
expect(late.kind).toBe('ok');
|
||||
expect(h.started).toHaveLength(1);
|
||||
expect(h.mem.claims.get('k-1')?.taskId).toBe('t-new-1');
|
||||
// 别的活动拿同一枚键 → 409
|
||||
await h.mem.store.claimLaunch({ key: 'k-2', activityId: 'other', ownerId: OWNER, launchedBy: 's-x' });
|
||||
expect(await h.ops.launch(W05, launchInput({ idempotencyKey: 'k-2' }), ME)).toMatchObject({ kind: 'busy' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('实例清单与详情', () => {
|
||||
const items = parseCmmn(XML).items;
|
||||
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||
const loop = (states: Record<string, ItemRuntime['state']>, pending?: LoopFace['pending']): LoopFace => ({
|
||||
model: { steps: [{ item: 'S1', intent: '把四项绑定' }] },
|
||||
parsed: { items },
|
||||
runtime: { items: rt(states) },
|
||||
...(pending === undefined ? {} : { pending }),
|
||||
});
|
||||
const line = (kind: string, title: string, at: string, detail?: Record<string, unknown>): HistoryEntry => ({ kind, title, at, ...(detail === undefined ? {} : { detail }) });
|
||||
|
||||
const rows = [
|
||||
row({ id: 't-1', no: 'INS-7' }),
|
||||
row({ id: 't-redo', no: 'INS-6', startedAt: '2026-08-26T02:00:00.000Z' }),
|
||||
row({ id: 't-done', no: 'INS-5', terminal: 'released', runtime: 'terminal', finishedAt: '2026-08-25T03:00:00.000Z', startedAt: '2026-08-25T02:00:00.000Z' }),
|
||||
row({ id: 't-dead', no: 'INS-4', terminal: 'terminated', reason: '由 冯兵 手动终止', finishedAt: '2026-08-24T02:30:00.000Z', startedAt: '2026-08-24T02:00:00.000Z', ownerId: ME.id }),
|
||||
row({ id: 't-mail', no: 'INS-3', origin: null, usedSteps: 0, data: {}, startedAt: '2026-08-23T02:00:00.000Z' }),
|
||||
];
|
||||
const history = {
|
||||
't-redo': [line('release-verdict', '裁决:redo', '2026-08-26T03:00:00.000Z', { by: OWNER, comment: '证据不足' })],
|
||||
't-done': [line('release-verdict', '落地收敛,发射输出口:assessment-released', '2026-08-25T03:00:00.000Z', { ports: ['assessment-released'] })],
|
||||
't-1': [line('engine-transition', '开始办:归一', '2026-08-27T02:00:10.000Z'), line('engine-transition', '完成:归一', '2026-08-27T02:01:10.000Z'), line('engine-transition', '开始办:交人确认', '2026-08-27T02:01:20.000Z')],
|
||||
};
|
||||
const loops = { 't-1': loop({ S1: 'Completed', S2: 'Active' }), 't-redo': loop({ S1: 'Completed', S2: 'Completed', T_release: 'Completed' }) };
|
||||
|
||||
it('五态各归其位;统计不随筛选动;产出只给已完成的;分页按筛后走', async () => {
|
||||
const { ops } = harness({ rows, history, loops });
|
||||
const all = await ops.listInstances(W05, { page: 1, size: 8 });
|
||||
expect(all?.stats).toEqual({ total: 5, byState: { pending: 1, running: 1, completed: 1, rejected: 1, failed: 1 } });
|
||||
expect(all?.items.map((i) => [i.no, i.state, i.output])).toEqual([
|
||||
['INS-7', 'running', null],
|
||||
['INS-6', 'rejected', null],
|
||||
['INS-5', 'completed', '报告.docx'],
|
||||
['INS-4', 'failed', null],
|
||||
['INS-3', 'pending', null],
|
||||
]);
|
||||
expect(all?.items[0]?.input).toBe('工业电源项目在用 LM2596S-5.0');
|
||||
// 入参空的那单退回来件主题
|
||||
expect(all?.items[4]?.input).toBe('RFQ');
|
||||
const page2 = await ops.listInstances(W05, { page: 2, size: 2, ownerId: OWNER });
|
||||
expect(page2).toMatchObject({ total: 4, page: 2, size: 2 });
|
||||
expect(page2?.items.map((i) => i.no)).toEqual(['INS-5', 'INS-3']);
|
||||
expect(page2?.stats.total).toBe(5);
|
||||
expect((await ops.listInstances(W05, { page: 1, size: 8, id: 'ins-4' }))?.items.map((i) => i.no)).toEqual(['INS-4']);
|
||||
expect(await ops.listInstances('nope', { page: 1, size: 8 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('详情:时间线 / 来源 / 文件 / 原因 / 用时', async () => {
|
||||
const { ops } = harness({ rows, history, loops });
|
||||
const live = await ops.instanceDetail(W05, 't-1');
|
||||
expect(live).toMatchObject({
|
||||
no: 'INS-7',
|
||||
state: 'running',
|
||||
reason: null,
|
||||
endedAt: null,
|
||||
durationMs: null,
|
||||
llm: 'deepseek-v4-flash',
|
||||
input: { content: '工业电源项目在用 LM2596S-5.0', source: { kind: 'manual', label: '手动启动 · 运营端' }, files: [{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 3 }] },
|
||||
output: { files: [] },
|
||||
});
|
||||
expect(live?.steps.map((s) => [s.name, s.by, s.byName, s.res, s.durationMs, s.note])).toEqual([
|
||||
['归一', 'agent', 'deepseek-v4-flash', 'done', 60_000, '把四项绑定'],
|
||||
['交人确认', 'human', '柴浩', 'current', null, ''],
|
||||
['放行', 'agent', 'deepseek-v4-flash', 'pending', null, ''],
|
||||
]);
|
||||
expect(live).not.toHaveProperty('input.output');
|
||||
|
||||
const redo = await ops.instanceDetail(W05, 't-redo');
|
||||
expect(redo?.reason).toEqual({ kind: 'rejected', text: '验收驳回:证据不足 —— 负责人 柴浩' });
|
||||
expect(redo?.steps.map((s) => s.res)).toEqual(['done', 'done', 'fail']);
|
||||
|
||||
const dead = await ops.instanceDetail(W05, 't-dead');
|
||||
expect(dead).toMatchObject({ state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' }, endedAt: '2026-08-24T02:30:00.000Z', durationMs: 30 * 60_000, owner: { name: '冯兵' } });
|
||||
|
||||
const done = await ops.instanceDetail(W05, 't-done');
|
||||
expect(done?.output.files).toEqual([{ index: 0, name: '报告.docx', contentType: expect.stringContaining('wordprocessingml'), size: 9 }]);
|
||||
|
||||
const mail = await ops.instanceDetail(W05, 't-mail');
|
||||
expect(mail?.input.source).toEqual({ kind: 'mail', label: '邮件 · quotes@itelma.ru' });
|
||||
expect(await ops.instanceDetail(W05, 'nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('上游工作流起的单标 upstream;测试台起的标 testbed', async () => {
|
||||
const { ops } = harness({ rows: [row({ id: 't-wf', workflowId: 'wf-1', origin: null }), row({ id: 't-tb', origin: 'testbed', data: {} })] });
|
||||
expect((await ops.instanceDetail(W05, 't-wf'))?.input.source).toEqual({ kind: 'upstream', label: '上游 · 统一报价 · Itelma' });
|
||||
expect((await ops.instanceDetail(W05, 't-tb'))?.input.source).toEqual({ kind: 'testbed', label: '测试台试跑' });
|
||||
});
|
||||
|
||||
it('文件:input 走上传件、output 走存档;越界 / 无单各回明确结果', async () => {
|
||||
const { ops } = harness({ rows });
|
||||
expect(await ops.instanceFile(W05, 't-1', 'input', 0)).toMatchObject({ kind: 'file', name: 'a.pdf', contentType: 'application/pdf' });
|
||||
expect((await ops.instanceFile(W05, 't-1', 'input', 1)).kind).toBe('no-file');
|
||||
expect(await ops.instanceFile(W05, 't-done', 'output', 0)).toMatchObject({ kind: 'file', name: '报告.docx' });
|
||||
expect((await ops.instanceFile(W05, 't-1', 'output', 0)).kind).toBe('no-file');
|
||||
expect((await ops.instanceFile(W05, 'nope', 'output', 0)).kind).toBe('no-instance');
|
||||
});
|
||||
|
||||
it('终止:走 task.control,理由带人名;已终态 412;之后详情是 failed + terminated', async () => {
|
||||
const { ops, controlled } = harness({ rows: [row({ id: 't-1' }), row({ id: 't-done', terminal: 'released', runtime: 'terminal' })] });
|
||||
const done = await ops.terminate(W05, 't-1', ME);
|
||||
expect(done).toMatchObject({ kind: 'ok', detail: { state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' } } });
|
||||
expect(controlled).toEqual([{ taskId: 't-1', reason: '由 冯兵 手动终止' }]);
|
||||
expect((await ops.terminate(W05, 't-done', ME)).kind).toBe('terminal');
|
||||
expect((await ops.terminate(W05, 't-1', ME)).kind).toBe('terminal');
|
||||
expect((await ops.terminate(W05, 'nope', ME)).kind).toBe('not-found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/** L3 动作过程记录:计划项 × 运行态 × 引擎痕 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCmmn } from '@modules/loop-engine';
|
||||
import type { ItemRuntime } from '@modules/loop-engine';
|
||||
import { stepsOf } from '../activity-ops/instance-steps.ts';
|
||||
|
||||
const XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
|
||||
<case id="c" name="试">
|
||||
<casePlanModel id="p" name="p">
|
||||
<humanTask id="S1" name="归一"/>
|
||||
<humanTask id="S2" name="交人确认">
|
||||
<itemControl><manualActivationRule id="m_s2"/></itemControl>
|
||||
</humanTask>
|
||||
<humanTask id="S3" name="放行"/>
|
||||
<milestone id="M1" name="里程碑"/>
|
||||
</casePlanModel>
|
||||
</case>
|
||||
</definitions>`;
|
||||
|
||||
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||
const line = (title: string, at: string) => ({ kind: 'engine-transition', title, at });
|
||||
const intents = new Map([['S1', '把来件归一成台账行'], ['S2', '派一张执行单交人']]);
|
||||
|
||||
describe('stepsOf', () => {
|
||||
const items = parseCmmn(XML).items;
|
||||
|
||||
it('只摆任务项(里程碑不算),按文档序;人工激活的项是人办的;时刻与用时来自引擎痕', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: rt({ S1: 'Completed', S2: 'Active', S3: 'Available' }),
|
||||
intents,
|
||||
history: [
|
||||
line('开始办:归一', '2026-08-27T02:00:00.000Z'),
|
||||
line('完成:归一', '2026-08-27T02:03:00.000Z'),
|
||||
line('开始办:交人确认', '2026-08-27T02:03:30.000Z'),
|
||||
],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'deepseek-v4-flash',
|
||||
});
|
||||
expect(steps.map((s) => [s.seq, s.name, s.by, s.byName, s.res])).toEqual([
|
||||
[1, '归一', 'agent', 'deepseek-v4-flash', 'done'],
|
||||
[2, '交人确认', 'human', '柴浩', 'current'],
|
||||
[3, '放行', 'agent', 'deepseek-v4-flash', 'pending'],
|
||||
]);
|
||||
expect(steps[0]).toMatchObject({ at: '2026-08-27T02:00:00.000Z', durationMs: 180_000, note: '把来件归一成台账行' });
|
||||
expect(steps[1]).toMatchObject({ at: '2026-08-27T02:03:30.000Z', durationMs: null, note: '派一张执行单交人' });
|
||||
expect(steps[2]).toMatchObject({ at: null, durationMs: null, note: '' });
|
||||
});
|
||||
|
||||
it('自动收步的项拿收步那一刻当时刻;没有 runtime 的项是 pending', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: rt({ S1: 'Completed' }),
|
||||
intents,
|
||||
history: [line('自动收步:S1 的成果已在台账里(rows > 0)—— 不必再问模型', '2026-08-27T02:01:00.000Z')],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'm',
|
||||
});
|
||||
expect(steps[0]).toMatchObject({ res: 'done', at: '2026-08-27T02:01:00.000Z', durationMs: null });
|
||||
expect(steps[1]?.res).toBe('pending');
|
||||
});
|
||||
|
||||
it('终止 / 失败:正在办的那一步记 fail;都没开就记第一个没到的;驳回记最后一个已完成的', () => {
|
||||
const facts = { items, intents, history: [], ownerName: '柴浩', llm: 'm' };
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Active' }), state: 'failed' }).map((s) => s.res)).toEqual(['done', 'fail', 'pending']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Terminated', S2: 'Terminated' }), state: 'failed' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Completed', S3: 'Completed' }), state: 'rejected' }).map((s) => s.res)).toEqual(['done', 'done', 'fail']);
|
||||
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Failed' }), state: 'running' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
|
||||
});
|
||||
|
||||
it('重复实例化的项看最新那一实例', () => {
|
||||
const steps = stepsOf({
|
||||
items,
|
||||
runtimeItems: [{ id: 'S2', state: 'Completed', instance: 1 }, { id: 'S2', state: 'Active', instance: 2 }],
|
||||
intents,
|
||||
history: [],
|
||||
state: 'running',
|
||||
ownerName: '柴浩',
|
||||
llm: 'm',
|
||||
});
|
||||
expect(steps[1]?.res).toBe('current');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 启动幂等键在真库上的并发:两条连接同时占同一枚键,**恰一个赢**;单没建成的过期键可被重占。
|
||||
* 独占一个库(照 mail-store.it 的先例):建表 / 清表都在 `jcai_it_activity_ops` 里,谁的库都不碰。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import { createActivityOpsStore } from '../activity-ops/store.ts';
|
||||
|
||||
const IT_DB = 'jcai_it_activity_ops';
|
||||
|
||||
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
|
||||
const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||
try {
|
||||
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
|
||||
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
|
||||
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
|
||||
await admin.end();
|
||||
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
|
||||
} catch {
|
||||
/* 开发实例连不上,试 Testcontainers */
|
||||
}
|
||||
try {
|
||||
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
|
||||
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
|
||||
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
|
||||
} catch (error) {
|
||||
process.stderr.write(`⚠️ activity-ops store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const database = await provisionDatabase();
|
||||
let poolA: Pool | undefined;
|
||||
let poolB: Pool | undefined;
|
||||
let dbA: Db;
|
||||
let dbB: Db;
|
||||
|
||||
const MIGRATION = new URL('../../../../../db/migrations/0035_activity_ops.sql', import.meta.url);
|
||||
|
||||
beforeAll(async () => {
|
||||
if (database === undefined) return;
|
||||
poolA = new Pool({ connectionString: database.url, max: 2 });
|
||||
poolB = new Pool({ connectionString: database.url, max: 2 });
|
||||
dbA = drizzle(poolA);
|
||||
dbB = drizzle(poolB);
|
||||
await poolA.query('create schema if not exists sys');
|
||||
await poolA.query('drop table if exists sys.activity_launch; drop table if exists sys.activity_profile');
|
||||
await poolA.query(readFileSync(MIGRATION, 'utf8'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await poolA?.end();
|
||||
await poolB?.end();
|
||||
await database?.stop();
|
||||
});
|
||||
|
||||
describe.skipIf(database === undefined)('activity_launch 幂等键(真库)', () => {
|
||||
it('两条连接同时占同一枚键,恰一个 claimed,另一个读回那一行;绑定后重放拿到 task_id', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
const b = createActivityOpsStore(dbB);
|
||||
const input = { key: 'race-1', activityId: 'w05', ownerId: 'owner', launchedBy: 'me' };
|
||||
const results = await Promise.all(Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? a : b).claimLaunch(input)));
|
||||
expect(results.filter((r) => r === 'claimed')).toHaveLength(1);
|
||||
for (const r of results) if (r !== 'claimed') expect(r).toMatchObject({ key: 'race-1', activityId: 'w05', taskId: null });
|
||||
await a.bindLaunch('race-1', 't-1');
|
||||
expect(await b.claimLaunch(input)).toMatchObject({ taskId: 't-1' });
|
||||
expect(await b.launchOf('t-1')).toMatchObject({ key: 'race-1', launchedBy: 'me' });
|
||||
});
|
||||
|
||||
it('单没建成的键:没过期不许重占;过期后恰一个重占成功;已绑 task 的永远不许', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
const b = createActivityOpsStore(dbB);
|
||||
expect(await a.claimLaunch({ key: 'stale-1', activityId: 'w05', ownerId: 'o', launchedBy: 'x' })).toBe('claimed');
|
||||
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'y')).toBe(false);
|
||||
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
|
||||
const won = await Promise.all([a.reclaimLaunch('stale-1', 60_000, 'o', 'y'), b.reclaimLaunch('stale-1', 60_000, 'o', 'z')]);
|
||||
expect(won.filter(Boolean)).toHaveLength(1);
|
||||
await a.bindLaunch('stale-1', 't-2');
|
||||
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
|
||||
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'q')).toBe(false);
|
||||
});
|
||||
|
||||
it('活动档案:upsert 冲突即更新,负责人可空', async () => {
|
||||
const a = createActivityOpsStore(dbA);
|
||||
await a.upsertProfile({ activityId: 'w05', ownerId: null, description: '一', ico: 'git-compare', hue: 'teal' });
|
||||
await a.upsertProfile({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
|
||||
expect((await a.profilesOf()).get('w05')).toEqual({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 活动模块的 app 层存取:SQL 不求值,只看「几笔、带了什么」与行 → 视图的映射(时间戳归一、seq → 实例号)。
|
||||
* 真库上的并发抢键在 `activity-ops-store.it.test.ts`。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createActivityOpsStore } from '../activity-ops/store.ts';
|
||||
|
||||
type Fake = { calls: string[]; execute: (query: unknown) => Promise<{ rows: unknown[] }> };
|
||||
|
||||
/** 每次 execute 按顺序吐一份 rows;SQL 片段拼成字符串留着断言 */
|
||||
function fakeDb(...results: unknown[][]): Fake {
|
||||
const calls: string[] = [];
|
||||
const queue = [...results];
|
||||
return {
|
||||
calls,
|
||||
execute: async (query: unknown) => {
|
||||
const q = query as { queryChunks?: unknown[] };
|
||||
calls.push(JSON.stringify(q.queryChunks ?? query));
|
||||
return { rows: queue.shift() ?? [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RAW_TASK = {
|
||||
id: 't-1',
|
||||
seq: '17',
|
||||
activity_id: 'part-substitution-assessment',
|
||||
workflow_id: null,
|
||||
origin: 'ops',
|
||||
owner_id: 's-1',
|
||||
title: 'W05',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
input_data: { customerCode: 'CU-0142' },
|
||||
used_steps: '3',
|
||||
// pg 的 timestamptz 在 raw execute 这一路回文本(实测),也可能是 Date —— 两种都要归一成 ISO
|
||||
started_at: '2026-08-27 16:55:05.123+08',
|
||||
finished_at: new Date('2026-08-27T08:55:14.383Z'),
|
||||
};
|
||||
|
||||
describe('createActivityOpsStore', () => {
|
||||
it('listTasks:seq → INS-n、时间戳归一成 ISO、jsonb 取成对象、数字列转数', async () => {
|
||||
const db = fakeDb([RAW_TASK]);
|
||||
const rows = await createActivityOpsStore(db).listTasks('part-substitution-assessment');
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
id: 't-1',
|
||||
no: 'INS-17',
|
||||
activityId: 'part-substitution-assessment',
|
||||
workflowId: null,
|
||||
origin: 'ops',
|
||||
ownerId: 's-1',
|
||||
title: 'W05',
|
||||
runtime: 'running',
|
||||
terminal: null,
|
||||
reason: null,
|
||||
envelope: { packageVersion: '1.5.0' },
|
||||
data: { customerCode: 'CU-0142' },
|
||||
usedSteps: 3,
|
||||
startedAt: '2026-08-27T08:55:05.123Z',
|
||||
finishedAt: '2026-08-27T08:55:14.383Z',
|
||||
},
|
||||
]);
|
||||
expect(db.calls[0]).toContain('order by started_at desc, seq desc');
|
||||
expect(db.calls[0]).toContain('activity_id = ');
|
||||
});
|
||||
|
||||
it('latestReleased 只看已放行的、按放行时刻倒序取一条;没有回 undefined', async () => {
|
||||
const db = fakeDb([RAW_TASK]);
|
||||
const one = await createActivityOpsStore(db).latestReleased('part-substitution-assessment');
|
||||
expect(one?.no).toBe('INS-17');
|
||||
expect(db.calls[0]).toContain("terminal = 'released'");
|
||||
expect(db.calls[0]).toContain('order by finished_at desc nulls last, started_at desc limit 1');
|
||||
expect(await createActivityOpsStore(fakeDb([])).latestReleased('x')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getTask 认活动 + 任务两把钥匙;没命中回 undefined', async () => {
|
||||
const db = fakeDb([]);
|
||||
expect(await createActivityOpsStore(db).getTask('a', 't')).toBeUndefined();
|
||||
expect(db.calls[0]).toContain('activity_id = ');
|
||||
expect(db.calls[0]).toContain('and id = ');
|
||||
});
|
||||
|
||||
it('countByActivity 按活动数;profilesOf 按活动键映射', async () => {
|
||||
const db = fakeDb([{ activity_id: 'a', n: '2' }, { activity_id: 'b', n: 1 }], [{ activity_id: 'a', owner_id: null, description: null, ico: 'target', hue: 'blue' }]);
|
||||
const store = createActivityOpsStore(db);
|
||||
expect([...(await store.countByActivity()).entries()]).toEqual([['a', 2], ['b', 1]]);
|
||||
const profiles = await store.profilesOf();
|
||||
expect(profiles.get('a')).toEqual({ activityId: 'a', ownerId: null, description: '', ico: 'target', hue: 'blue' });
|
||||
});
|
||||
|
||||
it('claimLaunch:一句 SQL 判 + 占(on conflict do nothing returning);插不进去就读回那一行', async () => {
|
||||
const claimed = fakeDb([{ key: 'k' }]);
|
||||
expect(await createActivityOpsStore(claimed).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toBe('claimed');
|
||||
expect(claimed.calls[0]).toContain('on conflict (key) do nothing');
|
||||
expect(claimed.calls[0]).toContain('returning key');
|
||||
expect(claimed.calls).toHaveLength(1);
|
||||
|
||||
const taken = fakeDb([], [{ key: 'k', activity_id: 'a', owner_id: 'o', launched_by: 'x', task_id: 't-9', claimed_at: new Date('2026-08-27T08:00:00.000Z') }]);
|
||||
expect(await createActivityOpsStore(taken).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toEqual({
|
||||
key: 'k',
|
||||
activityId: 'a',
|
||||
ownerId: 'o',
|
||||
launchedBy: 'x',
|
||||
taskId: 't-9',
|
||||
claimedAt: '2026-08-27T08:00:00.000Z',
|
||||
});
|
||||
expect(taken.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reclaimLaunch 只占「单没建成且已过期」的键;bindLaunch 落 task_id;upsertProfile 冲突即更新', async () => {
|
||||
const db = fakeDb([{ key: 'k' }], [], []);
|
||||
const store = createActivityOpsStore(db);
|
||||
expect(await store.reclaimLaunch('k', 60_000, 'o', 'me')).toBe(true);
|
||||
expect(db.calls[0]).toContain('task_id is null');
|
||||
expect(db.calls[0]).toContain("milliseconds')::interval");
|
||||
await store.bindLaunch('k', 't-1');
|
||||
expect(db.calls[1]).toContain('set task_id = ');
|
||||
await store.upsertProfile({ activityId: 'a', ownerId: null, description: 'd', ico: null, hue: null });
|
||||
expect(db.calls[2]).toContain('on conflict (activity_id) do update');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 清单一句话与检索 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstanceSummary } from '@contracts';
|
||||
import { staffIdSchema } from '@contracts';
|
||||
import { fieldSpecsOf } from '../activity-ops/launch-params.ts';
|
||||
import { applyInstanceFilters, clip, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from '../activity-ops/summaries.ts';
|
||||
|
||||
const specs = fieldSpecsOf({
|
||||
properties: {
|
||||
customerCode: { type: 'string' },
|
||||
requestDescription: { type: 'string', 'x-control': 'textarea' },
|
||||
datasheets: { type: 'array', format: 'file' },
|
||||
},
|
||||
});
|
||||
|
||||
describe('inputSummaryOf / inputFilesOf / outputSummaryOf', () => {
|
||||
it('textarea 那格优先;没有就最长的字符串;再没有退回来件主题;文件字段不算', () => {
|
||||
expect(inputSummaryOf({ customerCode: 'CU-0142', requestDescription: ' 工业电源项目\n在用 LM2596S-5.0 ' }, specs)).toBe('工业电源项目 在用 LM2596S-5.0');
|
||||
expect(inputSummaryOf({ customerCode: 'CU-0142', datasheets: [{ ref: 'r', name: 'a-very-long-file-name.pdf' }] }, specs)).toBe('CU-0142');
|
||||
expect(inputSummaryOf({}, specs, 'RE: 询价')).toBe('RE: 询价');
|
||||
expect(inputSummaryOf({}, specs)).toBe('');
|
||||
expect(clip('x'.repeat(100))).toHaveLength(80);
|
||||
expect(clip('x'.repeat(100)).endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('文件只认带 ref 的;index 跨字段连续', () => {
|
||||
const files = inputFilesOf({ datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }, { name: 'no-ref' }, 'junk'] }, specs);
|
||||
expect(files).toEqual([{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }]);
|
||||
});
|
||||
|
||||
it('产出:有文件摆文件名;没有摆发口;都没有 null', () => {
|
||||
expect(outputSummaryOf(['报告.docx', '对照表.xlsx'], ['x'])).toBe('报告.docx、对照表.xlsx');
|
||||
expect(outputSummaryOf([], ['assessment-released'])).toBe('已发口:assessment-released');
|
||||
expect(outputSummaryOf([], [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const owner = { id: 's-1', name: '柴浩' };
|
||||
const mk = (id: string, no: string, state: InstanceSummary['state'], startedAt: string, ownerId = 's-1'): InstanceSummary => ({
|
||||
id,
|
||||
no,
|
||||
state,
|
||||
owner: { id: staffIdSchema.parse(ownerId), name: owner.name },
|
||||
input: 'x',
|
||||
output: null,
|
||||
startedAt,
|
||||
});
|
||||
const items = [
|
||||
mk('t-aaa', 'INS-24', 'running', '2026-08-25T02:12:00.000Z'),
|
||||
mk('t-bbb', 'INS-17', 'completed', '2026-08-24T09:03:00.000Z', 's-2'),
|
||||
mk('t-ccc', 'INS-9', 'failed', '2026-08-20T01:00:00.000Z'),
|
||||
];
|
||||
|
||||
describe('applyInstanceFilters / statsOf / pageOf', () => {
|
||||
it('实例号模糊、负责人精确、状态精确、日期段含两端整日(本地时区)', () => {
|
||||
expect(applyInstanceFilters(items, { id: 'ins-2' }).map((i) => i.no)).toEqual(['INS-24']);
|
||||
expect(applyInstanceFilters(items, { id: 'bbb' }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { ownerId: 's-2' }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { state: 'failed' }).map((i) => i.no)).toEqual(['INS-9']);
|
||||
const day = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z'), startedTo: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-17']);
|
||||
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
|
||||
expect(applyInstanceFilters(items, { startedTo: day('2026-08-20T01:00:00.000Z') }).map((i) => i.no)).toEqual(['INS-9']);
|
||||
expect(applyInstanceFilters(items, {})).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('统计五键齐;分页越界给空', () => {
|
||||
expect(statsOf(items)).toEqual({ total: 3, byState: { pending: 0, running: 1, completed: 1, rejected: 0, failed: 1 } });
|
||||
expect(pageOf(items, 1, 2).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
|
||||
expect(pageOf(items, 2, 2).map((i) => i.no)).toEqual(['INS-9']);
|
||||
expect(pageOf(items, 3, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 沙箱视图出口:模版原文不改,只在 </body> 前注入报高脚本 */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
|
||||
import { injectHeightScript } from '../activity-ops/view-html.ts';
|
||||
|
||||
describe('injectHeightScript', () => {
|
||||
it('插在 </body> 之前;没有 body 就追加;原文一字不改', () => {
|
||||
const html = '<!doctype html><html><body><h1>一览</h1></body></html>';
|
||||
const out = injectHeightScript(html);
|
||||
expect(out.startsWith('<!doctype html><html><body><h1>一览</h1>')).toBe(true);
|
||||
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('</body>'));
|
||||
expect(out).toContain(ACTIVITY_VIEW_HEIGHT_MESSAGE);
|
||||
expect(out).toContain("postMessage({type:T,height:h()},'*')");
|
||||
const bare = injectHeightScript('<p>x</p>');
|
||||
expect(bare.startsWith('<p>x</p>')).toBe(true);
|
||||
expect(bare).toContain('<script>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 沙箱看板的取数与作用域:声明怎么求值、行集怎么摊平 —— 零 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('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 活动模块路由的归属:11 条全部进契约、注册表建得起来(与设计端的 activity-packages 互不撞),全归运营中心。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as contracts from '@contracts';
|
||||
import { ACTIVITY_OPS_ROUTES } from '@contracts';
|
||||
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
|
||||
|
||||
describe('活动模块路由契约', () => {
|
||||
it('11 条全在契约命名空间里,注册表能建起来', () => {
|
||||
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||
expect(ACTIVITY_OPS_ROUTES).toHaveLength(11);
|
||||
for (const route of ACTIVITY_OPS_ROUTES) expect(registry.has(route.method, route.path), route.path).toBe(true);
|
||||
});
|
||||
|
||||
it('全归 ops;设计端活动库让位到 activity-packages 后仍归 design', () => {
|
||||
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
|
||||
for (const route of ACTIVITY_OPS_ROUTES) expect(route.access).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activities')).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activity-packages')).toBe('design');
|
||||
expect(registry.lookup('POST', '/api/gov/activity-packages')).toBe('design');
|
||||
expect(registry.lookup('GET', '/api/gov/activities/part-substitution-assessment/view')).toBe('ops');
|
||||
expect(registry.lookup('POST', '/api/gov/activities/part-substitution-assessment/instances/t-1/terminate')).toBe('ops');
|
||||
expect(registry.lookup('GET', '/api/gov/activities/x/instances/t-1/files/output/0')).toBe('ops');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 活动模块路由:zod 校验 → 用例 → 结果翻状态码。用例是替身(用例本身另有测试),不挂鉴权中间件,
|
||||
* 身份由测试自己塞进上下文(终止 / 启动要知道是谁按的)。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstanceDetail, Principal } from '@contracts';
|
||||
import { positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { registerActivityRoutes } from '../routes/activities.ts';
|
||||
import type { ActivityOps } from '../activity-ops/service.ts';
|
||||
|
||||
const W05 = 'part-substitution-assessment';
|
||||
const owner = { id: staffIdSchema.parse('s-1'), name: '柴浩' };
|
||||
const detail: InstanceDetail = {
|
||||
id: 't-1',
|
||||
no: 'INS-7',
|
||||
state: 'running',
|
||||
owner,
|
||||
output: { files: [] },
|
||||
startedAt: '2026-08-27T02:00:00.000Z',
|
||||
activityId: W05,
|
||||
llm: 'deepseek-v4-flash',
|
||||
endedAt: null,
|
||||
durationMs: null,
|
||||
reason: null,
|
||||
input: { content: 'x', source: { kind: 'manual', label: '手动启动 · 柴浩' }, submittedAt: '2026-08-27T02:00:00.000Z', files: [] },
|
||||
steps: [],
|
||||
};
|
||||
const activity = { id: W05, name: 'W05', biz: '需求分析', status: 'active' as const, version: '1.5.0', owner, llm: 'm', createdAt: 'c', desc: '', view: null };
|
||||
|
||||
function fakeOps(over: Partial<ActivityOps> = {}): ActivityOps {
|
||||
return {
|
||||
listActivities: async () => [{ ...activity, instanceCount: 1 }],
|
||||
getActivity: async (id) => (id === W05 ? activity : undefined),
|
||||
viewHtml: async (id) => (id === W05 ? { kind: 'html', html: '<html><body>一览<script>x</script></body></html>' } : { kind: 'no-view' }),
|
||||
listInstances: async (id, q) => (id === W05 ? { stats: { total: 1, byState: { pending: 0, running: 1, completed: 0, rejected: 0, failed: 0 } }, total: 1, page: q.page, size: q.size, items: [] } : undefined),
|
||||
launchParams: async (id) => (id === W05 ? { activityId: id, version: '1.5.0', params: [] } : undefined),
|
||||
launch: async (id, input) => {
|
||||
if (id !== W05) return { kind: 'not-found' };
|
||||
if (input.idempotencyKey === 'busy') return { kind: 'busy', message: '正在创建' };
|
||||
if (input.idempotencyKey === 'bad') return { kind: 'invalid', issues: [{ key: 'customerCode', message: '必填' }] };
|
||||
if (input.idempotencyKey === 'off') return { kind: 'disabled' };
|
||||
if (input.idempotencyKey === 'owner') return { kind: 'owner-invalid', message: '责任人不在名册里' };
|
||||
return { kind: 'ok', detail, created: true };
|
||||
},
|
||||
instanceDetail: async (id, iid) => (id === W05 && iid === 't-1' ? detail : undefined),
|
||||
instanceFile: async (id, iid, io, index) =>
|
||||
id === W05 && iid === 't-1' && io === 'input' && index === 0 ? { kind: 'file', name: '报 告.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : iid === 't-1' ? { kind: 'no-file' } : { kind: 'no-instance' },
|
||||
setEnabled: async (id, enabled) => (id === W05 ? { ...activity, status: enabled ? 'active' : 'disabled' } : undefined),
|
||||
terminate: async (id, iid) => (id !== W05 || iid === 'nope' ? { kind: 'not-found' } : iid === 't-done' ? { kind: 'terminal' } : { kind: 'ok', detail: { ...detail, state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } } }),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function harness(opts: { anonymous?: boolean } = {}) {
|
||||
const app = new Hono<AppEnv>();
|
||||
const principal: Principal = {
|
||||
staff: { id: owner.id, account: 'chaihao', name: owner.name, positionId: positionIdSchema.parse('pm'), state: 'active', roles: [], kind: 'person' },
|
||||
position: { id: positionIdSchema.parse('pm'), code: 'PM', name: '项目经理', note: '' },
|
||||
roles: [],
|
||||
ends: ['ops'],
|
||||
};
|
||||
if (!opts.anonymous) {
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('principal', principal);
|
||||
await next();
|
||||
});
|
||||
}
|
||||
registerActivityRoutes(app, { activityOps: fakeOps() });
|
||||
const json = (method: string, path: string, body?: unknown) =>
|
||||
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { app, json };
|
||||
}
|
||||
|
||||
describe('活动路由', () => {
|
||||
it('L1 / L2:清单按 status 过滤入参校验;详情 404;视图回 text/html', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('GET', '/api/gov/activities')).json()).toMatchObject({ activities: [{ id: W05, instanceCount: 1 }] });
|
||||
expect((await h.json('GET', '/api/gov/activities?status=paused')).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}`)).status).toBe(200);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope')).status).toBe(404);
|
||||
const view = await h.json('GET', `/api/gov/activities/${W05}/view`);
|
||||
expect(view.status).toBe(200);
|
||||
expect(view.headers.get('content-type')).toContain('text/html');
|
||||
expect(view.headers.get('cache-control')).toBe('no-store');
|
||||
expect(await view.text()).toContain('一览');
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/view')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('实例清单:查询默认值与上限;启动参数表 404', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances`)).json()).toMatchObject({ page: 1, size: 8 });
|
||||
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances?page=2&size=20&state=running`)).json()).toMatchObject({ page: 2, size: 20 });
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?size=99`)).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?startedFrom=8-1`)).status).toBe(400);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/instances')).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/launch-params`)).status).toBe(200);
|
||||
expect((await h.json('GET', '/api/gov/activities/nope/launch-params')).status).toBe(404);
|
||||
});
|
||||
|
||||
it('启动:400 入参 / 412 禁用 / 409 在途 / 404 / 401 没身份 / 200 详情', async () => {
|
||||
const h = harness();
|
||||
const body = { idempotencyKey: 'k', ownerId: 's-1', params: { customerCode: 'CU-1' } };
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ownerId: 's-1' })).status).toBe(400);
|
||||
const bad = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'bad' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(await bad.json()).toMatchObject({ code: 'validation-failed', detail: [{ key: 'customerCode' }] });
|
||||
const owner = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'owner' });
|
||||
expect(owner.status).toBe(400);
|
||||
expect(await owner.json()).toMatchObject({ detail: [{ key: 'ownerId' }] });
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'off' })).status).toBe(412);
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'busy' })).status).toBe(409);
|
||||
expect((await h.json('POST', '/api/gov/activities/nope/instances', body)).status).toBe(404);
|
||||
const ok = await h.json('POST', `/api/gov/activities/${W05}/instances`, body);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.json()).toMatchObject({ id: 't-1', no: 'INS-7' });
|
||||
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances`, body)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('L3:详情 404;文件默认 JSON+base64、raw=1 直出字节带 RFC 5987 文件名;序号不合法 400', async () => {
|
||||
const h = harness();
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1`)).status).toBe(200);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope`)).status).toBe(404);
|
||||
const json = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0`);
|
||||
expect(await json.json()).toEqual({ name: '报 告.pdf', contentType: 'application/pdf', dataBase64: Buffer.from([1, 2, 3]).toString('base64') });
|
||||
const raw = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0?raw=1`);
|
||||
expect(raw.status).toBe(200);
|
||||
expect(raw.headers.get('content-type')).toBe('application/pdf');
|
||||
expect(raw.headers.get('content-disposition')).toBe(`attachment; filename*=UTF-8''${encodeURIComponent('报 告.pdf')}`);
|
||||
expect(new Uint8Array(await raw.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/output/0`)).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope/files/input/0`)).status).toBe(404);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/both/0`)).status).toBe(400);
|
||||
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/x`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('禁用 / 激活回活动详情;终止 412 已终态、404、401 没身份', async () => {
|
||||
const h = harness();
|
||||
expect(await (await h.json('POST', `/api/gov/activities/${W05}/disable`)).json()).toMatchObject({ status: 'disabled' });
|
||||
expect(await (await h.json('POST', `/api/gov/activities/${W05}/activate`)).json()).toMatchObject({ status: 'active' });
|
||||
expect((await h.json('POST', '/api/gov/activities/nope/disable')).status).toBe(404);
|
||||
const done = await h.json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`);
|
||||
expect(done.status).toBe(200);
|
||||
expect(await done.json()).toMatchObject({ state: 'failed', reason: { kind: 'terminated' } });
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/t-done/terminate`)).status).toBe(412);
|
||||
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/nope/terminate`)).status).toBe(404);
|
||||
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user