Skip to content

Commit 8edeb82

Browse files
ystemsrxclaude
andcommitted
test: split and expand suites for parser/builder/snapshots/exporter
Replaces the single parser.test.ts with five focused files and grows the suite from 4 tests to 52: - parser-sql.test.ts: existing SQL cases plus coverage for non-CREATE statements, composite PKs, CREATE TEMPORARY, Chinese identifiers, blank input, and UNIQUE/KEY/INDEX clause skipping - parser-dbml.test.ts: existing DBML case plus comments-inside-strings, multi-line attributes, note-as-comment, nested Note/indexes blocks, Ref { ... } blocks, schema-qualified targets, Chinese identifiers, Project/Enum/TableGroup ignores, recovery from stray top-level lines, and a regression test for the in-app sample DBML - builder.test.ts: covers generateChenModelData (entities, attributes, diamonds + N/1 edges, dashed placeholders for missing tables, self-loop edge type, hideFields, isColored, labelMode), getTextWidth and estimateAttributeHalfSize - snapshots.test.ts: hashInput stability/uniqueness/unicode plus captureGraphSnapshot happy-path and destroyed-graph short-circuit - exporter-xml.test.ts: escapeXml escaping rules and buildDrawioXML output structure (mxfile shell, vertex / edge counts, label escaping, per-node-type style strings, missing-endpoint skipping, dashed style) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ab8edf commit 8edeb82

5 files changed

Lines changed: 772 additions & 19 deletions

File tree

src/test/builder.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
generateChenModelData,
4+
estimateAttributeHalfSize,
5+
getTextWidth,
6+
} from "../builder";
7+
import type { ParsedRelationship, ParsedTable } from "../types";
8+
9+
const usersTable: ParsedTable = {
10+
name: "users",
11+
columns: [
12+
{ name: "id", type: "int", isPrimaryKey: true, comment: "user id" },
13+
{ name: "name", type: "varchar(50)", isPrimaryKey: false },
14+
],
15+
primaryKeys: ["id"],
16+
foreignKeys: [],
17+
};
18+
19+
const ordersTable: ParsedTable = {
20+
name: "orders",
21+
columns: [
22+
{ name: "id", type: "int", isPrimaryKey: true },
23+
{ name: "user_id", type: "int", isPrimaryKey: false },
24+
],
25+
primaryKeys: ["id"],
26+
foreignKeys: [
27+
{ column: "user_id", referencedTable: "users", referencedColumn: "id" },
28+
],
29+
};
30+
31+
describe("generateChenModelData", () => {
32+
it("creates an entity node per table and an attribute node per column", () => {
33+
const data = generateChenModelData([usersTable], []);
34+
const entities = data.nodes.filter((n) => n.nodeType === "entity");
35+
const attrs = data.nodes.filter((n) => n.nodeType === "attribute");
36+
expect(entities).toHaveLength(1);
37+
expect(entities[0].label).toBe("users");
38+
expect(attrs.map((a) => a.label)).toEqual(["id", "name"]);
39+
// Primary key attribute is bolder/highlighted
40+
expect(attrs[0].keyType).toBe("pk");
41+
expect(attrs[1].keyType).toBe("normal");
42+
});
43+
44+
it("connects every attribute back to its parent entity", () => {
45+
const data = generateChenModelData([usersTable], []);
46+
const entityId = data.nodes.find((n) => n.nodeType === "entity")!.id;
47+
const attrEdges = data.edges.filter(
48+
(e) => e.edgeType === "entity-attribute",
49+
);
50+
expect(attrEdges).toHaveLength(2);
51+
expect(attrEdges.every((e) => e.source === entityId)).toBe(true);
52+
});
53+
54+
it("renders one diamond + two edges (N / 1) for each relationship", () => {
55+
const rels: ParsedRelationship[] = [
56+
{ from: "orders", to: "users", label: "user_id" },
57+
];
58+
const data = generateChenModelData([usersTable, ordersTable], rels);
59+
const diamonds = data.nodes.filter((n) => n.nodeType === "relationship");
60+
expect(diamonds).toHaveLength(1);
61+
expect(diamonds[0].label).toBe("user_id");
62+
63+
const erEdges = data.edges.filter(
64+
(e) =>
65+
e.edgeType === "entity-relationship" ||
66+
e.edgeType === "relationship-entity",
67+
);
68+
expect(erEdges).toHaveLength(2);
69+
const labels = erEdges.map((e) => e.label).sort();
70+
expect(labels).toEqual(["1", "N"]);
71+
});
72+
73+
it("creates a dashed placeholder entity for refs to unknown tables", () => {
74+
const rels: ParsedRelationship[] = [
75+
{ from: "orders", to: "missing", label: "x_id" },
76+
];
77+
const data = generateChenModelData([ordersTable], rels);
78+
const placeholder = data.nodes.find(
79+
(n) => n.nodeType === "entity" && n.isPlaceholder,
80+
);
81+
expect(placeholder).toBeDefined();
82+
expect(placeholder!.label).toBe("missing");
83+
expect(placeholder!.style?.lineDash).toEqual([4, 4]);
84+
});
85+
86+
it("marks self-loop relationships with self-loop-arc edge type", () => {
87+
const selfRel: ParsedRelationship[] = [
88+
{ from: "users", to: "users", label: "manager_id" },
89+
];
90+
const data = generateChenModelData([usersTable], selfRel);
91+
const erEdges = data.edges.filter(
92+
(e) =>
93+
e.edgeType === "entity-relationship" ||
94+
e.edgeType === "relationship-entity",
95+
);
96+
expect(erEdges).toHaveLength(2);
97+
expect(erEdges.every((e) => e.type === "self-loop-arc")).toBe(true);
98+
expect(erEdges.every((e) => e.curveOffset === 22)).toBe(true);
99+
});
100+
101+
it("hideFields=true skips attribute nodes & their edges", () => {
102+
const data = generateChenModelData([usersTable], [], true, "name", true);
103+
expect(data.nodes.filter((n) => n.nodeType === "attribute")).toHaveLength(
104+
0,
105+
);
106+
expect(
107+
data.edges.filter((e) => e.edgeType === "entity-attribute"),
108+
).toHaveLength(0);
109+
});
110+
111+
it("isColored=false uses black/white styling", () => {
112+
const data = generateChenModelData([usersTable], [], false);
113+
const entity = data.nodes.find((n) => n.nodeType === "entity")!;
114+
const attr = data.nodes.find((n) => n.nodeType === "attribute")!;
115+
expect(entity.style?.stroke).toBe("#000000");
116+
expect(attr.style?.stroke).toBe("#000000");
117+
});
118+
119+
it("labelMode='comment' shows column comment instead of name (falls back to name)", () => {
120+
const data = generateChenModelData([usersTable], [], true, "comment");
121+
const attrs = data.nodes.filter((n) => n.nodeType === "attribute");
122+
// id has comment "user id"
123+
expect(attrs[0].label).toBe("user id");
124+
// name has no comment → falls back to name
125+
expect(attrs[1].label).toBe("name");
126+
});
127+
128+
it("labelMode='any' prefers comment when present, name otherwise", () => {
129+
const data = generateChenModelData([usersTable], [], true, "any");
130+
const attrs = data.nodes.filter((n) => n.nodeType === "attribute");
131+
expect(attrs[0].label).toBe("user id");
132+
expect(attrs[1].label).toBe("name");
133+
});
134+
});
135+
136+
describe("getTextWidth", () => {
137+
it("counts CJK characters at full font width and ASCII at ~0.6 width", () => {
138+
const fontSize = 10;
139+
expect(getTextWidth("ab", fontSize)).toBeCloseTo(12, 5); // 0.6 * 2 * 10
140+
expect(getTextWidth("中", fontSize)).toBeCloseTo(10, 5);
141+
expect(getTextWidth("a中", fontSize)).toBeCloseTo(16, 5);
142+
expect(getTextWidth("", fontSize)).toBe(0);
143+
});
144+
});
145+
146+
describe("estimateAttributeHalfSize", () => {
147+
it("never goes below the configured minimum (60 wide / 40 tall halved)", () => {
148+
const { halfW, halfH } = estimateAttributeHalfSize("");
149+
expect(halfW).toBeGreaterThanOrEqual(30);
150+
expect(halfH).toBeGreaterThanOrEqual(20);
151+
});
152+
153+
it("grows for longer labels", () => {
154+
const small = estimateAttributeHalfSize("a");
155+
const large = estimateAttributeHalfSize(
156+
"a_very_long_attribute_label_indeed",
157+
);
158+
expect(large.halfW).toBeGreaterThan(small.halfW);
159+
});
160+
});

src/test/exporter-xml.test.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildDrawioXML, escapeXml } from "../exporter";
3+
import type {
4+
EREdgeModel,
5+
ERNodeModel,
6+
GraphEdgeLike,
7+
GraphLike,
8+
GraphNodeLike,
9+
} from "../types";
10+
11+
describe("escapeXml", () => {
12+
it("escapes the five canonical XML entities", () => {
13+
expect(escapeXml(`<a href="x">x & y</a>`)).toBe(
14+
"&lt;a href=&quot;x&quot;&gt;x &amp; y&lt;/a&gt;",
15+
);
16+
expect(escapeXml("It's")).toBe("It&apos;s");
17+
});
18+
19+
it("treats null/undefined as empty string", () => {
20+
expect(escapeXml(null)).toBe("");
21+
expect(escapeXml(undefined)).toBe("");
22+
});
23+
24+
it("stringifies non-string inputs", () => {
25+
expect(escapeXml(42)).toBe("42");
26+
expect(escapeXml(true)).toBe("true");
27+
});
28+
});
29+
30+
// 构造仅 buildDrawioXML 需要的 GraphLike 子集。
31+
const buildNode = (
32+
model: ERNodeModel,
33+
bbox: { minX: number; minY: number; width: number; height: number },
34+
): GraphNodeLike =>
35+
({
36+
getModel: () => model,
37+
getBBox: () => ({
38+
...bbox,
39+
maxX: bbox.minX + bbox.width,
40+
maxY: bbox.minY + bbox.height,
41+
centerX: bbox.minX + bbox.width / 2,
42+
centerY: bbox.minY + bbox.height / 2,
43+
}),
44+
}) as unknown as GraphNodeLike;
45+
46+
const buildEdge = (model: EREdgeModel): GraphEdgeLike =>
47+
({ getModel: () => model }) as unknown as GraphEdgeLike;
48+
49+
const buildGraph = (
50+
nodes: GraphNodeLike[],
51+
edges: GraphEdgeLike[],
52+
): GraphLike =>
53+
({
54+
destroyed: false,
55+
getNodes: () => nodes,
56+
getEdges: () => edges,
57+
findById: () => null,
58+
updateItem: () => {},
59+
setAutoPaint: () => {},
60+
paint: () => {},
61+
refreshPositions: () => {},
62+
get: () => null,
63+
getZoom: () => 1,
64+
}) as unknown as GraphLike;
65+
66+
describe("buildDrawioXML", () => {
67+
it("emits a well-formed mxfile root with one mxCell per node + edge", () => {
68+
const nodes = [
69+
buildNode(
70+
{
71+
id: "entity-users-0",
72+
label: "users",
73+
nodeType: "entity",
74+
style: { fill: "#ffffff", stroke: "#000000", lineWidth: 2 },
75+
},
76+
{ minX: 100, minY: 80, width: 120, height: 60 },
77+
),
78+
buildNode(
79+
{
80+
id: "attr-users-id-0-0",
81+
label: "id",
82+
nodeType: "attribute",
83+
keyType: "pk",
84+
style: { fill: "#fffbe6", stroke: "#52c41a", lineWidth: 2 },
85+
},
86+
{ minX: 200, minY: 200, width: 60, height: 40 },
87+
),
88+
];
89+
const edges = [
90+
buildEdge({
91+
source: "entity-users-0",
92+
target: "attr-users-id-0-0",
93+
edgeType: "entity-attribute",
94+
style: { stroke: "#000000", lineWidth: 1 },
95+
}),
96+
];
97+
98+
const xml = buildDrawioXML(buildGraph(nodes, edges));
99+
100+
// Header / shell
101+
expect(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>')).toBe(true);
102+
expect(xml).toContain("<mxfile");
103+
expect(xml).toContain("</mxfile>");
104+
expect(xml).toMatch(/<diagram id="sql2er-[a-z0-9-]+" name="ER">/);
105+
106+
// Two vertex cells (renumbered to v0, v1) + one edge cell
107+
expect((xml.match(/vertex="1"/g) || []).length).toBe(2);
108+
expect((xml.match(/edge="1"/g) || []).length).toBe(1);
109+
// Edge endpoints should reference the renumbered vertex ids
110+
expect(xml).toContain('source="v0"');
111+
expect(xml).toContain('target="v1"');
112+
113+
// Geometry uses rounded ints
114+
expect(xml).toContain('<mxGeometry x="100" y="80" width="120" height="60"');
115+
});
116+
117+
it("XML-escapes node and edge labels", () => {
118+
const xml = buildDrawioXML(
119+
buildGraph(
120+
[
121+
buildNode(
122+
{
123+
id: "n",
124+
label: `<bad & "quoted">`,
125+
nodeType: "entity",
126+
},
127+
{ minX: 0, minY: 0, width: 10, height: 10 },
128+
),
129+
],
130+
[],
131+
),
132+
);
133+
expect(xml).toContain(
134+
'value="&lt;bad &amp; &quot;quoted&quot;&gt;"',
135+
);
136+
expect(xml).not.toContain('value="<bad');
137+
});
138+
139+
it("uses different style strings for entity / attribute / relationship nodes", () => {
140+
const xml = buildDrawioXML(
141+
buildGraph(
142+
[
143+
buildNode(
144+
{ id: "e", label: "E", nodeType: "entity" },
145+
{ minX: 0, minY: 0, width: 10, height: 10 },
146+
),
147+
buildNode(
148+
{ id: "a", label: "A", nodeType: "attribute", keyType: "pk" },
149+
{ minX: 0, minY: 0, width: 10, height: 10 },
150+
),
151+
buildNode(
152+
{ id: "r", label: "R", nodeType: "relationship" },
153+
{ minX: 0, minY: 0, width: 10, height: 10 },
154+
),
155+
],
156+
[],
157+
),
158+
);
159+
// Entity: rectangle (no shape prefix), attribute: ellipse, relationship: rhombus
160+
expect(xml).toMatch(/value="E"[^/]*style="rounded=0/);
161+
expect(xml).toMatch(/value="A"[^/]*style="ellipse/);
162+
expect(xml).toMatch(/value="R"[^/]*style="rhombus/);
163+
});
164+
165+
it("skips edges whose source or target id is unknown", () => {
166+
const nodes = [
167+
buildNode(
168+
{ id: "e1", label: "e1", nodeType: "entity" },
169+
{ minX: 0, minY: 0, width: 10, height: 10 },
170+
),
171+
];
172+
const edges = [
173+
buildEdge({ source: "e1", target: "ghost" }),
174+
buildEdge({ source: "ghost", target: "e1" }),
175+
];
176+
const xml = buildDrawioXML(buildGraph(nodes, edges));
177+
expect((xml.match(/edge="1"/g) || []).length).toBe(0);
178+
});
179+
180+
it("marks dashed-style nodes with dashed=1", () => {
181+
const xml = buildDrawioXML(
182+
buildGraph(
183+
[
184+
buildNode(
185+
{
186+
id: "p",
187+
label: "missing",
188+
nodeType: "entity",
189+
style: { lineDash: [4, 4] },
190+
},
191+
{ minX: 0, minY: 0, width: 10, height: 10 },
192+
),
193+
],
194+
[],
195+
),
196+
);
197+
expect(xml).toContain("dashed=1");
198+
});
199+
});

0 commit comments

Comments
 (0)