import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import type { KeyNode, PhyFile } from "../src/domain/phy"; import { flattenKeys } from "../src/domain/geometry"; import { clusterNodes, reframe, reparent } from "../src/domain/phyCommands"; import { importKle } from "../src/io/kleImport"; import { exportKle } from "../src/io/kleExport"; import { createIdx } from "../src/domain/idx"; type OracleKey = { x: number; y: number; width: number; height: number; x2: number; y2: number; width2: number; height2: number; rotation_angle: number; rotation_x: number; rotation_y: number; labels: (string | null)[]; textColor: (string | null)[]; textSize: (number | null)[]; default: { textColor: string; textSize: number }; color: string; profile: string; nub: boolean; stepped: boolean; decal: boolean; ghost: boolean; sm: string; sb: string; st: string; }; type Case = { input: unknown[]; meta: Record; keys: OracleKey[] }; const oracle = JSON.parse( readFileSync(resolve(process.cwd(), "tests/fixtures/kle-oracle.json"), "utf8"), ) as Record; // Independent expected lookup derived from the frozen serial.js output fixture. const labelMap: readonly (readonly (number | null)[])[] = [ [0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5], [null, 0, null, null, 6, null, null, 1, null, 4, 11, 5], [null, null, null, 0, 8, 2, null, null, null, 4, 11, 5], [null, null, null, null, 0, null, null, null, null, 4, 11, 5], [0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null], [null, 0, null, null, 6, null, null, 1, null, null, 4, null], [null, null, null, 0, 8, 2, null, null, null, null, 4, null], [null, null, null, null, 0, null, null, null, null, null, 4, null], ]; const uuid = (() => { let i = 0; return () => `12345678-90ab-4cde-8f01-${String(++i).padStart(12, "0")}`; })(); const angleRad = (degrees: number) => (degrees * Math.PI) / 180; const near = (actual: number, expected: number, tolerance = 1e-9) => expect(Math.abs(actual - expected), `${actual} differs from ${expected}`).toBeLessThanOrEqual(tolerance); function canonicalLabels(labels: (string | null)[]): string[] { const result = labels.map((label) => label ?? ""); while (result.at(-1) === "") result.pop(); return result; } function oracleOrigin(k: OracleKey): { x: number; y: number } { const r = angleRad(k.rotation_angle), dx = k.x - k.rotation_x, dy = k.y - k.rotation_y; return { x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy, y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy, }; } function expectedCorners(k: OracleKey, second = false): { x: number; y: number }[] { const r = angleRad(k.rotation_angle), ox = k.x + (second ? k.x2 : 0), oy = k.y + (second ? k.y2 : 0); const w = second ? k.width2 : k.width, h = second ? k.height2 : k.height; return [ [0, 0], [w, 0], [w, h], [0, h], ].map(([x, y]) => { const px = ox + x!, py = oy + y!, dx = px - k.rotation_x, dy = py - k.rotation_y; return { x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy, y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy, }; }); } function keyCorners(k: KeyNode, second = false): { x: number; y: number }[] { const r = angleRad(k.transform.rotation), ox = second ? (k.size.x2 ?? 0) : 0, oy = second ? (k.size.y2 ?? 0) : 0; const w = second ? (k.size.w2 ?? k.size.w) : k.size.w, h = second ? (k.size.h2 ?? k.size.h) : k.size.h; return [ [0, 0], [w, 0], [w, h], [0, h], ].map(([x, y]) => ({ x: k.transform.x + Math.cos(r) * (ox + x!) - Math.sin(r) * (oy + y!), y: k.transform.y + Math.sin(r) * (ox + x!) + Math.cos(r) * (oy + y!), })); } function expectedStyleArrays(k: OracleKey, key: KeyNode): void { const compat = key.kleCompatibility ?? {}, a = typeof compat.a === "number" ? compat.a : 4; const slots = labelMap[a] ?? labelMap[4]!; const t = typeof compat.t === "string" ? compat.t.split("\n") : []; expect(compat.c).toBe(k.color); expect(compat.p).toBe(k.profile); expect(compat.sm).toBe(k.sm); expect(compat.sb).toBe(k.sb); expect(compat.st).toBe(k.st); expect(compat.l === true).toBe(k.stepped); expect(compat.n === true).toBe(k.nub); expect(key.decal === true).toBe(k.decal); expect(key.ghost === true).toBe(k.ghost); expect(compat.f).toBe(k.default.textSize); const defaultTextColor = t[0] ?? "#000000"; expect(k.default.textColor).toBe(defaultTextColor); const expectedColors = Array(12).fill(null), expectedSizes = Array(12).fill(null); slots.forEach((source, slot) => { const hasLabel = (key.labels?.[slot] ?? "") !== ""; if (hasLabel && source !== null && source > 0 && source < t.length) expectedColors[slot] = t[source]!; if (hasLabel && source !== null && source >= 0) { if (Array.isArray(compat.fa) && source < compat.fa.length && typeof compat.fa[source] === "number") expectedSizes[slot] = compat.fa[source] as number; else if (source > 0 && typeof compat.f2 === "number") expectedSizes[slot] = compat.f2; } }); const oracleColors = Array.from({ length: 12 }, (_, i) => k.textColor[i] ?? null); const oracleSizes = Array.from({ length: 12 }, (_, i) => k.textSize[i] ?? null); expect(expectedColors).toEqual(oracleColors); expect(expectedSizes).toEqual(oracleSizes); for (let i = 0; i < 12; i++) { expect(expectedColors[i]).toBe(oracleColors[i]); expect(expectedSizes[i]).toBe(oracleSizes[i]); } } function verify(phy: PhyFile, expected: Case, tolerance = 1e-9): void { const keys = flattenKeys(phy).map((x) => x.key); expect(keys).toHaveLength(expected.keys.length); expect(phy.kleCompatibility).toEqual(expected.meta); const unmatched = [...keys]; for (const want of expected.keys) { const origin = oracleOrigin(want), labels = canonicalLabels(want.labels); const index = unmatched.findIndex( (candidate) => JSON.stringify(candidate.labels ?? []) === JSON.stringify(labels) && Math.abs(candidate.transform.x - origin.x) < 1e-6 && Math.abs(candidate.transform.y - origin.y) < 1e-6, ); expect( index, `missing oracle key labels=${JSON.stringify(labels)} at ${origin.x},${origin.y}`, ).toBeGreaterThanOrEqual(0); const actual = unmatched.splice(index, 1)[0]!; near(actual.transform.x, origin.x, tolerance); near(actual.transform.y, origin.y, tolerance); near(actual.transform.rotation, want.rotation_angle, tolerance); expect(actual.labels ?? []).toEqual(labels); expect(actual.size.w).toBe(want.width); expect(actual.size.h).toBe(want.height); expect(actual.size.x2 ?? 0).toBe(want.x2); expect(actual.size.y2 ?? 0).toBe(want.y2); expect(actual.size.w2 ?? actual.size.w).toBe(want.width2); expect(actual.size.h2 ?? actual.size.h).toBe(want.height2); const actualMain = keyCorners(actual), expectedMain = expectedCorners(want); const actualSecond = keyCorners(actual, true), expectedSecond = expectedCorners(want, true); [...actualMain, ...actualSecond].forEach((point, i) => { const target = [...expectedMain, ...expectedSecond][i]!; near(point.x, target.x, tolerance); near(point.y, target.y, tolerance); }); expectedStyleArrays(want, actual); } } describe("KLE importer against frozen serial.js oracle", () => { for (const [name, testCase] of Object.entries(oracle)) { it(`${name}: import and export/import match oracle`, () => { const first = importKle(testCase.input, { idGenerator: uuid }); verify(first.phy, testCase); const eligible = first.phy.root.filter((node): node is KeyNode => node.type === "key" && !node.decal); expect(first.keyOrder).toEqual(eligible.map((key) => key.id)); const second = importKle(exportKle(first.phy), { idGenerator: uuid }); verify(second.phy, testCase, 1e-6); }); } }); describe("PHY regression cases from Sol review", () => { const base = (root: PhyFile["root"]): PhyFile => ({ version: "0.1.0", metadata: {}, profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }], root, }); it("reparent rejects self and descendants regardless of moved node kind", () => { const phy = base([ { type: "cluster", id: "parent", transform: { x: 0, y: 0, rotation: 0 }, children: [ { type: "cluster", id: "child", transform: { x: 0, y: 0, rotation: 0 }, children: [ { type: "cluster", id: "grandchild", transform: { x: 0, y: 0, rotation: 0 }, children: [] }, ], }, ], }, { type: "variantGroup", id: "group", name: "G", transform: { x: 0, y: 0, rotation: 0 }, default: "v", children: [ { type: "variant", id: "v", name: "V", children: [ { type: "cluster", id: "inside", transform: { x: 0, y: 0, rotation: 0 }, children: [] }, ], }, { type: "variant", id: "v2", name: "V2", children: [] }, ], }, ]); expect(() => reparent(phy, "parent", "parent")).toThrow(); expect(() => reparent(phy, "parent", "child")).toThrow(); expect(() => reparent(phy, "child", "child")).toThrow(); expect(() => reparent(phy, "child", "grandchild")).toThrow(); expect(() => reparent(phy, "group", "inside")).toThrow(); }); it("cluster frame starts at the rotated shape bounds, including second rectangles", () => { const phy = base([ { type: "key", id: "wide", profile: "default", transform: { x: 2, y: 2, rotation: 90 }, size: { w: 2, h: 1, x2: -1, y2: -0.5, w2: 1, h2: 1 }, }, { type: "key", id: "other", profile: "default", transform: { x: 5, y: 2, rotation: 90 }, size: { w: 1, h: 1 }, }, ]); const grouped = clusterNodes( phy, ["wide", "other"], "block", () => "12345678-90ab-4cde-8f01-000000000001", ); const cluster = grouped.root[0]!; expect(cluster.type).toBe("cluster"); if (cluster.type !== "cluster") throw new Error("Expected cluster"); // In the cluster's 90-degree coordinate frame the second rectangle extends left/up. near(cluster.transform.rotation, 90); near(cluster.transform.x, 5); near(cluster.transform.y, 1); }); it("flattening propagates raw ancestor rotations without normalizing", () => { const phy = base([ { type: "cluster", id: "a", transform: { x: 0, y: 0, rotation: 450 }, children: [ { type: "cluster", id: "b", transform: { x: 0, y: 0, rotation: 400 }, children: [ { type: "key", id: "k", profile: "default", transform: { x: 0, y: 0, rotation: -20 }, size: { w: 1, h: 1 }, }, ], }, ], }, ]); expect(flattenKeys(phy)[0]!.rotation).toBe(830); }); it("cluster, uncluster, reparent, and reframe retain raw world angles", () => { let phy = base([ { type: "cluster", id: "outer", transform: { x: 1, y: 2, rotation: 400 }, children: [ { type: "key", id: "a", profile: "default", transform: { x: 0, y: 0, rotation: 50 }, size: { w: 1, h: 1 }, }, { type: "key", id: "b", profile: "default", transform: { x: 2, y: 0, rotation: 50 }, size: { w: 1, h: 1 }, }, ], }, { type: "cluster", id: "target", transform: { x: -1, y: 4, rotation: 720 }, children: [] }, ]); const rotations = () => Object.fromEntries(flattenKeys(phy).map(({ key, rotation }) => [key.id, rotation])); expect(rotations()).toEqual({ a: 450, b: 450 }); phy = clusterNodes(phy, ["a", "b"], "nested", uuid); expect(rotations()).toEqual({ a: 450, b: 450 }); const outer = phy.root[0]!; if (outer.type !== "cluster") throw new Error("Expected outer cluster"); const nested = outer.children[0]!; if (nested.type !== "cluster") throw new Error("Expected nested cluster"); phy = reframe(phy, nested.id, { x: 3, y: -2, rotation: 810 }); expect(rotations()).toEqual({ a: 450, b: 450 }); phy = reparent(phy, "a", "target"); expect(rotations()).toEqual({ a: 450, b: 450 }); phy = clusterNodes(phy, ["b"], "single", uuid); expect(rotations()).toEqual({ a: 450, b: 450 }); }); it("T-18 initial order filters decal and retains ghost", () => { const phy = base([ { type: "key", id: "decal", profile: "default", transform: { x: 0, y: 0, rotation: 0 }, size: { w: 1, h: 1 }, decal: true, }, { type: "key", id: "ghost", profile: "default", transform: { x: 1, y: 0, rotation: 0 }, size: { w: 1, h: 1 }, ghost: true, }, ]); const imported = importKle([[{ d: true }, "D", { g: true }, "G"]], { idGenerator: uuid }); expect(imported.keyOrder).toHaveLength(1); const idx = createIdx(phy, ["decal", "ghost"], { source: "x", checksum: "sha256-jcs:x" }); expect(idx.order).toEqual(["ghost"]); }); });