/** flow 步序:声明校验 / 失败走向 / 兜底出参 —— domain 测试零 mock(AM 12.3) */ import { describe, expect, it } from 'vitest'; import { flowStepSchema } from '@contracts'; import type { FlowStep } from '@contracts'; import { checkDeclaration, fallbackFields, failureAction, initialPosition, lastStepIsAcceptance, markStep, parallelGroupComplete, } from '../internal/domain/step-sequence.ts'; const llmStep = (over: Record = {}): FlowStep => flowStepSchema.parse({ kind: 'llm', name: 'S3', label: 'S3 逐条解析归一', input: { rfqRef: 'rfqRef' }, output: { domain: 'quote_intake', object: 'record', fields: { pn: 'pn', brand: 'brand', eau: 'eau' } }, model: 'quote-extractor', template: '解析 {{rfqRef}}', outputSchema: { type: 'object' }, failure: { retry: { times: 2, backoff: 'PT30S' }, fallback: { kind: 'human', workOrder: 'execution' } }, ...over, }); describe('人兜底:出参契约属于步,不属于执行者', () => { it('兜底卡问的字段 = 这一步声明的出参 —— 人填完能落进作用域', () => { // 若只给一个笼统的「结果」框,下游按 output.fields 取值会取空,在下一步崩 expect(fallbackFields(llmStep())).toEqual(['pn', 'brand', 'eau']); }); it('行标识也要问 —— 主键取不到就写不进台账', () => { const step = flowStepSchema.parse({ kind: 'llm', name: 'S1', label: '生成正式报价单', input: {}, model: 'm', template: 't', outputSchema: { type: 'object' }, output: { domain: 'q', object: 'formal_quote', key: { quote_no: 'quoteNo', pn: 'pn' }, fields: { unit_price: 'unitPrice' }, }, failure: { retry: { times: 0, backoff: 'PT1S' }, fallback: { kind: 'human', workOrder: 'execution' } }, }); // pn 上游已给,不必再问;quoteNo 没人给过,必须问 expect(fallbackFields(step, { pn: 'LM358DR' })).toEqual(['quoteNo', 'unitPrice']); expect(fallbackFields(step, {})).toEqual(['quoteNo', 'pn', 'unitPrice']); }); it('无出参声明的步不凭空造字段', () => { const step = flowStepSchema.parse({ kind: 'llm', name: 'S9', label: '只说不写的一步', input: {}, model: 'm', template: 't', outputSchema: { type: 'object' }, failure: { retry: { times: 0, backoff: 'PT1S' }, fallback: { kind: 'blocked' } }, }); expect(fallbackFields(step)).toEqual([]); }); }); describe('失败走向声明给定,不临场发明', () => { it('重试次数未用尽 → retry', () => { expect(failureAction(llmStep(), 1).kind).toBe('retry'); }); it('重试用尽 → 按声明的 fallback 转人', () => { const action = failureAction(llmStep(), 2); expect(action.kind).toBe('human'); }); it('声明 blocked 的步不转人 —— 走向是声明的,不是猜的', () => { const step = llmStep({ failure: { retry: { times: 0, backoff: 'PT1S' }, fallback: { kind: 'blocked' } } }); expect(failureAction(step, 0).kind).toBe('blocked'); }); }); describe('步序位置', () => { it('初始位置每步待跑', () => { const position = initialPosition([llmStep(), llmStep({ name: 'S4' })]); expect(position.index).toBe(0); expect(position.steps.map((s) => s.state)).toEqual(['pending', 'pending']); }); it('markStep 只改那一步,其余原样', () => { const position = initialPosition([llmStep(), llmStep({ name: 'S4' })]); const next = markStep(position, 1, { state: 'done' }); expect(next.steps.map((s) => s.state)).toEqual(['pending', 'done']); }); it('并行组要全完成才算完成', () => { const position = markStep(initialPosition([llmStep(), llmStep({ name: 'S4' })]), 0, { state: 'done' }); expect(parallelGroupComplete(position, ['S3', 'S4'])).toBe(false); expect(parallelGroupComplete(markStep(position, 1, { state: 'done' }), ['S3', 'S4'])).toBe(true); }); }); describe('声明校验', () => { it('步名重复即拒', () => { expect(checkDeclaration([llmStep(), llmStep()]).length).toBeGreaterThan(0); }); it('末步是验收工单可辨', () => { const acceptance: FlowStep = flowStepSchema.parse({ kind: 'human', name: 'S6', label: '验收', input: {}, workOrder: 'acceptance', card: {}, deadline: 'P1D', failure: { retry: { times: 0, backoff: 'PT1S' }, fallback: { kind: 'blocked' } }, }); expect(lastStepIsAcceptance([llmStep(), acceptance])).toBe(true); expect(lastStepIsAcceptance([llmStep()])).toBe(false); }); });