pcbjam/web/standalone/src/wasm/collab/sexpr-diff.test.ts
Gergő Törcsvári f29ceb4964
feat(standalone): sexprDiff structural comparator + vitest (0003/0004 §B)
Add the shared, uuid-keyed structural s-expr comparator (README §B) used by both
the live drift check (0003) and the round-trip tests (0004), plus a vitest setup.

- wasm/collab/sexpr-diff.ts: zero-dependency module (importable from standalone
  app code and the Playwright specs). Tokenizes s-expr text into nested lists,
  indexes item nodes by their direct (uuid "X") child, compares item sets order-
  insensitively as multisets of normalized children, emitting
  { equal, added, removed, changed } with per-property a/b. ignoreTokens drops
  volatile tokens. False-positive-free only for same-serializer inputs.
- wasm/collab/sexpr-diff.test.ts: 15 cases (parse, equality, changes, ignoreTokens).
- vitest config + test scripts (node env, isolated from the app build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:05:39 +02:00

110 lines
4.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { parseSexpr, sexprDiff } from "./sexpr-diff";
// A tiny item helper: a list node `(type (uuid "ID") <props…>)` as text.
const item = (type: string, id: string, ...props: string[]) =>
`(${type} (uuid "${id}") ${props.join(" ")})`;
describe("parseSexpr", () => {
it("parses nested lists and atoms", () => {
expect(parseSexpr("(a b (c 1))")).toEqual([["a", "b", ["c", "1"]]]);
});
it("keeps quoted strings (with quotes) as single atoms, spaces preserved", () => {
expect(parseSexpr('(text "hello world")')).toEqual([["text", '"hello world"']]);
});
it("handles escaped quotes inside strings", () => {
expect(parseSexpr('(t "a\\"b")')).toEqual([["t", '"a\\"b"']]);
});
it("throws on unbalanced parens", () => {
expect(() => parseSexpr("(a (b)")).toThrow(/unbalanced/);
});
it("throws on an unterminated string", () => {
expect(() => parseSexpr('(a "oops)')).toThrow(/unterminated/);
});
});
describe("sexprDiff — equality", () => {
it("identical text is equal", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)")})`;
expect(sexprDiff(a, a).equal).toBe(true);
});
it("reordered items are equal (keyed by uuid)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const b = `(root ${item("seg", "u2", "(x 2)")} ${item("seg", "u1", "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(true);
});
it("reordered properties within an item are equal", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)", "(layer F)")})`;
const b = `(root ${item("seg", "u1", "(layer F)", "(y 2)", "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(true);
});
it("whitespace differences are irrelevant", () => {
const a = `(root ${item("seg", "u1", "(x 1)")})`;
const b = `(root\n (seg (uuid "u1")\n (x 1) )\n)`;
expect(sexprDiff(a, b).equal).toBe(true);
});
});
describe("sexprDiff — changes", () => {
it("catches a single changed value with uuid + path + a/b", () => {
const a = `(root ${item("seg", "u1", "(x 1)", "(y 2)")})`;
const b = `(root ${item("seg", "u1", "(x 1)", "(y 9)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.changed).toHaveLength(1);
expect(r.changed[0]).toMatchObject({ uuid: "u1", path: "y", a: "(2 y)", b: "(9 y)" });
});
it("distinguishes a quoted string from the same bare token", () => {
const a = `(root ${item("t", "u1", '(val "5")')})`;
const b = `(root ${item("t", "u1", "(val 5)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.changed[0]).toMatchObject({ uuid: "u1", path: "val" });
});
it("detects an added item (present only in B)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")})`;
const b = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.added).toEqual(["u2"]);
expect(r.removed).toEqual([]);
});
it("detects a removed item (present only in A)", () => {
const a = `(root ${item("seg", "u1", "(x 1)")} ${item("seg", "u2", "(x 2)")})`;
const b = `(root ${item("seg", "u1", "(x 1)")})`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
expect(r.removed).toEqual(["u2"]);
expect(r.added).toEqual([]);
});
it("compares nested blob items by their own uuids", () => {
const a = `(root (fp (uuid "f1") (at 0 0) ${item("pad", "p1", "(size 1)")}))`;
const b = `(root (fp (uuid "f1") (at 0 0) ${item("pad", "p1", "(size 2)")}))`;
const r = sexprDiff(a, b);
expect(r.equal).toBe(false);
// The nested pad's value change surfaces under its own uuid…
expect(r.changed.some((c) => c.uuid === "p1" && c.path === "size")).toBe(true);
// …and the parent footprint differs too (it contains the pad subtree).
expect(r.changed.some((c) => c.uuid === "f1")).toBe(true);
});
});
describe("sexprDiff — ignoreTokens", () => {
it("ignores a volatile token so equal-but-for-version compares equal", () => {
const a = `(root ${item("seg", "u1", '(generator_version "8.0")', "(x 1)")})`;
const b = `(root ${item("seg", "u1", '(generator_version "9.9")', "(x 1)")})`;
expect(sexprDiff(a, b).equal).toBe(false); // by default the version diff shows
expect(sexprDiff(a, b, { ignoreTokens: ["generator_version"] }).equal).toBe(true);
});
});