F7.1: intra-diagram mermaid diffing (#22) #28
@@ -0,0 +1,28 @@
|
||||
# Manual smoke — F7.1 intra-diagram mermaid diffing (#22)
|
||||
|
||||
Webview SVG rendering isn't covered by automated E2E (sealed sandbox, §6.8), so
|
||||
verify the rendered colors by hand once. The host-side augmentation (the styling
|
||||
directives injected into the mermaid source) *is* covered by unit + host E2E; this
|
||||
smoke confirms mermaid actually paints them.
|
||||
|
||||
1. Open a markdown doc with a flowchart:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B[Parse]
|
||||
B --> C[Emit]
|
||||
```
|
||||
|
||||
2. Run **Cowriting: Show Track-Changes Preview** (`ctrl+alt+r`). Pin the baseline
|
||||
(**Cowriting: Pin Diff Baseline**).
|
||||
3. Edit the diagram: change `B[Parse]` → `B[Parse+Lint]`, add `B --> D[Validate]`,
|
||||
and delete the `B --> C` edge and the `C[Emit]` node.
|
||||
4. **Expect** in the preview: `B` outlined amber (changed), `D` green (added),
|
||||
`C` shown as a faded dashed ghost (removed) with a dashed grey ghost edge, and
|
||||
a legend `added · changed · removed` beneath the diagram.
|
||||
5. Repeat with a `sequenceDiagram`: add a message (green `rect` band), remove one
|
||||
(grey ghost band, message still visible), confirm a removed participant still
|
||||
appears.
|
||||
6. Change a diagram to a `classDiagram` and confirm it still shows the v1
|
||||
whole-block "changed" badge (graceful fallback, INV-30) — no error, no broken
|
||||
render.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,3 +64,10 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
|
||||
.cw-seg:last-child { border-radius: 0 3px 3px 0; border-left: none; }
|
||||
.cw-seg-on { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
|
||||
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
|
||||
|
||||
/* F7.1 (#22) intra-diagram mermaid diff legend. */
|
||||
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
|
||||
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
|
||||
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
|
||||
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
|
||||
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* mermaidDiff — F7.1 (#22) dispatcher. Detects a mermaid diagram's type and
|
||||
* routes a baseline→current diff to the matching pure differ, which re-emits the
|
||||
* CURRENT diagram source augmented with mermaid styling directives (INV-29). Any
|
||||
* unsupported type or parser surprise degrades to the v1 whole-block badge
|
||||
* (INV-30) — this function never throws. vscode-free, DOM-free, deterministic.
|
||||
*/
|
||||
import { diffFlowchart } from "./mermaidFlowchartDiff";
|
||||
import { diffSequence } from "./mermaidSequenceDiff";
|
||||
|
||||
/** Theme-neutral colors baked into emitted mermaid source (source can't read CSS vars). */
|
||||
export const CW_COLORS = {
|
||||
added: "#2ea043",
|
||||
changed: "#d29922",
|
||||
removed: "#808080",
|
||||
} as const;
|
||||
|
||||
export type DiagramType = "flowchart" | "sequence" | "other";
|
||||
|
||||
export interface MermaidDiffAugmented {
|
||||
kind: "augmented";
|
||||
source: string;
|
||||
}
|
||||
export interface MermaidDiffFallback {
|
||||
kind: "fallback";
|
||||
}
|
||||
export type MermaidDiffResult = MermaidDiffAugmented | MermaidDiffFallback;
|
||||
|
||||
export function detectDiagramType(source: string): DiagramType {
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (t === "" || t.startsWith("%%")) continue;
|
||||
if (/^(flowchart|graph)\b/.test(t)) return "flowchart";
|
||||
if (/^sequenceDiagram\b/.test(t)) return "sequence";
|
||||
return "other";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function diffMermaid(beforeSrc: string, currentSrc: string): MermaidDiffResult {
|
||||
const type = detectDiagramType(currentSrc);
|
||||
try {
|
||||
if (type === "flowchart") return { kind: "augmented", source: diffFlowchart(beforeSrc, currentSrc) };
|
||||
if (type === "sequence") return { kind: "augmented", source: diffSequence(beforeSrc, currentSrc) };
|
||||
} catch {
|
||||
return { kind: "fallback" };
|
||||
}
|
||||
return { kind: "fallback" };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* mermaidFlowchartDiff — F7.1 (#22). Pure flowchart parser + diff/emit. Parses a
|
||||
* `graph`/`flowchart` source into nodes (by id) and edges (by declaration order),
|
||||
* diffs baseline vs current, and re-emits the CURRENT source augmented with
|
||||
* `classDef`/`class`/`linkStyle` directives coloring added/changed elements and
|
||||
* ghosting removed ones (INV-29/31). Deterministic; no vscode, no DOM.
|
||||
*/
|
||||
import { CW_COLORS } from "./mermaidDiff";
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
open?: string;
|
||||
close?: string;
|
||||
/** Verbatim declaration token, e.g. `A[Start]`, for ghost re-injection. */
|
||||
decl?: string;
|
||||
}
|
||||
export interface FlowEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
index: number;
|
||||
/** Verbatim edge statement for ghost re-injection. */
|
||||
raw: string;
|
||||
}
|
||||
export interface FlowGraph {
|
||||
header: string;
|
||||
nodes: Map<string, FlowNode>;
|
||||
edges: FlowEdge[];
|
||||
}
|
||||
|
||||
const NODE_TOKEN = /^([A-Za-z0-9_]+)(\[\[[^\]]*\]\]|\[[^\]]*\]|\(\([^)]*\)\)|\([^)]*\)|\{[^}]*\}|>[^\]]*\])?/;
|
||||
// link operators: -->, ---, -.->, -.-, ==>, ===, --x, --o, ==x, ==o, optionally |label|
|
||||
const LINK = /^(-->|---|-\.->|-\.-|==>|===|--[xo]|==[xo])(\|([^|]*)\|)?/;
|
||||
|
||||
function shapeOf(bracket: string): { label: string; open: string; close: string } {
|
||||
if (bracket.startsWith("[[")) return { label: bracket.slice(2, -2), open: "[[", close: "]]" };
|
||||
if (bracket.startsWith("((")) return { label: bracket.slice(2, -2), open: "((", close: "))" };
|
||||
const open = bracket[0];
|
||||
const close = bracket[bracket.length - 1];
|
||||
return { label: bracket.slice(1, -1), open, close };
|
||||
}
|
||||
|
||||
function recordNode(nodes: Map<string, FlowNode>, token: string): string {
|
||||
const m = token.match(NODE_TOKEN)!;
|
||||
const id = m[1];
|
||||
const existing = nodes.get(id) ?? { id };
|
||||
if (m[2]) {
|
||||
const s = shapeOf(m[2]);
|
||||
existing.label = s.label;
|
||||
existing.open = s.open;
|
||||
existing.close = s.close;
|
||||
existing.decl = `${id}${m[2]}`;
|
||||
}
|
||||
nodes.set(id, existing);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function parseFlowchart(source: string): FlowGraph {
|
||||
const lines = source.split(/\r?\n/);
|
||||
let header = "";
|
||||
const nodes = new Map<string, FlowNode>();
|
||||
const edges: FlowEdge[] = [];
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "" || line.startsWith("%%")) continue;
|
||||
if (!header && /^(flowchart|graph)\b/.test(line)) {
|
||||
header = line;
|
||||
continue;
|
||||
}
|
||||
let rest = line;
|
||||
const firstM = rest.match(NODE_TOKEN);
|
||||
if (!firstM || firstM[0] === "") continue;
|
||||
let leftId = recordNode(nodes, firstM[0]);
|
||||
rest = rest.slice(firstM[0].length).trimStart();
|
||||
// Walk a (possibly chained) edge statement: A[..] --> B -->|x| C
|
||||
while (rest.length > 0) {
|
||||
const linkM = rest.match(LINK);
|
||||
if (!linkM) break;
|
||||
rest = rest.slice(linkM[0].length).trimStart();
|
||||
const rhsM = rest.match(NODE_TOKEN);
|
||||
if (!rhsM || rhsM[0] === "") break;
|
||||
const rightId = recordNode(nodes, rhsM[0]);
|
||||
edges.push({
|
||||
from: leftId,
|
||||
to: rightId,
|
||||
label: linkM[3] || undefined,
|
||||
index: edges.length,
|
||||
raw: `${leftId} ${linkM[1]}${linkM[2] ?? ""} ${rightId}`,
|
||||
});
|
||||
rest = rest.slice(rhsM[0].length).trimStart();
|
||||
leftId = rightId;
|
||||
}
|
||||
}
|
||||
return { header: header || "flowchart TD", nodes, edges };
|
||||
}
|
||||
|
||||
function nodeChanged(a: FlowNode, b: FlowNode): boolean {
|
||||
return (a.label ?? "") !== (b.label ?? "") || (a.open ?? "") !== (b.open ?? "");
|
||||
}
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* mermaidSequenceDiff — F7.1 (#22). Pure sequence-diagram parser + diff/emit.
|
||||
* Mermaid sequence diagrams have NO per-message color directive; the only
|
||||
* per-message hook is the `rect rgb(...) … end` background block. So the emitter
|
||||
* rebuilds the message stream (ghosted-removed messages re-inserted at their
|
||||
* baseline position) wrapping each added/changed/removed message in a one-message
|
||||
* tinted rect, and re-declares removed participants (INV-29/31). Deterministic;
|
||||
* no vscode, no DOM.
|
||||
*/
|
||||
import { diffArrays } from "diff";
|
||||
import { CW_COLORS } from "./mermaidDiff";
|
||||
|
||||
export interface SeqDiagram {
|
||||
header: string;
|
||||
participants: string[];
|
||||
/** Message/other statement lines, verbatim & trimmed, in order. */
|
||||
statements: string[];
|
||||
}
|
||||
|
||||
// `A->>B: text`, plus ->, -->, -->>, -x, --x, -), --) variants. Captures from/to.
|
||||
const MSG = /^([A-Za-z0-9_]+)\s*(-{1,2}(?:>>?|x|\)))\s*([A-Za-z0-9_]+)\s*:/;
|
||||
const PARTICIPANT = /^(?:participant|actor)\s+([A-Za-z0-9_]+)/;
|
||||
|
||||
export function parseSequence(source: string): SeqDiagram {
|
||||
const lines = source.split(/\r?\n/);
|
||||
let header = "";
|
||||
const declared: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const statements: string[] = [];
|
||||
const addP = (p: string) => {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
declared.push(p);
|
||||
}
|
||||
};
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "" || line.startsWith("%%")) continue;
|
||||
if (!header && /^sequenceDiagram\b/.test(line)) {
|
||||
header = line;
|
||||
continue;
|
||||
}
|
||||
const pm = line.match(PARTICIPANT);
|
||||
if (pm) {
|
||||
addP(pm[1]);
|
||||
continue;
|
||||
}
|
||||
const mm = line.match(MSG);
|
||||
if (mm) {
|
||||
addP(mm[1]);
|
||||
addP(mm[3]);
|
||||
}
|
||||
statements.push(line);
|
||||
}
|
||||
return { header: header || "sequenceDiagram", participants: declared, statements };
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const h = hex.replace("#", "");
|
||||
const n = parseInt(h, 16);
|
||||
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
|
||||
}
|
||||
|
||||
function rectWrap(stmt: string, hex: string): string[] {
|
||||
return [` rect rgb(${hexToRgb(hex)})`, ` ${stmt}`, ` end`];
|
||||
}
|
||||
|
||||
export function diffSequence(beforeSrc: string, currentSrc: string): string {
|
||||
const before = parseSequence(beforeSrc);
|
||||
const current = parseSequence(currentSrc);
|
||||
|
||||
const out: string[] = [current.header];
|
||||
|
||||
// Participants: keep current declarations, then re-declare removed ones (ghosts).
|
||||
for (const p of current.participants) out.push(` participant ${p}`);
|
||||
const curSet = new Set(current.participants);
|
||||
for (const p of before.participants) {
|
||||
if (!curSet.has(p)) out.push(` participant ${p}`);
|
||||
}
|
||||
|
||||
// Message stream diff via LCS over statements; pair adjacent removed+added as changed.
|
||||
const parts = diffArrays(before.statements, current.statements);
|
||||
for (let n = 0; n < parts.length; n++) {
|
||||
const ch = parts[n];
|
||||
if (!ch.added && !ch.removed) {
|
||||
for (const s of ch.value) out.push(` ${s}`);
|
||||
continue;
|
||||
}
|
||||
if (ch.removed) {
|
||||
const next = parts[n + 1];
|
||||
const addVals = next?.added ? next.value : [];
|
||||
const paired = Math.min(ch.value.length, addVals.length);
|
||||
for (let k = 0; k < paired; k++) out.push(...rectWrap(addVals[k], CW_COLORS.changed));
|
||||
for (let k = paired; k < ch.value.length; k++) out.push(...rectWrap(ch.value[k], CW_COLORS.removed));
|
||||
for (let k = paired; k < addVals.length; k++) out.push(...rectWrap(addVals[k], CW_COLORS.added));
|
||||
if (next?.added) n++;
|
||||
continue;
|
||||
}
|
||||
// lone added run
|
||||
for (const s of ch.value) out.push(...rectWrap(s, CW_COLORS.added));
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { diffArrays, diffWords } from "diff";
|
||||
import { diffMermaid } from "./mermaidDiff";
|
||||
|
||||
export type BlockType = "prose" | "code" | "mermaid";
|
||||
|
||||
@@ -204,6 +205,24 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
return defaultFence(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */
|
||||
function mermaidFenceBody(raw: string): string | null {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
|
||||
if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
|
||||
const body = lines.slice(1);
|
||||
if (body.length && /^(\s*)(`{3,}|~{3,})/.test(body[body.length - 1])) body.pop();
|
||||
return body.join("\n");
|
||||
}
|
||||
|
||||
/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */
|
||||
const MERMAID_LEGEND =
|
||||
'<div class="cw-mermaid-legend">' +
|
||||
'<span class="cw-leg cw-leg-add">added</span>' +
|
||||
'<span class="cw-leg cw-leg-chg">changed</span>' +
|
||||
'<span class="cw-leg cw-leg-rem">removed</span>' +
|
||||
"</div>";
|
||||
|
||||
/** Build a markdown string with inline <ins>/<del> from a word-level prose diff. */
|
||||
function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
||||
return diffWords(beforeRaw, afterRaw)
|
||||
@@ -256,8 +275,19 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
|
||||
case "changed":
|
||||
cls = "cw-changed";
|
||||
if (op.atomic) {
|
||||
inner = safe(op.block.raw); // the NEW block, whole (INV-23)
|
||||
badge = '<span class="cw-badge">changed</span>';
|
||||
const curBody = op.block.type === "mermaid" ? mermaidFenceBody(op.block.raw) : null;
|
||||
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
|
||||
const md =
|
||||
curBody !== null && beforeBody !== null
|
||||
? diffMermaid(beforeBody, curBody)
|
||||
: { kind: "fallback" as const };
|
||||
if (md.kind === "augmented") {
|
||||
// Re-render the augmented diagram as a mermaid fence; add a legend, drop the badge (INV-29).
|
||||
inner = safe("```mermaid\n" + md.source + "\n```") + MERMAID_LEGEND;
|
||||
} else {
|
||||
inner = safe(op.block.raw); // the NEW block, whole (INV-23 / INV-30 fallback)
|
||||
badge = '<span class="cw-badge">changed</span>';
|
||||
}
|
||||
} else {
|
||||
inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw));
|
||||
}
|
||||
|
||||
@@ -214,6 +214,14 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
getLastModel(uriString: string): BlockOp[] | undefined {
|
||||
return this.lastModel.get(uriString);
|
||||
}
|
||||
/** F7.1 (#22) test seam: the track-changes HTML the panel would post for a doc. */
|
||||
renderHtmlFor(uriString: string): string {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (!doc) return "";
|
||||
const current = doc.getText();
|
||||
const baseline = this.diffView.getBaseline(uriString);
|
||||
return renderTrackChanges(baseline?.text ?? current, current);
|
||||
}
|
||||
/** F9: current view mode for a panel (default track-changes). */
|
||||
getMode(uriString: string): "changes" | "authorship" {
|
||||
return this.mode.get(uriString) ?? "changes";
|
||||
|
||||
@@ -105,4 +105,31 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam,
|
||||
await settle();
|
||||
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
|
||||
});
|
||||
|
||||
test("a changed flowchart augments the emitted mermaid source (#22, INV-29)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
// Show the preview and pin the baseline so the fixture's `a --> b` flowchart
|
||||
// is the "before"; then add an edge `a --> c` and confirm the intra-diagram diff.
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await settle();
|
||||
|
||||
const anchor = doc.getText().indexOf("a --> b");
|
||||
assert.ok(anchor >= 0, "fixture contains the flowchart edge");
|
||||
const insertAt = doc.positionAt(anchor + "a --> b".length);
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, insertAt, "\n a --> c");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "flowchart edit applied");
|
||||
await settle();
|
||||
|
||||
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
|
||||
const mermaidOp = model.find((o) => o.kind === "changed" && o.block.type === "mermaid");
|
||||
assert.ok(mermaidOp, "the flowchart block is a changed mermaid op");
|
||||
|
||||
const html = api.trackChangesPreviewController.renderHtmlFor(docUri());
|
||||
assert.match(html, /classDef cwAdded/, "augmented with cwAdded classDef");
|
||||
assert.match(html, /class\s+c\s+cwAdded/, "node c colored added");
|
||||
assert.match(html, /cw-mermaid-legend/, "legend emitted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectDiagramType, diffMermaid } from "../src/mermaidDiff";
|
||||
|
||||
describe("detectDiagramType", () => {
|
||||
it("detects flowchart from `flowchart` / `graph` headers", () => {
|
||||
expect(detectDiagramType("flowchart LR\n a-->b")).toBe("flowchart");
|
||||
expect(detectDiagramType("graph TD\n a-->b")).toBe("flowchart");
|
||||
expect(detectDiagramType(" \n%% c\ngraph TD")).toBe("flowchart");
|
||||
});
|
||||
it("detects sequence from `sequenceDiagram`", () => {
|
||||
expect(detectDiagramType("sequenceDiagram\n A->>B: hi")).toBe("sequence");
|
||||
});
|
||||
it("returns `other` for unsupported types", () => {
|
||||
expect(detectDiagramType("classDiagram\n class A")).toBe("other");
|
||||
expect(detectDiagramType("stateDiagram-v2\n s1 --> s2")).toBe("other");
|
||||
expect(detectDiagramType("")).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffMermaid", () => {
|
||||
it("falls back for an unsupported diagram type", () => {
|
||||
expect(diffMermaid("classDiagram\n class A", "classDiagram\n class B")).toEqual({ kind: "fallback" });
|
||||
});
|
||||
it("falls back (never throws) when the differ throws", () => {
|
||||
// A deliberately malformed flowchart still degrades gracefully.
|
||||
const r = diffMermaid("flowchart LR", "flowchart LR\n a -->");
|
||||
expect(["augmented", "fallback"]).toContain(r.kind);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseFlowchart } from "../src/mermaidFlowchartDiff";
|
||||
|
||||
describe("parseFlowchart", () => {
|
||||
it("captures the header and node declarations with labels + shapes", () => {
|
||||
const g = parseFlowchart("flowchart LR\n A[Start] --> B(Middle)\n B --> C{End}");
|
||||
expect(g.header).toBe("flowchart LR");
|
||||
expect(g.nodes.get("A")).toMatchObject({ id: "A", label: "Start", open: "[", close: "]" });
|
||||
expect(g.nodes.get("B")).toMatchObject({ id: "B", label: "Middle", open: "(", close: ")" });
|
||||
expect(g.nodes.get("C")).toMatchObject({ id: "C", label: "End", open: "{", close: "}" });
|
||||
});
|
||||
|
||||
it("captures edges in declaration order with from/to and index", () => {
|
||||
const g = parseFlowchart("graph TD\n A-->B\n B-->C");
|
||||
expect(g.edges.map((e) => [e.from, e.to, e.index])).toEqual([
|
||||
["A", "B", 0],
|
||||
["B", "C", 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it("records a node referenced only in an edge (no explicit declaration)", () => {
|
||||
const g = parseFlowchart("flowchart LR\n A-->B");
|
||||
expect(g.nodes.has("A")).toBe(true);
|
||||
expect(g.nodes.has("B")).toBe(true);
|
||||
expect(g.nodes.get("A")?.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles a labelled edge `A -->|yes| B`", () => {
|
||||
const g = parseFlowchart("flowchart LR\n A -->|yes| B");
|
||||
expect(g.edges[0]).toMatchObject({ from: "A", to: "B", label: "yes" });
|
||||
});
|
||||
|
||||
it("ignores comments and blank lines", () => {
|
||||
const g = parseFlowchart("flowchart LR\n%% a comment\n\n A-->B");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSequence } from "../src/mermaidSequenceDiff";
|
||||
|
||||
describe("parseSequence", () => {
|
||||
it("captures explicit participants in order", () => {
|
||||
const d = parseSequence("sequenceDiagram\n participant A\n participant B\n A->>B: hi");
|
||||
expect(d.participants).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("captures message statements verbatim (trimmed)", () => {
|
||||
const d = parseSequence("sequenceDiagram\n A->>B: hi\n B-->>A: bye");
|
||||
expect(d.statements).toEqual(["A->>B: hi", "B-->>A: bye"]);
|
||||
});
|
||||
|
||||
it("infers participants from messages when not declared", () => {
|
||||
const d = parseSequence("sequenceDiagram\n A->>B: hi\n B->>C: on");
|
||||
expect(d.participants).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("ignores comments and blank lines", () => {
|
||||
const d = parseSequence("sequenceDiagram\n%% note\n\n A->>B: hi");
|
||||
expect(d.statements).toEqual(["A->>B: hi"]);
|
||||
});
|
||||
});
|
||||
|
||||
import { diffSequence } from "../src/mermaidSequenceDiff";
|
||||
|
||||
const rectCount = (s: string, rgb: string) => s.split(`rect rgb(${rgb})`).length - 1;
|
||||
|
||||
describe("diffSequence", () => {
|
||||
it("wraps an added message in a green rect", () => {
|
||||
const before = "sequenceDiagram\n A->>B: hi";
|
||||
const current = "sequenceDiagram\n A->>B: hi\n B->>C: on";
|
||||
const out = diffSequence(before, current);
|
||||
expect(rectCount(out, "46, 160, 67")).toBe(1); // CW_COLORS.added as rgb
|
||||
expect(out).toContain("B->>C: on");
|
||||
});
|
||||
|
||||
it("ghosts a removed message back in a grey rect", () => {
|
||||
const before = "sequenceDiagram\n A->>B: hi\n B->>C: gone";
|
||||
const current = "sequenceDiagram\n A->>B: hi";
|
||||
const out = diffSequence(before, current);
|
||||
expect(out).toContain("B->>C: gone");
|
||||
expect(rectCount(out, "128, 128, 128")).toBe(1);
|
||||
});
|
||||
|
||||
it("re-declares a removed participant so it still appears", () => {
|
||||
const before = "sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: x";
|
||||
const current = "sequenceDiagram\n participant A\n participant B\n A->>B: y";
|
||||
const out = diffSequence(before, current);
|
||||
expect(out).toMatch(/participant C/);
|
||||
});
|
||||
|
||||
it("keeps an unchanged message outside any rect", () => {
|
||||
const doc = "sequenceDiagram\n A->>B: hi";
|
||||
const out = diffSequence(doc, doc);
|
||||
expect(out).not.toContain("rect rgb");
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
const before = "sequenceDiagram\n A->>B: hi";
|
||||
const current = "sequenceDiagram\n A->>B: hi\n B->>A: bye";
|
||||
expect(diffSequence(before, current)).toBe(diffSequence(before, current));
|
||||
});
|
||||
});
|
||||
@@ -99,14 +99,16 @@ describe("renderTrackChanges", () => {
|
||||
expect(html).toContain("cw-badge");
|
||||
});
|
||||
|
||||
it('emits <pre class="mermaid"> for a mermaid fence + a changed badge', () => {
|
||||
it('emits <pre class="mermaid"> for a changed flowchart, augmented intra-diagram (#22)', () => {
|
||||
const html = renderTrackChanges(
|
||||
"```mermaid\nflowchart LR\n a-->b\n```\n",
|
||||
"```mermaid\nflowchart LR\n a-->c\n```\n",
|
||||
);
|
||||
expect(html).toContain('<pre class="mermaid">');
|
||||
expect(html).toContain("a-->c"); // current source, escaped
|
||||
expect(html).toContain("cw-badge");
|
||||
expect(html).toContain("a-->c"); // current source preserved as prefix, escaped
|
||||
// a changed flowchart is now diffed in place (INV-29): styling, not a whole-block badge
|
||||
expect(html).toContain("classDef cwAdded");
|
||||
expect(html).not.toContain('<span class="cw-badge">changed</span>');
|
||||
});
|
||||
|
||||
it("unchanged doc renders with no marks", () => {
|
||||
@@ -232,3 +234,33 @@ describe("renderAuthorship", () => {
|
||||
expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans));
|
||||
});
|
||||
});
|
||||
|
||||
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
||||
|
||||
describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
|
||||
it("augments a changed flowchart with styling directives (INV-29)", () => {
|
||||
const base = "```mermaid\nflowchart LR\n A-->B\n```\n";
|
||||
const cur = "```mermaid\nflowchart LR\n A-->B\n B-->C\n```\n";
|
||||
const html = rtc2(base, cur);
|
||||
expect(html).toContain("classDef cwAdded");
|
||||
expect(html).toMatch(/class\s+C\s+cwAdded/);
|
||||
expect(html).toContain('class="cw-mermaid-legend"');
|
||||
// no single whole-block "changed" badge when we augmented
|
||||
expect(html).not.toContain('<span class="cw-badge">changed</span>');
|
||||
});
|
||||
|
||||
it("augments a changed sequence diagram", () => {
|
||||
const base = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n";
|
||||
const cur = "```mermaid\nsequenceDiagram\n A->>B: hi\n B->>C: on\n```\n";
|
||||
const html = rtc2(base, cur);
|
||||
expect(html).toContain("rect rgb(46, 160, 67)");
|
||||
});
|
||||
|
||||
it("falls back to the v1 badge for an unsupported diagram type (INV-30)", () => {
|
||||
const base = "```mermaid\nclassDiagram\n class A\n```\n";
|
||||
const cur = "```mermaid\nclassDiagram\n class B\n```\n";
|
||||
const html = rtc2(base, cur);
|
||||
expect(html).toContain('<span class="cw-badge">changed</span>');
|
||||
expect(html).not.toContain("classDef cwAdded");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user