/** * 條件邊 ON_TRUE / ON_FALSE / ON_BRANCH —— CP `arcrun-usable` 步驟 5 缺口① * SDD: workflow-discovery tasks 3.11 * * 為什麼要有這組測試(別刪): * `if_control` 零件回 `{success, data:{result, branch}}`,但引擎過去只有 * ON_SUCCESS / IF / FOREACH ⇒ 就算照規矩用 if_control,也只拿到布林值, * 還是得寫 code 判斷該走哪條路 ⇒ 這正是「全變成 code」的根(Arcrun#5)。 * * 本檔先寫測試再改引擎(引擎核心風險最高,紅線要求)。 * 既有邊行為的零變化迴歸另見 executor.test.ts(PIPE/IF/ON_SUCCESS 原樣通過)。 */ import { SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; /** 送一張圖進 /execute,回 parsed JSON */ async function run(graph: unknown, context: Record = {}) { const res = await SELF.fetch('http://localhost/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph, context }), }); return { status: res.status, body: (await res.json()) as { success: boolean; data: Record; trace?: Array<{ nodeId: string }>; error?: string; }, }; } /** * 用 Input 節點直接餵出 if_control 形狀的 output({data:{result,branch}}), * 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。 */ function branchGraph(branch: 'true' | 'false', edges: Array>) { return { id: `g-branch-${branch}`, name: '條件邊測試', nodes: [ // 模擬 if_control 的輸出形狀 { id: 'cond', type: 'Input', data: { success: true, data: { result: branch === 'true', branch } } }, { id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } }, { id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } }, ], edges, }; } describe('條件邊:ON_TRUE / ON_FALSE(缺口① Arcrun#5 根治)', () => { it('branch=true → 只走 ON_TRUE 那條,ON_FALSE 那條不執行', async () => { const { body } = await run( branchGraph('true', [ { from: 'cond', to: 'yes', type: 'ON_TRUE' }, { from: 'cond', to: 'no', type: 'ON_FALSE' }, ]), ); expect(body.success).toBe(true); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('yes'); expect(visited).not.toContain('no'); }); it('branch=false → 只走 ON_FALSE 那條,ON_TRUE 那條不執行', async () => { const { body } = await run( branchGraph('false', [ { from: 'cond', to: 'yes', type: 'ON_TRUE' }, { from: 'cond', to: 'no', type: 'ON_FALSE' }, ]), ); expect(body.success).toBe(true); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('no'); expect(visited).not.toContain('yes'); }); it('result 是布林但沒有 branch 欄位 → 仍judged得出(相容 {result:true} 形狀)', async () => { const graph = { id: 'g-bool-only', name: '只有 result', nodes: [ { id: 'cond', type: 'Input', data: { result: true } }, { id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } }, { id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } }, ], edges: [ { from: 'cond', to: 'yes', type: 'ON_TRUE' }, { from: 'cond', to: 'no', type: 'ON_FALSE' }, ], }; const { body } = await run(graph); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('yes'); expect(visited).not.toContain('no'); }); it('條件邊的下游拿得到上游 context(propagateCtx 一致)', async () => { const graph = { id: 'g-ctx', name: 'context 傳遞', nodes: [ { id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' }, carried: 'keep-me' } }, { id: 'yes', type: 'Component', componentId: 'comp_passthrough' }, ], edges: [{ from: 'cond', to: 'yes', type: 'ON_TRUE' }], }; const { body } = await run(graph); expect(body.success).toBe(true); expect(body.data.carried).toBe('keep-me'); }); it('兩條 ON_TRUE 並存 → 都走(同分支多下游是合法 fan-out)', async () => { const graph = { id: 'g-fanout', name: '同分支多下游', nodes: [ { id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' } } }, { id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } }, { id: 'b', type: 'Component', componentId: 'comp_uppercase', data: { text: 'b' } }, ], edges: [ { from: 'cond', to: 'a', type: 'ON_TRUE' }, { from: 'cond', to: 'b', type: 'ON_TRUE' }, ], }; const { body } = await run(graph); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('a'); expect(visited).toContain('b'); }); }); describe('條件邊:ON_BRANCH(switch 具名分支)', () => { /** switch 零件回 {success, data:{branch:"branch_a"}} */ function switchGraph(branch: string) { return { id: 'g-switch', name: 'switch 具名分支', nodes: [ { id: 'sw', type: 'Input', data: { success: true, data: { branch } } }, { id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } }, { id: 'z', type: 'Component', componentId: 'comp_uppercase', data: { text: 'z' } }, ], edges: [ { from: 'sw', to: 'a', type: 'ON_BRANCH', branch: 'branch_a' }, { from: 'sw', to: 'z', type: 'ON_BRANCH', branch: 'fallback' }, ], }; } it('branch=branch_a → 只走標 branch_a 的邊', async () => { const { body } = await run(switchGraph('branch_a')); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('a'); expect(visited).not.toContain('z'); }); it('branch=fallback → 只走標 fallback 的邊', async () => { const { body } = await run(switchGraph('fallback')); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('z'); expect(visited).not.toContain('a'); }); it('沒有任何邊匹配 → 誠實地不走(不亂挑一條,也不報錯)', async () => { const { body } = await run(switchGraph('no_such_branch')); expect(body.success).toBe(true); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).not.toContain('a'); expect(visited).not.toContain('z'); }); }); describe('通用具名分支涵蓋三型零件(leo 08-01:switch 比 if 更嚴重)', () => { /** * 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string`: * if_control → "true" | "false"(布林兩路) * switch → case 的 branch 名 | default_branch(N 路) * try_catch → "try" | "catch"(成功/失敗兩路) * ⇒ 引擎只需要「依標籤選邊」這一個機制,不是為每顆零件開特例。 * ON_TRUE / ON_FALSE 只是 if 布林路的語法糖,底層與 ON_BRANCH 同一條路。 */ async function branchTo(branch: string, edges: Array>) { return run({ id: `g-generic-${branch}`, name: '通用具名分支', nodes: [ { id: 'ctrl', type: 'Input', data: { success: true, data: { branch } } }, { id: 'p1', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p1' } }, { id: 'p2', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p2' } }, { id: 'p3', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p3' } }, ], edges, }); } const threeWay = [ { from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'branch_active' }, { from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'branch_inactive' }, { from: 'ctrl', to: 'p3', type: 'ON_BRANCH', branch: 'branch_default' }, ]; it('switch 多路:branch_active → 只走第一條,其餘兩條不走', async () => { const { body } = await branchTo('branch_active', threeWay); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('p1'); expect(visited).not.toContain('p2'); expect(visited).not.toContain('p3'); }); it('switch 多路:branch_inactive → 只走第二條', async () => { const { body } = await branchTo('branch_inactive', threeWay); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('p2'); expect(visited).not.toContain('p1'); expect(visited).not.toContain('p3'); }); it('switch default:無匹配 case 時零件回 default_branch → 走 default 那條', async () => { // 注意:挑 default 是 switch 零件內部的事(它回 default_branch 名); // 引擎這層看到的一律是「一個標籤」,故 default 不需要引擎特別處理。 const { body } = await branchTo('branch_default', threeWay); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('p3'); expect(visited).not.toContain('p1'); expect(visited).not.toContain('p2'); }); it('try_catch 成功路:branch=try → 走 try 邊,不走 catch 邊', async () => { const { body } = await branchTo('try', [ { from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' }, { from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' }, ]); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('p1'); expect(visited).not.toContain('p2'); }); it('try_catch 失敗路:branch=catch → 走 catch 邊,不走 try 邊', async () => { const { body } = await branchTo('catch', [ { from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' }, { from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' }, ]); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).toContain('p2'); expect(visited).not.toContain('p1'); }); it('ON_TRUE 與 ON_BRANCH branch="true" 等價(語法糖,底層同一條路)', async () => { const sugar = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_TRUE' }]); const raw = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'true' }]); const v1 = (sugar.body.trace ?? []).map(t => t.nodeId); const v2 = (raw.body.trace ?? []).map(t => t.nodeId); expect(v1).toEqual(v2); expect(v1).toContain('p1'); }); }); describe('零變化保證:新邊型不影響既有邊', () => { it('ON_TRUE 邊存在時,同圖的 PIPE 邊照常走', async () => { const graph = { id: 'g-mixed', name: '混合邊', nodes: [ { id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' }, count: 0 } }, { id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } }, { id: 'always', type: 'Component', componentId: 'comp_counter' }, ], edges: [ { from: 'cond', to: 'yes', type: 'ON_TRUE' }, { from: 'cond', to: 'always', type: 'PIPE' }, ], }; const { body } = await run(graph); const visited = (body.trace ?? []).map(t => t.nodeId); expect(visited).not.toContain('yes'); // 條件邊擋掉 expect(visited).toContain('always'); // PIPE 不受影響 }); it('/validate 接受 ON_TRUE / ON_FALSE / ON_BRANCH(schema 已放行)', async () => { const res = await SELF.fetch('http://localhost/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'g-validate', name: 'schema 驗證', nodes: [ { id: 'a', type: 'Input' }, { id: 'b', type: 'Output' }, { id: 'c', type: 'Output' }, ], edges: [ { from: 'a', to: 'b', type: 'ON_TRUE' }, { from: 'a', to: 'c', type: 'ON_FALSE' }, ], }), }); const data = (await res.json()) as { valid: boolean }; expect(res.status).toBe(200); expect(data.valid).toBe(true); }); });