import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import type { PhyFile, KeyNode } from "../src/domain/phy"; import { flattenKeys } from "../src/domain/geometry"; import { importKle } from "../src/io/kleImport"; import { exportKle } from "../src/io/kleExport"; import { canonicalize, sha256Jcs } from "../src/io/json"; import { addVariant, clusterNodes, createVariantGroup, deleteVariant, reframe, reparent, uncluster, } from "../src/domain/phyCommands"; const gen = (() => { let i = 0; return () => { const s = String(++i).padStart(12, "0"); return `${s.slice(0, 8)}-${s.slice(8)}-4000-8000-000000000000`; }; })(); function sample(path: string): unknown { return JSON.parse(readFileSync(resolve(process.cwd(), "../samples/kle", path), "utf8")) as unknown; } function byShape(p: PhyFile) { return flattenKeys(p) .map((k) => ({ x: k.x, y: k.y, r: k.rotation, w: k.key.size.w, h: k.key.size.h, labels: k.key.labels, compat: k.key.kleCompatibility, })) .sort( (a, b) => Math.round(a.r * 1e6) - Math.round(b.r * 1e6) || Math.round(a.y * 1e6) - Math.round(b.y * 1e6) || Math.round(a.x * 1e6) - Math.round(b.x * 1e6), ); } describe("KLE round trip", () => { for (const file of ["macropad-4x4.json", "cr0wn60.json"]) it(file, () => { const a = importKle(sample(file), { idGenerator: gen }).phy; const b = importKle(exportKle(a), { idGenerator: gen }).phy; const aa = byShape(a), bb = byShape(b); expect(bb).toHaveLength(aa.length); for (let i = 0; i < aa.length; i++) { const x = aa[i]!, y = bb[i]!; expect( Math.abs(x.x - y.x), `${file} item ${i}: x ${x.x} vs ${y.x}; y=${x.y}/${y.y}; rot=${x.r}/${y.r}; labels=${JSON.stringify(x.labels)}/${JSON.stringify(y.labels)}`, ).toBeLessThanOrEqual(1e-6); expect(Math.abs(x.y - y.y), `${file} item ${i}: y ${x.y} vs ${y.y}`).toBeLessThanOrEqual(1e-6); expect(Math.abs(x.r - y.r)).toBeLessThanOrEqual(1e-6); expect(x.w).toBe(y.w); expect(x.h).toBe(y.h); expect(x.labels).toEqual(y.labels); expect(x.compat).toEqual(y.compat); } }); it("rotated KLE cursor, mid-row rotation and pivot changes", () => { const { phy } = importKle([[{ r: 90, rx: 1, ry: 1 }, "A", "B", { r: 180, rx: 2, ry: 1 }, "C"]], { idGenerator: gen, }); const k = phy.root as KeyNode[]; expect(k.map((x) => [x.transform.x, x.transform.y, x.transform.rotation])).toEqual([ [1, 1, 90], [1, 2, 90], [2, 1, 180], ]); const corners = (n: KeyNode) => [ [0, 0], [n.size.w, 0], [n.size.w, n.size.h], [0, n.size.h], ].map(([x, y]) => { const a = (n.transform.rotation * Math.PI) / 180; return [ n.transform.x + Math.cos(a) * x! - Math.sin(a) * y!, n.transform.y + Math.sin(a) * x! + Math.cos(a) * y!, ]; }); const check = (actual: number[][], expected: number[][]) => actual.forEach((p, i) => p.forEach((v, j) => expect(Math.abs(v - expected[i]![j]!)).toBeLessThanOrEqual(1e-12)), ); check(corners(k[0]!), [ [1, 1], [1, 2], [0, 2], [0, 1], ]); check(corners(k[2]!), [ [2, 1], [1, 1], [1, 0], [2, 0], ]); }); }); function fixture(): PhyFile { return { version: "0.1.0", metadata: {}, profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }], root: [ { type: "cluster", id: "outer", name: "Outer", transform: { x: 4, y: 2, rotation: 30 }, children: [ { type: "cluster", id: "inner", name: "Inner", transform: { x: 1, y: 2, rotation: 20 }, children: [ { type: "key", id: "a", profile: "default", transform: { x: 0.2, y: 0.4, rotation: 15 }, size: { w: 1, h: 1 }, }, { type: "key", id: "b", profile: "default", transform: { x: 1.4, y: -0.2, rotation: -5 }, size: { w: 1, h: 1 }, }, ], }, ], }, { type: "cluster", id: "other", name: "Other", transform: { x: -3, y: 6, rotation: -12 }, children: [], }, ], }; } const pose = (p: PhyFile) => Object.fromEntries(flattenKeys(p).map((k) => [k.key.id, [k.x, k.y, k.rotation]])); const closePose = (a: Record, b: Record) => { for (const id of Object.keys(a)) { a[id]!.forEach((v, i) => expect(Math.abs(v - b[id]![i]!), `pose ${id} component ${i}: ${v} vs ${b[id]![i]}`).toBeLessThanOrEqual( 1e-9, ), ); } }; describe("PHY commands and variants", () => { it("preserves nested rotated key world placement through cluster, uncluster, reparent, reframe", () => { let p = fixture(); const before = pose(p); p = clusterNodes(p, ["a", "b"], "picked", gen); closePose(before, pose(p)); const outer = p.root[0]!; if (outer.type !== "cluster") throw Error(); const inner = outer.children[0]!; if (inner.type !== "cluster") throw Error(); const created = inner.children[0]!; if (created.type !== "cluster") throw Error(); p = uncluster(p, created.id); closePose(before, pose(p)); p = reparent(p, "a", "other"); closePose(before, pose(p)); p = reframe(p, "outer", { x: 8, y: -4, rotation: -20 }); closePose(before, pose(p)); }); it("flattens variant selection, exports only the selected branch, and applies deletion rules", () => { let p: PhyFile = { version: "0.1.0", metadata: {}, profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }], root: [], }; p = createVariantGroup( p, "Layout", { x: 2, y: 0, rotation: 0 }, [ { name: "ANSI", children: [ { type: "key", id: "ansi", profile: "default", transform: { x: 0, y: 0, rotation: 0 }, size: { w: 1, h: 1 }, labels: ["Enter"], }, ], }, { name: "ISO", children: [ { type: "key", id: "iso", profile: "default", transform: { x: 0, y: 0, rotation: 0 }, size: { w: 1, h: 1 }, labels: ["Enter"], }, ], }, ], gen, ); const group = p.root[0]!; if (group.type !== "variantGroup") throw Error(); expect(flattenKeys(p).map((x) => x.key.id)).toEqual(["ansi"]); const choice = group.children[1]!.id; expect(flattenKeys(p, { [group.id]: choice }).map((x) => x.key.id)).toEqual(["iso"]); const out = JSON.stringify(exportKle(p, { [group.id]: choice })); expect(out).not.toContain('"ansi"'); expect(() => deleteVariant(p, group.id, group.children[0]!.id)).toThrow(); p = addVariant(p, group.id, "JIS", gen); const grown = p.root[0]!; if (grown.type !== "variantGroup") throw Error(); p = deleteVariant(p, group.id, grown.default); const reduced = p.root[0]!; if (reduced.type !== "variantGroup") throw Error(); expect(reduced.default).toBe(reduced.children[0]!.id); }); }); describe("JCS", () => { it("sorts keys and uses ECMAScript number formatting", () => { expect(canonicalize({ z: 1, a: 1e-6, b: 1e21, c: -0 })).toBe('{"a":0.000001,"b":1e+21,"c":0,"z":1}'); expect(canonicalize([333333333.33333329, 1e30, 4.5, 2e-3, 1e-27])).toBe( "[333333333.3333333,1e+30,4.5,0.002,1e-27]", ); }); it("hashes canonical UTF-8 with SHA-256", async () => { expect(await sha256Jcs({ a: 1 })).toBe( "sha256-jcs:015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862", ); }); });