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 { 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", '', ' hello ', ), ); expect(result.semanticallyEqual).toBe(true); expect(result.stats.normalized).toBeGreaterThan(0); }); it("rejects DOCTYPE and XInclude before comparison", () => { expect(() => compareInputs(request("xml", "", "")), ).toThrow(/rejected/iu); expect(() => compareInputs( request( "xml", '', "", ), ), ).toThrow(/XInclude/iu); }); it("rejects excessive XML nesting before DOM construction", () => { const deeplyNested = `${"".repeat(129)}${"".repeat(129)}`; expect(() => compareInputs(request("xml", deeplyNested, ""))).toThrow( /XML depth/iu, ); }); it("treats attribute order as substantive when normalization is disabled", () => { const result = compareInputs( request("xml", '', '', { 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("