254 lines
7.7 KiB
TypeScript
254 lines
7.7 KiB
TypeScript
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");
|
|
});
|
|
});
|