Release Diff Tools 0.1.0

This commit is contained in:
2026-09-01 02:45:20 +02:00
commit a1380cfba2
65 changed files with 11442 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
import { describe, expect, it } from "vitest";
import { compareInputs } from "../../src/core/compare";
import { DEFAULT_OPTIONS, type CompareRequest } from "../../src/core/types";
function request(
mode: CompareRequest["mode"],
left: string,
right: string,
overrides: Partial<CompareRequest["options"]> = {},
): CompareRequest {
return {
mode,
left,
right,
options: {
text: { ...DEFAULT_OPTIONS.text, ...overrides.text },
json: { ...DEFAULT_OPTIONS.json, ...overrides.json },
xml: { ...DEFAULT_OPTIONS.xml, ...overrides.xml },
csv: { ...DEFAULT_OPTIONS.csv, ...overrides.csv },
},
};
}
describe("text comparison", () => {
it("preserves exact final-newline and line-ending state", () => {
const result = compareInputs(request("text", "one\r\ntwo", "one\ntwo\n"));
expect(result.left.newlines).toEqual({
lf: 0,
crlf: 1,
cr: 0,
final: "none",
});
expect(result.right.newlines).toEqual({
lf: 2,
crlf: 0,
cr: 0,
final: "lf",
});
expect(result.exactlyEqual).toBe(false);
expect(result.unifiedPatch).toContain("\\ No newline at end of file");
});
it("shows source differences that compare equal after normalization", () => {
const result = compareInputs(
request("text", "Cafe\u0301\r\n", "CAFÉ\n", {
text: {
...DEFAULT_OPTIONS.text,
unicodeNormalization: "NFC",
ignoreCase: true,
ignoreLineEndingStyle: true,
},
}),
);
expect(result.semanticallyEqual).toBe(true);
expect(result.rows).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "normalized" })]),
);
});
it.each(["line", "word", "code-point", "grapheme"] as const)(
"supports %s granularity",
(granularity) => {
const result = compareInputs(
request("text", "A 👨‍👩‍👧", "A 👨‍👩‍👦", {
text: { ...DEFAULT_OPTIONS.text, granularity },
}),
);
expect(
result.stats.modified + result.stats.added + result.stats.removed,
).toBeGreaterThan(0);
},
);
it.each(["line", "word", "code-point", "grapheme"] as const)(
"normalizes line-ending style at %s granularity",
(granularity) => {
const result = compareInputs(
request("text", "A\r\nB", "A\nB", {
text: {
...DEFAULT_OPTIONS.text,
granularity,
ignoreLineEndingStyle: true,
},
}),
);
expect(result.semanticallyEqual).toBe(true);
expect(result.stats.normalized).toBeGreaterThan(0);
},
);
});
describe("semantic JSON", () => {
it("compares object order and exact decimal value semantically", () => {
const result = compareInputs(
request(
"json",
'{"amount":1.00,"large":9007199254740993123456789,"name":"Ada"}',
'{"name":"Ada","large":9007199254740993123456789,"amount":1e0}',
),
);
expect(result.semanticallyEqual).toBe(true);
expect(result.exactlyEqual).toBe(false);
expect(result.stats.normalized).toBeGreaterThanOrEqual(2);
expect(result.jsonPatch).toBe("[]\n");
});
it("reports common-member reordering alongside additions", () => {
const result = compareInputs(
request("json", '{"a":1,"b":2}', '{"b":2,"a":1,"c":3}'),
);
expect(result.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "normalized", path: "" }),
expect.objectContaining({ kind: "added", path: "/c" }),
]),
);
});
it("emits an exact RFC 6902 patch", () => {
const result = compareInputs(
request(
"json",
'{"amount":1,"items":["a","b"]}',
'{"amount":9007199254740993123456789,"items":["a","c","d"]}',
),
);
expect(result.jsonPatch).toContain("9007199254740993123456789");
expect(result.jsonPatch).toContain('"op": "replace"');
expect(result.jsonPatch).toContain('"op": "add"');
});
it("rejects duplicates and prototype-affecting keys", () => {
expect(() =>
compareInputs(request("json", '{"a":1,"a":2}', '{"a":2}')),
).toThrow(/Duplicate JSON key/iu);
expect(() =>
compareInputs(request("json", '{"__proto__":1}', "{}")),
).toThrow(/prototype-affecting/iu);
});
});
describe("namespace-aware XML", () => {
it("ignores prefixes and attribute order while keeping them visible", () => {
const result = compareInputs(
request(
"xml",
'<a:root xmlns:a="urn:x" id="1" role="test"><![CDATA[ hello ]]></a:root>',
'<b:root role="test" id="1" xmlns:b="urn:x"> hello </b:root>',
),
);
expect(result.semanticallyEqual).toBe(true);
expect(result.stats.normalized).toBeGreaterThan(0);
});
it("rejects DOCTYPE and XInclude before comparison", () => {
expect(() =>
compareInputs(request("xml", "<!DOCTYPE x><x/>", "<x/>")),
).toThrow(/rejected/iu);
expect(() =>
compareInputs(
request(
"xml",
'<x xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="x"/></x>',
"<x/>",
),
),
).toThrow(/XInclude/iu);
});
it("rejects excessive XML nesting before DOM construction", () => {
const deeplyNested = `${"<x>".repeat(129)}${"</x>".repeat(129)}`;
expect(() => compareInputs(request("xml", deeplyNested, "<x/>"))).toThrow(
/XML depth/iu,
);
});
it("treats attribute order as substantive when normalization is disabled", () => {
const result = compareInputs(
request("xml", '<x a="1" b="2"/>', '<x b="2" a="1"/>', {
xml: { ...DEFAULT_OPTIONS.xml, ignoreAttributeOrder: false },
}),
);
expect(result.semanticallyEqual).toBe(false);
expect(result.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "modified", path: "/x[1]/@*" }),
]),
);
});
});
describe("keyed CSV", () => {
it("matches rows by key and compares fields as strings", () => {
const result = compareInputs(
request(
"csv",
"id,name,value\n2,Grace,001\n1,Ada,10",
"value,id,name\n10,1,Ada\n002,2,Grace",
{ csv: { ...DEFAULT_OPTIONS.csv, keyColumns: "id" } },
),
);
expect(result.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "normalized", path: "$columns" }),
expect.objectContaining({
kind: "modified",
left: "001",
right: "002",
}),
]),
);
});
it("rejects duplicate keys with a row diagnostic", () => {
expect(() =>
compareInputs(
request("csv", "id,name\n1,A\n1,B", "id,name\n1,A", {
csv: { ...DEFAULT_OPTIONS.csv, keyColumns: "id" },
}),
),
).toThrow(/Duplicate key/iu);
});
it("keeps common row and column reordering visible when items are added", () => {
const result = compareInputs(
request(
"csv",
"id,a,b\n1,A,B\n2,C,D",
"b,id,a,extra\nD,2,C,x\nB,1,A,y\nZ,3,E,z",
{ csv: { ...DEFAULT_OPTIONS.csv, keyColumns: "id" } },
),
);
expect(result.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "normalized", path: "$columns" }),
expect.objectContaining({ kind: "normalized", path: "$rows" }),
expect.objectContaining({ kind: "added", path: "$columns/extra" }),
]),
);
});
});
describe("portable artifacts", () => {
it("produces a schema-labelled JSON report without library HTML", () => {
const result = compareInputs(request("text", "before\n", "after\n"));
expect(JSON.parse(result.report)).toMatchObject({
schema: "de.add-ideas.diff-tools.report.v1",
generatedLocally: true,
});
expect(result.report).not.toContain("<ins");
expect(result.unifiedPatch).toContain("--- before.txt");
});
});