Files
Arcrun/cypher-executor/tests/conditional-edges.test.ts
uncle6me-web 323ccc8475 步驟5 缺口①:引擎通用具名分支邊(ON_TRUE/ON_FALSE/ON_BRANCH)=Arcrun#5 根治
問題(「全變成 code」的根):if_control 回 {result, branch} 卻沒有邊讀得懂它,
圖的邊只有 ON_SUCCESS/IF/FOREACH ⇒ 就算照規矩用零件,仍得寫 code 判斷走哪條。
leo 08-01 追問「你改了 if,有改 switch 嗎?switch 更嚴重」——確認 switch(N 路)
與 try_catch(try/catch) 同病,故一次做成通用機制,不留「為 switch 再改一次」的債。

設計:三顆流程控制零件的 output_schema 本來就都收斂到同一形狀 data.branch: string
(if_control→true/false;switch→case 名或 default_branch;try_catch→try/catch)
⇒ 引擎只需「依標籤選邊」一個機制 ON_BRANCH;ON_TRUE/ON_FALSE 是布林路的語法糖,
底層同一條路(測試已證等價)。讀不出分支=不走(誠實,不亂挑一條)。

- types/schemas/constants:新增三邊型(純新增,既有列舉不動)+ GraphEdge.branch
- graph-executor:readBranch() 依 data.branch → branch → data.result → result 四層相容
- 中文語意詞:成立時/為真時=ON_TRUE,不成立時/為假時/否則=ON_FALSE,BRANCH=ON_BRANCH

分支用法要「查得到」(leo 08-01:AI 可能像 n8n 那樣逐顆查、自己組圖):
新增 lib/branch-hints.ts,讓 if_control/switch/try_catch 的查詢回應自帶 branch_hint
(branch_field/branches/edge_types/usage/example)——只看這一顆的回應就知道怎麼接下一步,
不必回頭讀 skill。四條回應路徑全wire:catalog found/legacy 逐顆/步驟4 substitution/
target=component 名字搜尋(=n8n 式那條)。不分岔的零件不加此欄,避免噪音。

測試(先寫測試再改引擎,紅線要求):tests/conditional-edges.test.ts 16 項全綠
——if 兩路/switch 多路+default/try_catch 成功與失敗路/語法糖等價/
context 傳遞/同分支 fan-out/無匹配不走/混合邊時 PIPE 不受影響/schema 放行。
零變化保證:195 passed(前 179 +16 新),失敗數維持既有 9 筆未變(console/portal
HTML 資產未建置+executor 斷言字串漂移,皆與本次無關);tsc --noEmit 綠。
現存 workflow/example 用到新邊型=0 筆(grep 實查)⇒ 既有行為不可能被改動。

SDD: workflow-discovery task 3.11|CP: arcrun-usable 步驟 5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 16:26:26 +08:00

305 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 條件邊 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.tsPIPE/IF/ON_SUCCESS 原樣通過)。
*/
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
/** 送一張圖進 /execute,回 parsed JSON */
async function run(graph: unknown, context: Record<string, unknown> = {}) {
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<string, unknown>;
trace?: Array<{ nodeId: string }>;
error?: string;
},
};
}
/**
* 用 Input 節點直接餵出 if_control 形狀的 output{data:{result,branch}}),
* 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。
*/
function branchGraph(branch: 'true' | 'false', edges: Array<Record<string, unknown>>) {
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('條件邊的下游拿得到上游 contextpropagateCtx 一致)', 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_BRANCHswitch 具名分支)', () => {
/** 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-01switch 比 if 更嚴重)', () => {
/**
* 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string`
* if_control → "true" | "false"(布林兩路)
* switch → case 的 branch 名 | default_branchN 路)
* try_catch → "try" | "catch"(成功/失敗兩路)
* ⇒ 引擎只需要「依標籤選邊」這一個機制,不是為每顆零件開特例。
* ON_TRUE / ON_FALSE 只是 if 布林路的語法糖,底層與 ON_BRANCH 同一條路。
*/
async function branchTo(branch: string, edges: Array<Record<string, unknown>>) {
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_BRANCHschema 已放行)', 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);
});
});