feat(f7.1): flowchart node/edge diff + styling emission (#22)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -95,8 +95,60 @@ export function parseFlowchart(source: string): FlowGraph {
|
|||||||
return { header: header || "flowchart TD", nodes, edges };
|
return { header: header || "flowchart TD", nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replaced in Task 3 with the real diff/emit.
|
function nodeChanged(a: FlowNode, b: FlowNode): boolean {
|
||||||
export function diffFlowchart(_beforeSrc: string, currentSrc: string): string {
|
return (a.label ?? "") !== (b.label ?? "") || (a.open ?? "") !== (b.open ?? "");
|
||||||
void CW_COLORS;
|
}
|
||||||
return currentSrc;
|
const edgeKey = (e: FlowEdge): string => `${e.from} ${e.to}`;
|
||||||
|
|
||||||
|
export function diffFlowchart(beforeSrc: string, currentSrc: string): string {
|
||||||
|
const before = parseFlowchart(beforeSrc);
|
||||||
|
const current = parseFlowchart(currentSrc);
|
||||||
|
|
||||||
|
const addedNodes: string[] = [];
|
||||||
|
const changedNodes: string[] = [];
|
||||||
|
for (const [id, cur] of current.nodes) {
|
||||||
|
const prev = before.nodes.get(id);
|
||||||
|
if (!prev) addedNodes.push(id);
|
||||||
|
else if (nodeChanged(prev, cur)) changedNodes.push(id);
|
||||||
|
}
|
||||||
|
const removedNodes: FlowNode[] = [];
|
||||||
|
for (const [id, prev] of before.nodes) {
|
||||||
|
if (!current.nodes.has(id)) removedNodes.push(prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeEdgeKeys = new Set(before.edges.map(edgeKey));
|
||||||
|
const currentEdgeKeys = new Set(current.edges.map(edgeKey));
|
||||||
|
const addedEdgeIdx = current.edges.filter((e) => !beforeEdgeKeys.has(edgeKey(e))).map((e) => e.index);
|
||||||
|
const removedEdges = before.edges.filter((e) => !currentEdgeKeys.has(edgeKey(e)));
|
||||||
|
|
||||||
|
// Ghost-edge indices follow the current edge count, in deterministic order.
|
||||||
|
let nextIdx = current.edges.length;
|
||||||
|
const ghostEdgeLines: string[] = [];
|
||||||
|
const ghostEdgeIdx: number[] = [];
|
||||||
|
for (const e of removedEdges) {
|
||||||
|
ghostEdgeLines.push(` ${e.from} -.-> ${e.to}`);
|
||||||
|
ghostEdgeIdx.push(nextIdx++);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: string[] = [currentSrc.replace(/\s+$/, "")];
|
||||||
|
|
||||||
|
// Ghost removed nodes: re-inject their declaration (or bare id) so they appear.
|
||||||
|
for (const n of removedNodes) out.push(` ${n.decl ?? n.id}`);
|
||||||
|
out.push(...ghostEdgeLines);
|
||||||
|
|
||||||
|
// classDefs (always emit the three; harmless if a class is unused).
|
||||||
|
out.push(
|
||||||
|
` classDef cwAdded fill:${CW_COLORS.added}22,stroke:${CW_COLORS.added},stroke-width:2px;`,
|
||||||
|
` classDef cwChanged fill:${CW_COLORS.changed}22,stroke:${CW_COLORS.changed},stroke-width:2px;`,
|
||||||
|
` classDef cwRemoved fill:${CW_COLORS.removed}11,stroke:${CW_COLORS.removed},stroke-width:1px,stroke-dasharray:5 3,color:${CW_COLORS.removed};`,
|
||||||
|
);
|
||||||
|
if (addedNodes.length) out.push(` class ${addedNodes.join(",")} cwAdded;`);
|
||||||
|
if (changedNodes.length) out.push(` class ${changedNodes.join(",")} cwChanged;`);
|
||||||
|
if (removedNodes.length) out.push(` class ${removedNodes.map((n) => n.id).join(",")} cwRemoved;`);
|
||||||
|
|
||||||
|
for (const i of addedEdgeIdx) out.push(` linkStyle ${i} stroke:${CW_COLORS.added},stroke-width:2px;`);
|
||||||
|
for (const i of ghostEdgeIdx)
|
||||||
|
out.push(` linkStyle ${i} stroke:${CW_COLORS.removed},stroke-width:1px,stroke-dasharray:5 3;`);
|
||||||
|
|
||||||
|
return out.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,50 @@ describe("parseFlowchart", () => {
|
|||||||
expect(g.edges).toHaveLength(1);
|
expect(g.edges).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { diffFlowchart } from "../src/mermaidFlowchartDiff";
|
||||||
|
|
||||||
|
describe("diffFlowchart", () => {
|
||||||
|
it("colors an added node with the cwAdded class", () => {
|
||||||
|
const before = "flowchart LR\n A-->B";
|
||||||
|
const current = "flowchart LR\n A-->B\n B-->C";
|
||||||
|
const out = diffFlowchart(before, current);
|
||||||
|
expect(out).toContain("classDef cwAdded");
|
||||||
|
expect(out).toMatch(/class\s+C\s+cwAdded/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("colors a changed node (same id, new label) with cwChanged", () => {
|
||||||
|
const before = "flowchart LR\n A[Old]-->B";
|
||||||
|
const current = "flowchart LR\n A[New]-->B";
|
||||||
|
const out = diffFlowchart(before, current);
|
||||||
|
expect(out).toMatch(/class\s+A\s+cwChanged/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("styles an added edge via linkStyle on its current index", () => {
|
||||||
|
const before = "flowchart LR\n A-->B";
|
||||||
|
const current = "flowchart LR\n A-->B\n A-->C";
|
||||||
|
const out = diffFlowchart(before, current);
|
||||||
|
// edge index 1 (A-->C) is the added one
|
||||||
|
expect(out).toMatch(/linkStyle\s+1\s+stroke:#2ea043/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ghosts a removed node in place with cwRemoved (re-injected decl)", () => {
|
||||||
|
const before = "flowchart LR\n A-->B\n B-->C[Gone]";
|
||||||
|
const current = "flowchart LR\n A-->B";
|
||||||
|
const out = diffFlowchart(before, current);
|
||||||
|
expect(out).toContain("C[Gone]"); // re-injected
|
||||||
|
expect(out).toMatch(/class\s+C\s+cwRemoved/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic (same inputs → identical output)", () => {
|
||||||
|
const before = "flowchart LR\n A-->B";
|
||||||
|
const current = "flowchart LR\n A-->B\n B-->C\n A-->D";
|
||||||
|
expect(diffFlowchart(before, current)).toBe(diffFlowchart(before, current));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the original current source as the prefix", () => {
|
||||||
|
const current = "flowchart LR\n A-->B\n B-->C";
|
||||||
|
const out = diffFlowchart("flowchart LR\n A-->B", current);
|
||||||
|
expect(out.startsWith(current)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user