diff --git a/cypher-executor/tests/branch-hint-response.test.ts b/cypher-executor/tests/branch-hint-response.test.ts new file mode 100644 index 0000000..44b2691 --- /dev/null +++ b/cypher-executor/tests/branch-hint-response.test.ts @@ -0,0 +1,68 @@ +/** + * 逐顆查詢的回應要自我說明分支用法(SDD workflow-discovery 3.11;總管 08-01 抽驗第 3 點) + * + * 判準(leo/總管一致):**AI 只看那一顆的回應,就知道怎麼接下一步**—— + * 不必回頭讀 skill、不必猜。看得到分支說明才算數。 + * + * 取證背景(08-01 prod):逐顆查 if_control 只回 + * status/componentId/type/source/input_schema{condition,input}/success_rate/stability + * ⇒ **沒有任何欄位說明分支怎麼接** ⇒ 走 n8n 式逐顆查的 AI 只好寫 code。 + * + * 本檔直接驗 `branchHintFor()`(回應裡那個欄位的來源),並把 AI 實際會看到的內容印出來。 + */ +import { describe, it, expect } from 'vitest'; +import { branchHintFor } from '../src/lib/branch-hints'; + +describe('三顆分支零件的查詢回應自帶用法(AI 看一眼就知道怎麼接)', () => { + for (const id of ['if_control', 'switch', 'try_catch']) { + it(`${id}:回應含 branch_field/branches/edge_types/usage/example`, () => { + const hint = branchHintFor(id); + expect(hint).toBeDefined(); + + // 這一顆會輸出哪個欄位當分支標籤 + expect(hint!.branch_field).toBe('data.branch'); + // 接下游要用哪些邊型 + expect(hint!.edge_types.length).toBeGreaterThan(0); + // 一行說明 + 可照抄範例(缺任一個,AI 都得自己猜) + expect(hint!.usage.length).toBeGreaterThan(0); + expect(hint!.example.length).toBeGreaterThan(0); + + // eslint-disable-next-line no-console + console.log( + `\n──────── 逐顆查 ${id} 時,AI 會看到的 branch_hint ────────\n` + + JSON.stringify(hint, null, 2), + ); + }); + } + + it('if_control 明說 ON_TRUE/ON_FALSE 兩條邊', () => { + const h = branchHintFor('if_control')!; + expect(h.edge_types).toContain('ON_TRUE'); + expect(h.edge_types).toContain('ON_FALSE'); + expect(h.branches).toEqual(['true', 'false']); + // 明說「不需要自己寫 code 判斷」——這句是防腹語術的關鍵 + expect(h.usage).toContain('不需要自己寫 code'); + }); + + it('switch 明說用 ON_BRANCH 並在邊上標 case 名,且 default 不需特別邊型', () => { + const h = branchHintFor('switch')!; + expect(h.edge_types).toContain('ON_BRANCH'); + expect(h.usage).toContain('ON_BRANCH'); + expect(h.usage).toContain('default_branch'); + // branches 是動態的(由 cases 決定),要誠實說明而非給死清單 + expect(typeof h.branches).toBe('string'); + }); + + it('try_catch 明說 try/catch 兩條標籤,錯誤處理不必寫 code', () => { + const h = branchHintFor('try_catch')!; + expect(h.branches).toEqual(['try', 'catch']); + expect(h.edge_types).toContain('ON_BRANCH'); + expect(h.usage).toContain('不需要寫 code'); + }); + + it('不分岔的零件沒有 branch_hint(不加噪音)', () => { + expect(branchHintFor('http_request')).toBeUndefined(); + expect(branchHintFor('code')).toBeUndefined(); + expect(branchHintFor(undefined)).toBeUndefined(); + }); +}); diff --git a/cypher-executor/tests/branch-real-components.test.ts b/cypher-executor/tests/branch-real-components.test.ts new file mode 100644 index 0000000..3c2499b --- /dev/null +++ b/cypher-executor/tests/branch-real-components.test.ts @@ -0,0 +1,147 @@ +/** + * 三型分支零件「真的接上引擎」的實測(SDD workflow-discovery 3.11) + * + * 為什麼要另立這一支(總管 08-01 抽驗要求,正確的要求): + * conditional-edges.test.ts 是用 Input 節點**手餵分支形狀**測引擎走邊邏輯, + * 那證明的是「引擎依標籤選邊」,**沒有證明「真零件吐出來的標籤真的對得上」**。 + * leo 特別點名 switch/try_catch,且 `ON_CASE`/`ON_CATCH` grep=0 + * ⇒ 必須排除「機制通用所以理論上支援」這種推論。 + * + * 本檔的 given 全部是**真 WASM 零件的實跑輸出**(wasmtime 執行 .component-builds/*.wasm + * 抓回來的原文,非杜撰),再送進引擎驗證走對邊。 + * + * 真零件實跑指令(可復驗): + * cd .component-builds + * echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm + * echo '{"value":"pending","cases":[...],"default_branch":"branch_default"}' | wasmtime switch/component.wasm + * echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm + */ +import { SELF } from 'cloudflare:test'; +import { describe, it, expect } from 'vitest'; + +async function run(graph: unknown) { + const res = await SELF.fetch('http://localhost/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ graph, context: {} }), + }); + const body = (await res.json()) as { + success: boolean; + trace?: Array<{ nodeId: string }>; + }; + return { body, visited: (body.trace ?? []).map(t => t.nodeId) }; +} + +/** 真零件輸出 → 當作上游節點的 output 餵進圖 */ +function graphWith(realOutput: unknown, edges: Array>, extraNodes: string[]) { + return { + id: 'real-branch', + name: '真零件輸出走邊', + nodes: [ + { id: 'ctrl', type: 'Input', data: realOutput }, + ...extraNodes.map(id => ({ + id, type: 'Component', componentId: 'comp_uppercase', data: { text: id }, + })), + ], + edges, + }; +} + +describe('if_control 真輸出 → 引擎走對邊', () => { + // 真跑:echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm + const REAL_TRUE = { data: { branch: 'true', result: true }, success: true }; + // 真跑:input.status = "inactive" + const REAL_FALSE = { data: { branch: 'false', result: false }, success: true }; + + const edges = [ + { from: 'ctrl', to: 'yes', type: 'ON_TRUE' }, + { from: 'ctrl', to: 'no', type: 'ON_FALSE' }, + ]; + + it('條件成立(真輸出 branch="true")→ 走 ON_TRUE', async () => { + const { body, visited } = await run(graphWith(REAL_TRUE, edges, ['yes', 'no'])); + expect(body.success).toBe(true); + expect(visited).toContain('yes'); + expect(visited).not.toContain('no'); + }); + + it('條件不成立(真輸出 branch="false")→ 走 ON_FALSE', async () => { + const { visited } = await run(graphWith(REAL_FALSE, edges, ['yes', 'no'])); + expect(visited).toContain('no'); + expect(visited).not.toContain('yes'); + }); +}); + +describe('switch 真輸出 → 引擎走對邊(多路+default,leo:「switch 更嚴重」)', () => { + // 真跑(三個 case + default_branch): + // value="active" → {"data":{"branch":"branch_active"},"success":true} + // value="pending" → {"data":{"branch":"branch_pending"},"success":true} + // value="zzz" → {"data":{"branch":"branch_default"},"success":true} + const REAL_CASE1 = { data: { branch: 'branch_active' }, success: true }; + const REAL_CASE3 = { data: { branch: 'branch_pending' }, success: true }; + const REAL_DEFAULT = { data: { branch: 'branch_default' }, success: true }; + + const targets = ['p_active', 'p_inactive', 'p_pending', 'p_default']; + const edges = [ + { from: 'ctrl', to: 'p_active', type: 'ON_BRANCH', branch: 'branch_active' }, + { from: 'ctrl', to: 'p_inactive', type: 'ON_BRANCH', branch: 'branch_inactive' }, + { from: 'ctrl', to: 'p_pending', type: 'ON_BRANCH', branch: 'branch_pending' }, + { from: 'ctrl', to: 'p_default', type: 'ON_BRANCH', branch: 'branch_default' }, + ]; + + it('第 1 條 case(真輸出 branch_active)→ 只走 p_active', async () => { + const { body, visited } = await run(graphWith(REAL_CASE1, edges, targets)); + expect(body.success).toBe(true); + expect(visited).toContain('p_active'); + expect(visited).not.toContain('p_inactive'); + expect(visited).not.toContain('p_pending'); + expect(visited).not.toContain('p_default'); + }); + + it('第 3 條 case(真輸出 branch_pending)→ 只走 p_pending(證明第 N 條路走得對)', async () => { + const { visited } = await run(graphWith(REAL_CASE3, edges, targets)); + expect(visited).toContain('p_pending'); + expect(visited).not.toContain('p_active'); + expect(visited).not.toContain('p_inactive'); + expect(visited).not.toContain('p_default'); + }); + + it('無匹配(真輸出 branch_default)→ 只走 p_default', async () => { + const { visited } = await run(graphWith(REAL_DEFAULT, edges, targets)); + expect(visited).toContain('p_default'); + expect(visited).not.toContain('p_active'); + expect(visited).not.toContain('p_pending'); + }); +}); + +describe('try_catch 真輸出 → 引擎走對邊(ok/catch 兩路都驗)', () => { + // 真跑:echo '{"result":{"value":42},"error":""}' | wasmtime try_catch/component.wasm + const REAL_TRY = { data: { branch: 'try', result: { value: 42 } }, success: true }; + // 真跑:echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm + const REAL_CATCH = { data: { branch: 'catch', error: 'boom' }, success: true }; + + const edges = [ + { from: 'ctrl', to: 'normal', type: 'ON_BRANCH', branch: 'try' }, + { from: 'ctrl', to: 'rescue', type: 'ON_BRANCH', branch: 'catch' }, + ]; + + it('成功(真輸出 branch="try")→ 走 normal,不走 rescue', async () => { + const { body, visited } = await run(graphWith(REAL_TRY, edges, ['normal', 'rescue'])); + expect(body.success).toBe(true); + expect(visited).toContain('normal'); + expect(visited).not.toContain('rescue'); + }); + + it('失敗(真輸出 branch="catch")→ 走 rescue,不走 normal', async () => { + const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue'])); + expect(visited).toContain('rescue'); + expect(visited).not.toContain('normal'); + }); + + it('try_catch 的 catch 路承接了「上游失敗」——不必寫 code try 一遍', async () => { + // 這是 leo 點名 try_catch 的原因:schema 用文字寫「走 catch 分支」但機器層沒有那條路。 + // 現在有了:catch 標籤 → ON_BRANCH branch="catch" → 補救節點。 + const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue'])); + expect(visited).toContain('rescue'); + }); +});