This commit is contained in:
董宏凯 committed 2026-06-12 14:09:12 +08:00
1 parent 086c280e5d
commit 9de85591bc
1 file changed
+319
+319
View File
@@ -0,0 +1,319 @@
// src/roundtable-planning/lib/flow-graph.test.ts
import assert from "node:assert/strict";
import test from "node:test";
// src/roundtable-planning/lib/flow-graph.ts
var FLOW_NODE_TYPE_META = {
start: { label: "\u5F00\u59CB", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
end: { label: "\u7ED3\u675F", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
stage: { label: "\u9636\u6BB5", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" },
task: { label: "\u4EFB\u52A1", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" },
decision: { label: "\u5224\u65AD", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" },
risk: { label: "\u98CE\u9669", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" },
deliverable: { label: "\u4EA4\u4ED8\u7269", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" },
role: { label: "\u89D2\u8272", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" }
};
var VALID_TYPES = new Set(Object.keys(FLOW_NODE_TYPE_META));
var FLOW_FENCE_OPEN = "```flow-json";
function collectObjects(text, key) {
const keyIdx = text.indexOf(key);
if (keyIdx < 0) return [];
const arrStart = text.indexOf("[", keyIdx);
if (arrStart < 0) return [];
const out = [];
let depth = 0;
let objStart = -1;
let inStr = false;
let esc = false;
for (let i = arrStart + 1; i < text.length; i++) {
const ch = text[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') {
inStr = true;
continue;
}
if (ch === "{") {
if (depth === 0) objStart = i;
depth++;
continue;
}
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
try {
const parsed = JSON.parse(text.slice(objStart, i + 1));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
out.push(parsed);
}
} catch {
}
objStart = -1;
}
continue;
}
if (ch === "]" && depth === 0) break;
}
return out;
}
function asString(v) {
return typeof v === "string" ? v.trim() : "";
}
function toNode(raw) {
const id = asString(raw.id);
const label = asString(raw.label) || asString(raw.name);
if (!id || !label) return null;
const rawType = asString(raw.type).toLowerCase();
const type = VALID_TYPES.has(rawType) ? rawType : "task";
const detail = asString(raw.detail) || asString(raw.description) || void 0;
return { id, label, type, detail };
}
function edgesFromNode(raw, nodeId) {
const from = raw.from ?? raw.prev;
if (!Array.isArray(from)) return [];
const out = [];
for (const entry of from) {
if (typeof entry === "string" && entry.trim()) {
out.push({ from: entry.trim(), to: nodeId });
} else if (entry && typeof entry === "object") {
const o = entry;
const fromId = asString(o.id) || asString(o.from);
if (!fromId) continue;
const label = asString(o.label) || void 0;
out.push({ from: fromId, to: nodeId, label });
}
}
return out;
}
function toEdge(raw) {
const from = asString(raw.from) || asString(raw.source);
const to = asString(raw.to) || asString(raw.target);
if (!from || !to) return null;
const label = asString(raw.label) || void 0;
return { from, to, label };
}
function extractFlowGraph(text) {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return null;
let body = text.slice(start + FLOW_FENCE_OPEN.length);
const close = body.indexOf("```");
if (close >= 0) body = body.slice(0, close);
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
let title = "";
if (titleMatch) {
try {
title = JSON.parse(`"${titleMatch[1]}"`);
} catch {
title = titleMatch[1];
}
}
const rawNodes = collectObjects(body, '"nodes"');
const nodes = [];
const seen = /* @__PURE__ */ new Set();
const edges = [];
for (const raw of rawNodes) {
const node = toNode(raw);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
edges.push(...edgesFromNode(raw, node.id));
}
for (const raw of collectObjects(body, '"edges"')) {
const edge = toEdge(raw);
if (edge) edges.push(edge);
}
if (nodes.length === 0) return null;
const edgeKeys = /* @__PURE__ */ new Set();
const validEdges = edges.filter((e) => {
if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
const key = `${e.from}->${e.to}`;
if (edgeKeys.has(key)) return false;
edgeKeys.add(key);
return true;
});
return { title, nodes, edges: validEdges };
}
var FLOW_INTENT_PATTERNS = [
"\u6D41\u7A0B\u56FE",
"\u53EF\u89C6\u5316\u6D41\u7A0B",
"\u7ED3\u6784\u5316\u8F93\u51FA",
"\u7ED3\u6784\u5316\u6570\u636E",
"\u7ED3\u6784\u5316\u6D41\u7A0B",
"\u7ED3\u6784\u5316\u603B\u7ED3",
"\u683C\u5F0F\u5316\u8F93\u51FA",
"\u683C\u5F0F\u5316\u603B\u7ED3",
"flow-json",
"flowchart"
];
function detectFlowIntent(text) {
if (!text) return false;
const lower = text.toLowerCase();
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
}
function stripFlowFence(text, streaming) {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return text;
const after = text.slice(start + FLOW_FENCE_OPEN.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
const placeholder = close < 0 && streaming ? "\n\n> \u{1F4CA} \u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u6D41\u7A0B\u56FE\u753B\u5E03\u540C\u6B65\u7ED8\u5236\u4E2D\u2026" : "\n\n> \u{1F4CA} \u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u53EF\u5728\u53F3\u4FA7\u300C\u6D41\u7A0B\u56FE\u300D\u753B\u5E03\u67E5\u770B\uFF08\u70B9\u51FB\u8282\u70B9\u770B\u8BE6\u60C5\uFF09\u3002";
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
}
var X_GAP = 224;
var Y_GAP = 132;
function layoutFlowGraph(data) {
const preds = /* @__PURE__ */ new Map();
for (const e of data.edges) {
const list = preds.get(e.to);
if (list) list.push(e.from);
else preds.set(e.to, [e.from]);
}
const memo = /* @__PURE__ */ new Map();
const visiting = /* @__PURE__ */ new Set();
const layerOf = (id) => {
const cached = memo.get(id);
if (cached !== void 0) return cached;
if (visiting.has(id)) return 0;
visiting.add(id);
const ps = preds.get(id) ?? [];
const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1;
visiting.delete(id);
memo.set(id, layer);
return layer;
};
const byLayer = /* @__PURE__ */ new Map();
for (const n of data.nodes) {
const layer = layerOf(n.id);
const list = byLayer.get(layer);
if (list) list.push(n);
else byLayer.set(layer, [n]);
}
const pos = /* @__PURE__ */ new Map();
for (const [layer, nodes] of byLayer) {
const rowWidth = (nodes.length - 1) * X_GAP;
nodes.forEach((n, i) => {
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
});
}
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id) }));
}
// src/roundtable-planning/lib/flow-graph.test.ts
var FULL = [
"\u62A5\u544A\u6B63\u6587\u2026\u2026",
"```flow-json",
'{"title":"\u65B9\u6848\u6267\u884C\u6D41\u7A0B","nodes":[',
'{"id":"n1","label":"\u73AF\u5883\u8BC4\u4F30","type":"stage","detail":"\u8BC4\u4F30\u73B0\u573A\u73AF\u5883"},',
'{"id":"n2","label":"\u7F16\u5236\u65B9\u6848","type":"task","from":["n1"]},',
'{"id":"n3","label":"\u662F\u5426\u53EF\u884C","type":"decision","from":[{"id":"n2","label":"\u8BC4\u5BA1"}]}',
"]}",
"```"
].join("\n");
test("extractFlowGraph\uFF1A\u5B8C\u6574\u5757\u89E3\u6790\u51FA\u8282\u70B9/\u8FB9/\u6807\u9898", () => {
const g = extractFlowGraph(FULL);
assert.ok(g);
assert.equal(g.title, "\u65B9\u6848\u6267\u884C\u6D41\u7A0B");
assert.equal(g.nodes.length, 3);
assert.equal(g.nodes[0].type, "stage");
assert.deepEqual(g.edges, [
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3", label: "\u8BC4\u5BA1" }
]);
});
test("extractFlowGraph\uFF1A\u6D41\u5F0F\u534A\u622A\uFF08fence \u672A\u95ED\u5408 + \u5C3E\u5BF9\u8C61\u622A\u65AD\uFF09\u53EA\u53D6\u5B8C\u6574\u8282\u70B9", () => {
const partial = [
"\u6B63\u6587",
"```flow-json",
'{"title":"T","nodes":[',
'{"id":"a","label":"A","type":"start"},',
'{"id":"b","label":"B","type":"task","from":["a"]},',
'{"id":"c","label":"C","ty'
// 截断
].join("\n");
const g = extractFlowGraph(partial);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.deepEqual(g.edges, [{ from: "a", to: "b" }]);
});
test("extractFlowGraph\uFF1A\u672A\u77E5 type \u843D task\u3001\u91CD\u590D id \u53BB\u91CD\u3001\u60AC\u7A7A\u8FB9\u4E22\u5F03", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"x","label":"X","type":"banana"},',
'{"id":"x","label":"X-dup","type":"task"},',
'{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}',
"]}",
"```"
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.equal(g.nodes[0].type, "task");
assert.equal(g.nodes[0].label, "X");
assert.deepEqual(g.edges, [{ from: "x", to: "y" }]);
});
test("extractFlowGraph\uFF1A\u65E0 fence / \u65E0\u5B8C\u6574\u8282\u70B9\u8FD4\u56DE null", () => {
assert.equal(extractFlowGraph("\u666E\u901A\u56DE\u590D\uFF0C\u6CA1\u6709\u7ED3\u6784\u5316\u6570\u636E"), null);
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
});
test("extractFlowGraph\uFF1A\u517C\u5BB9\u72EC\u7ACB edges \u6570\u7EC4\uFF08source/target \u522B\u540D\uFF09", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],',
'"edges":[{"source":"a","target":"b","label":"\u8D70"}]}',
"```"
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "\u8D70" }]);
});
test("stripFlowFence\uFF1A\u95ED\u5408\u5757\u66FF\u6362\u4E3A\u5F15\u5BFC\u8BED\u5E76\u4FDD\u7559\u524D\u540E\u6587", () => {
const out = stripFlowFence(FULL, false);
assert.ok(out.includes("\u62A5\u544A\u6B63\u6587"));
assert.ok(!out.includes("```flow-json"));
assert.ok(out.includes("\u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E"));
});
test("stripFlowFence\uFF1A\u672A\u95ED\u5408\uFF08\u6D41\u5F0F\u4E2D\uFF09\u7528\u300C\u6B63\u5728\u8F93\u51FA\u300D\u63AA\u8F9E", () => {
const out = stripFlowFence('\u6B63\u6587\n```flow-json\n{"nodes":[', true);
assert.ok(out.includes("\u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E"));
assert.ok(!out.includes("```flow-json"));
});
test("layoutFlowGraph\uFF1A\u6309\u524D\u9A71\u5206\u5C42\uFF08\u6839 0 \u5C42\uFF0C\u5B50\u5C42\u9012\u589E\uFF09\uFF0C\u4FDD\u6301\u539F\u59CB\u987A\u5E8F", () => {
const g = extractFlowGraph(FULL);
const positioned = layoutFlowGraph(g);
assert.equal(positioned[0].y, 0);
assert.ok(positioned[1].y > positioned[0].y);
assert.ok(positioned[2].y > positioned[1].y);
assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]);
});
test("detectFlowIntent\uFF1A\u660E\u786E\u77ED\u8BED\u547D\u4E2D\uFF08\u7F16\u6392\u63D0\u793A / \u8F93\u5165\u6846\u6D88\u606F\uFF09", () => {
assert.ok(detectFlowIntent("\u6700\u7EC8\u603B\u7ED3\u8BF7\u7528\u7ED3\u6784\u5316\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u603B\u7ED3\u6210\u683C\u5F0F\u5316\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u7ED9\u6211\u753B\u4E00\u5F20\u6D41\u7A0B\u56FE"));
assert.ok(detectFlowIntent("\u8F93\u51FA\u53EF\u89C6\u5316\u6D41\u7A0B"));
assert.ok(detectFlowIntent("\u8BF7\u6309 flow-json \u5951\u7EA6\u8F93\u51FA"));
assert.ok(detectFlowIntent("\u91CD\u70B9\u603B\u7ED3\u65B9\u6848\u8BBE\u8BA1\u5E2D\u4F4D\uFF0C\u6700\u540E\u7ED9\u7ED3\u6784\u5316\u6570\u636E"));
});
test("detectFlowIntent\uFF1A\u666E\u901A\u63AA\u8F9E\u4E0D\u8BEF\u89E6\u53D1", () => {
assert.equal(detectFlowIntent("\u7528\u8868\u683C\u628A\u8981\u70B9\u7ED3\u6784\u5316\u5448\u73B0"), false);
assert.equal(detectFlowIntent("\u628A\u6267\u884C\u8DEF\u7EBF\u5199\u5F97\u66F4\u7EC6\u4E00\u4E9B"), false);
assert.equal(detectFlowIntent("\u9762\u5411\u9AD8\u7BA1\u6C47\u62A5\uFF0C\u63A7\u5236\u5728\u4E00\u9875\u5185"), false);
assert.equal(detectFlowIntent(""), false);
assert.equal(detectFlowIntent(null), false);
assert.equal(detectFlowIntent(void 0), false);
});
test("layoutFlowGraph\uFF1A\u73AF\u4E0D\u6B7B\u5FAA\u73AF", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}',
"```"
].join("\n");
const g = extractFlowGraph(text);
const positioned = layoutFlowGraph(g);
assert.equal(positioned.length, 2);
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
});