mock: 仮決定に沿ってモックを実装する(PHY Editor・IDX・ELC・MAP・Generator)
provisional/decisions.md の仮決定(T-1〜T-28)に従い、mock/ に Vue 3 + Vite + TypeScript のモックを置いた。 T-3 の1〜3(回転したクラスター内のドラッグと保存の往復、IDX・MAPとの連携、ELCと出力の対応表)まで。疑似デバイスとの往復(T-24)は未着手。 - KLE JSONの入出力は、KLE本家の serial.js の deserialize の結果を期待値とする fixture(tests/fixtures/kle-oracle.json)で照合した。本家のコードは取り込んでいない(T-7) - 基本キーコードの表は QMK の data/constants/keycodes/keycodes_0.0.1_basic.hjson(1e340cd)から抽出した - 実装は Codex(Luna)、レビューは Codex(Sol)。検証: Vitest 53件、Playwright 20件、vue-tsc、vite build - TypeScript は vue-tsc が 7 系に未対応のため 6 系に固定した - 操作性の改善(ELCの提供者の表示、一括配線とGeneratorのピン設定の引き継ぎ、キー内の文字のはみ出し)は別途行う Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createProjectFromKle } from "../src/domain/project";
|
||||
import { saveProject, loadProject } from "../src/io/projectFile";
|
||||
import { validateSchema } from "../src/io/validateSchema";
|
||||
import { appendKeys, unregisteredKeys } from "../src/domain/idx";
|
||||
import { assignKey, isEditableKeycode, parseKeycode, renameLayer, deleteLayer } from "../src/domain/map";
|
||||
import { getKeycodeLabel } from "../src/domain/keycodeLabels";
|
||||
import { qmkBasicKeycodes } from "../src/domain/qmkBasicKeycodes";
|
||||
import { generateMockTarget } from "../src/domain/generator";
|
||||
import { deleteNodeCascade } from "../src/domain/deleteCascade";
|
||||
import { ProjectHistory } from "../src/domain/history";
|
||||
import { validateProject } from "../src/domain/validate";
|
||||
import { mcuPinCandidates } from "../src/domain/elc";
|
||||
const kle = JSON.parse(
|
||||
readFileSync(new URL("../../samples/kle/macropad-4x4.json", import.meta.url), "utf8"),
|
||||
) as unknown;
|
||||
const gen = (() => {
|
||||
let i = 0;
|
||||
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
|
||||
})();
|
||||
describe("domain project flow", () => {
|
||||
it("supports QMK basic keycode names and aliases while preserving entered spelling", async () => {
|
||||
const editable = [
|
||||
"KC_ENT",
|
||||
"KC_ENTER",
|
||||
"KC_ESCAPE",
|
||||
"KC_ESC",
|
||||
"KC_P0",
|
||||
"KC_KP_0",
|
||||
"KC_LCTL",
|
||||
"KC_LEFT_CTRL",
|
||||
"KC_INT1",
|
||||
"KC_LNG1",
|
||||
];
|
||||
for (const code of editable) expect(isEditableKeycode(code), code).toBe(true);
|
||||
|
||||
const formalNames = qmkBasicKeycodes.filter((entry) => entry.group !== "internal");
|
||||
expect(formalNames.length).toBeGreaterThanOrEqual(100);
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const keyId = p.idx.order[0]!;
|
||||
for (const code of editable) {
|
||||
p.map = assignKey(p.map, "Base", keyId, code);
|
||||
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
|
||||
}
|
||||
p.map = assignKey(p.map, "Base", keyId, "LCTL(KC_ENT)");
|
||||
expect(isEditableKeycode("LCTL(KC_ENT)")).toBe(true);
|
||||
p.map.layers.push({ name: "Nav", keymap: [] });
|
||||
p.map = assignKey(p.map, "Base", keyId, "LT(Nav, KC_LNG1)");
|
||||
expect(isEditableKeycode("LT(Nav, KC_LNG1)")).toBe(true);
|
||||
p.map = assignKey(p.map, "Base", keyId, "KC_P0");
|
||||
const roundTrip = await loadProject((await saveProject(p)).json!);
|
||||
expect(roundTrip.project!.map.layers[0]!.keymap[0]!.keycode).toBe("KC_P0");
|
||||
|
||||
for (const code of ["KC_NO", "XXXXXXX", "KC_TRANSPARENT", "KC_TRNS", "_______"]) {
|
||||
expect(isEditableKeycode(code), code).toBe(true);
|
||||
p.map = assignKey(p.map, "Base", keyId, code);
|
||||
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
|
||||
}
|
||||
|
||||
for (const code of ["KC_FOO", "QK_BOOT"]) {
|
||||
expect(parseKeycode(code).kind).toBe("unknown");
|
||||
expect(isEditableKeycode(code)).toBe(false);
|
||||
expect(() => assignKey(p.map, "Base", keyId, code)).toThrow();
|
||||
}
|
||||
p.map.layers[0]!.keymap[0]!.keycode = "KC_FOO";
|
||||
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe("KC_FOO");
|
||||
|
||||
expect(getKeycodeLabel("KC_A")).toBe("A");
|
||||
expect(getKeycodeLabel("KC_ENTER")).toBe("Enter");
|
||||
expect(getKeycodeLabel("KC_ENT")).toBe("Enter");
|
||||
expect(getKeycodeLabel("KC_ESCAPE")).toBe("Escape");
|
||||
expect(getKeycodeLabel("KC_ESC")).toBe("Escape");
|
||||
});
|
||||
it("imports KLE, saves, reloads and validates schemas", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const saved = await saveProject(p);
|
||||
expect(saved.errors.some((issue) => issue.severity === "blocksSave")).toBe(false);
|
||||
const loaded = await loadProject(saved.json!);
|
||||
expect(loaded.errors.filter((x) => x.severity === "blocksSave")).toEqual([]);
|
||||
expect(JSON.stringify(loaded.project)).toBe(JSON.stringify(p));
|
||||
for (const l of ["phy", "idx", "elc", "map"] as const) expect(validateSchema(l, p[l])).toEqual([]);
|
||||
});
|
||||
it("preserves unknown data and IDX order", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
p.extra = { x: 1 };
|
||||
(p.phy as typeof p.phy & { extraPhy: boolean }).extraPhy = true;
|
||||
p.idx.extraIdx = true;
|
||||
p.elc.extraElc = true;
|
||||
p.map.extraMap = true;
|
||||
const q = (await loadProject((await saveProject(p)).json!)).project!;
|
||||
expect(q.extra).toEqual({ x: 1 });
|
||||
expect((q.phy as typeof q.phy & { extraPhy: boolean }).extraPhy).toBe(true);
|
||||
expect(q.idx.extraIdx).toBe(true);
|
||||
expect(q.elc.extraElc).toBe(true);
|
||||
expect(q.map.extraMap).toBe(true);
|
||||
expect(unregisteredKeys(q.phy, appendKeys(q.idx, []))).toEqual([]);
|
||||
});
|
||||
it("supports MAP layer reference changes, duplicate checks and safe parsing", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
p.map.layers.push({ name: "Nav", keymap: [] });
|
||||
p.map = assignKey(p.map, "Base", p.idx.order[0]!, "MO(Nav)");
|
||||
p.map = assignKey(p.map, "Base", p.idx.order[1]!, "LT(Nav, KC_SPC)");
|
||||
p.map = renameLayer(p.map, "Nav", "Fn");
|
||||
expect(p.map.layers[0]!.keymap.map((x) => x.keycode)).toEqual(["MO(Fn)", "LT(Fn, KC_SPC)"]);
|
||||
expect(parseKeycode("MO(Fn)").layer).toBe("Fn");
|
||||
expect(() => deleteLayer(p.map, "Fn")).toThrow();
|
||||
p.map.layers[0]!.keymap.push({ ...p.map.layers[0]!.keymap[0]! });
|
||||
expect(validateProject(p).some((x) => x.code === "duplicate-map-key")).toBe(true);
|
||||
});
|
||||
it("generates ordered matrix/keymap previews and cascades deletion", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const ids = p.idx.order;
|
||||
p.output.matrix = {
|
||||
rowPins: ["m.GP0", "m.GP1", "m.GP2", "m.GP3"],
|
||||
colPins: ["m.GP4", "m.GP5", "m.GP6", "m.GP7"],
|
||||
};
|
||||
p.elc.providers.push({ id: "m", kind: "mcu", part: "RP2040" });
|
||||
p.elc.ledChains = [{ id: "chain", data: "m.GP8", order: [ids[0]!, ids[1]!] }];
|
||||
p.map.layers[0]!.keymap = ids.map((keyId, i) => ({ keyId, keycode: `KC_${i % 10}` }));
|
||||
p.elc.keys = ids.map((key, i) => ({
|
||||
key,
|
||||
detect: "contact",
|
||||
terminals: { a: `m.GP${Math.floor(i / 4)}`, b: `m.GP${4 + (i % 4)}` },
|
||||
diode: "b-to-a",
|
||||
}));
|
||||
const out = generateMockTarget(p);
|
||||
expect(out.errors).toEqual([]);
|
||||
expect(out.target?.idx).toEqual(ids);
|
||||
expect(out.target?.matrix.rows.slice(0, 5)).toEqual([0, 0, 0, 0, 1]);
|
||||
expect(out.target?.matrix.cols.slice(0, 5)).toEqual([0, 1, 2, 3, 0]);
|
||||
expect(out.target?.keymaps[0]!.keycodes).toHaveLength(16);
|
||||
const q = deleteNodeCascade(p, ids[0]!);
|
||||
expect(q.idx.order).not.toContain(ids[0]);
|
||||
expect(q.elc.keys.some((x) => x.key === ids[0])).toBe(false);
|
||||
expect(q.elc.ledChains?.[0]?.order).not.toContain(ids[0]);
|
||||
expect(q.map.layers[0]!.keymap.some((x) => x.keyId === ids[0])).toBe(false);
|
||||
});
|
||||
it("groups history edits", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen }),
|
||||
h = new ProjectHistory(p);
|
||||
const q = structuredClone(p);
|
||||
q.phy.metadata.name = "drag";
|
||||
h.begin();
|
||||
h.push(q);
|
||||
h.replaceTop(q);
|
||||
h.commit(q);
|
||||
expect(h.canUndo).toBe(true);
|
||||
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
||||
expect(h.redo().phy.metadata.name).toBe("drag");
|
||||
});
|
||||
it("imports the VIA geometry sample", async () => {
|
||||
const via = JSON.parse(
|
||||
readFileSync(new URL("../../samples/via/cr0wn60.json", import.meta.url), "utf8"),
|
||||
) as unknown;
|
||||
const p = await createProjectFromKle(via, { idGenerator: gen });
|
||||
expect(validateSchema("phy", p.phy)).toEqual([]);
|
||||
});
|
||||
it("detects T-25 collisions and permits reversed diode pairs and exclusive variants", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const ids = p.idx.order.slice(0, 2);
|
||||
p.phy.root = p.phy.root.filter((n) => n.type === "key" && ids.includes(n.id));
|
||||
p.idx.order = ids;
|
||||
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||
p.elc.keys = [
|
||||
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||
p.elc.keys = [
|
||||
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "b-to-a" },
|
||||
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" }, diode: "b-to-a" },
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
|
||||
p.elc.keys = [
|
||||
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||
p.elc.keys = [
|
||||
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "a-to-b" },
|
||||
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||
const a = p.phy.root[0]!,
|
||||
b = p.phy.root[1]!;
|
||||
p.phy.root = [
|
||||
{
|
||||
type: "variantGroup",
|
||||
id: "vg",
|
||||
name: "Choice",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
default: "va",
|
||||
children: [
|
||||
{ type: "variant", id: "va", name: "A", children: [a] },
|
||||
{ type: "variant", id: "vb", name: "B", children: [b] },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
|
||||
expect(mcuPinCandidates(p.elc.providers[0] as { id: string; kind: "mcu"; part: string })).toHaveLength(
|
||||
30,
|
||||
);
|
||||
});
|
||||
it("filters variant output without rewriting stored IDX order", async () => {
|
||||
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||
const ids = p.idx.order.slice(0, 2);
|
||||
p.phy.root = [
|
||||
{
|
||||
type: "variantGroup",
|
||||
id: "vgx",
|
||||
name: "Choice",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
default: "vax",
|
||||
children: [
|
||||
{
|
||||
type: "variant",
|
||||
id: "vax",
|
||||
name: "A",
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: ids[0]!,
|
||||
profile: "default",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "variant",
|
||||
id: "vbx",
|
||||
name: "B",
|
||||
children: [
|
||||
{
|
||||
type: "key",
|
||||
id: ids[1]!,
|
||||
profile: "default",
|
||||
transform: { x: 1, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
p.output.variantSelection = { vgx: "vax" };
|
||||
p.idx.order = ids;
|
||||
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||
p.output.matrix = { rowPins: ["m.GP0"], colPins: ["m.GP1"] };
|
||||
p.elc.keys = ids.map((key) => ({
|
||||
key,
|
||||
detect: "contact",
|
||||
terminals: { a: "m.GP0", b: "m.GP1" },
|
||||
diode: "b-to-a",
|
||||
}));
|
||||
p.map.layers[0]!.keymap = ids.map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||
let g = generateMockTarget(p);
|
||||
expect(g.target?.idx).toEqual([ids[0]]);
|
||||
p.output.variantSelection = { vgx: "vbx" };
|
||||
g = generateMockTarget(p);
|
||||
expect(g.target?.idx).toEqual([ids[1]]);
|
||||
expect(p.idx.order).toEqual(ids);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { snapFromStart, snappedDelta } from "../src/ui/phy/dragMath";
|
||||
|
||||
describe("PHY drag math", () => {
|
||||
it("snaps from the operation start in quarter-unit increments", () => {
|
||||
expect(snapFromStart(0.1, 0.28)).toBeCloseTo(0.25);
|
||||
expect(snapFromStart(0.1, 0.38)).toBeCloseTo(0.5);
|
||||
});
|
||||
it("returns snapped local deltas", () => {
|
||||
expect(snappedDelta({ x: 1, y: 1 }, { x: 1.37, y: 0.61 })).toEqual({ x: 0.25, y: -0.5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createElc } from "../src/domain/elc";
|
||||
import { wireMatrixKeys } from "../src/domain/elcMatrix";
|
||||
|
||||
describe("wireMatrixKeys", () => {
|
||||
it("assigns row-major pins and diode without mutating source", () => {
|
||||
const source = createElc({ source: "test", checksum: "" });
|
||||
const next = wireMatrixKeys(
|
||||
source,
|
||||
["k1", "k2", "k3"],
|
||||
["MCU.GP0", "MCU.GP1"],
|
||||
["MCU.GP2", "MCU.GP3"],
|
||||
"a-to-b",
|
||||
);
|
||||
expect(next.keys.map((w) => w.detect === "contact" && [w.terminals.a, w.terminals.b, w.diode])).toEqual([
|
||||
["MCU.GP0", "MCU.GP2", "a-to-b"],
|
||||
["MCU.GP0", "MCU.GP3", "a-to-b"],
|
||||
["MCU.GP1", "MCU.GP2", "a-to-b"],
|
||||
]);
|
||||
expect(source.keys).toEqual([]);
|
||||
});
|
||||
it("rejects an undersized matrix", () => {
|
||||
expect(() =>
|
||||
wireMatrixKeys(createElc({ source: "test", checksum: "" }), ["a", "b"], ["R"], ["C"]),
|
||||
).toThrow();
|
||||
});
|
||||
it("preserves extension fields when updating existing wiring", () => {
|
||||
const source = {
|
||||
...createElc({ source: "test", checksum: "" }),
|
||||
keys: [
|
||||
{
|
||||
key: "k1",
|
||||
detect: "contact" as const,
|
||||
terminals: { a: "OLD.R", b: "OLD.C" },
|
||||
extension: "keep",
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = wireMatrixKeys(source, ["k1"], ["R"], ["C"]);
|
||||
expect(next.keys[0]).toMatchObject({ extension: "keep", terminals: { a: "R", b: "C" } });
|
||||
});
|
||||
});
|
||||
Vendored
+5967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
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<string, unknown>; keys: OracleKey[] };
|
||||
const oracle = JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), "tests/fixtures/kle-oracle.json"), "utf8"),
|
||||
) as Record<string, Case>;
|
||||
// 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<string | null>(12).fill(null),
|
||||
expectedSizes = Array<number | null>(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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
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<string, number[]>, b: Record<string, number[]>) => {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createProject } from "../src/domain/project";
|
||||
import type { PhyFile, KeyNode, VariantGroupNode } from "../src/domain/phy";
|
||||
import { generateMockTarget } from "../src/domain/generator";
|
||||
import { validateProject } from "../src/domain/validate";
|
||||
import { assignKey, type MapFile } from "../src/domain/map";
|
||||
import { setKeyWiring, setProvider, type ContactWiring, type ElcFile } from "../src/domain/elc";
|
||||
import { listDeleteReferences, deleteNodeCascade } from "../src/domain/deleteCascade";
|
||||
import { loadProject, saveProject } from "../src/io/projectFile";
|
||||
import { makePhyReference } from "../src/domain/phyReference";
|
||||
import { ProjectHistory } from "../src/domain/history";
|
||||
|
||||
const emptyPhy = (): PhyFile => ({
|
||||
version: "0.1.0",
|
||||
metadata: {},
|
||||
profiles: [{ id: "d", name: "D", unitSize: { x: 1, y: 1 } }],
|
||||
root: [],
|
||||
});
|
||||
const key = (id: string, decal = false): KeyNode => ({
|
||||
type: "key",
|
||||
id,
|
||||
profile: "d",
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
size: { w: 1, h: 1 },
|
||||
...(decal ? { decal: true } : {}),
|
||||
});
|
||||
const variantGroup = (
|
||||
id: string,
|
||||
defaultId: string,
|
||||
variants: Array<[string, string, (KeyNode | VariantGroupNode)[]]>,
|
||||
): VariantGroupNode => ({
|
||||
type: "variantGroup",
|
||||
id,
|
||||
name: id,
|
||||
transform: { x: 0, y: 0, rotation: 0 },
|
||||
default: defaultId,
|
||||
children: variants.map(([variantId, name, children]) => ({
|
||||
type: "variant",
|
||||
id: variantId,
|
||||
name,
|
||||
children,
|
||||
})),
|
||||
});
|
||||
|
||||
describe("review regressions", () => {
|
||||
it("keeps sibling Variant references when deleting a Variant and moves the selection", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [
|
||||
variantGroup("vg", "va", [
|
||||
["va", "A", [key("ka")]],
|
||||
["vb", "B", [key("kb")]],
|
||||
["vc", "C", [key("kc")]],
|
||||
]),
|
||||
];
|
||||
const p = await createProject(phy);
|
||||
p.idx.order = ["ka", "kb", "kc"];
|
||||
p.output.variantSelection = { vg: "va" };
|
||||
p.elc.keys = ["ka", "kb", "kc"].map((id) => ({
|
||||
key: id,
|
||||
detect: "contact",
|
||||
terminals: { a: "VCC", b: "GND" },
|
||||
}));
|
||||
p.elc.ledChains = [{ id: "chain", data: "VCC", order: ["ka", "kb", "kc"] }];
|
||||
p.map.layers[0]!.keymap = ["ka", "kb", "kc"].map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||
expect(listDeleteReferences(p, "va").map((ref) => ref.id)).toEqual(["ka", "ka", "ka", "ka"]);
|
||||
const result = deleteNodeCascade(p, "va");
|
||||
expect(
|
||||
result.phy.root[0]!.type === "variantGroup" && result.phy.root[0]!.children.map((v) => v.id),
|
||||
).toEqual(["vb", "vc"]);
|
||||
expect(result.phy.root[0]!.type === "variantGroup" && result.phy.root[0]!.default).toBe("vb");
|
||||
expect(result.output.variantSelection.vg).toBe("vb");
|
||||
expect(result.idx.order).toEqual(["kb", "kc"]);
|
||||
expect(result.elc.keys.map((w) => w.key)).toEqual(["kb", "kc"]);
|
||||
expect(result.elc.ledChains?.[0]?.order).toEqual(["kb", "kc"]);
|
||||
expect(result.map.layers[0]!.keymap.map((assignment) => assignment.keyId)).toEqual(["kb", "kc"]);
|
||||
});
|
||||
|
||||
it("returns schema issues without running semantic validation on malformed input", async () => {
|
||||
const p = await createProject(emptyPhy());
|
||||
const badOutput = await loadProject(JSON.stringify({ ...p, output: {} }));
|
||||
expect(badOutput.errors.some((issue) => issue.path === "/output/variantSelection")).toBe(true);
|
||||
expect(badOutput.errors.some((issue) => issue.code === "positive-unit-size")).toBe(false);
|
||||
const badPhy = await loadProject(
|
||||
JSON.stringify({ ...p, phy: { version: "0.1.0", profiles: null, root: [] } }),
|
||||
);
|
||||
expect(badPhy.errors.every((issue) => issue.code === "schema")).toBe(true);
|
||||
expect(badPhy.errors.length).toBeGreaterThan(0);
|
||||
const blockedSave = await saveProject({ ...p, phy: { ...p.phy, profiles: [] } });
|
||||
expect(blockedSave.json).toBeUndefined();
|
||||
expect(blockedSave.errors.some((issue) => issue.severity === "blocksSave")).toBe(true);
|
||||
const semanticBlock = await saveProject({
|
||||
...p,
|
||||
phy: { ...p.phy, profiles: [{ ...p.phy.profiles[0]!, unitSize: { x: 0, y: 1 } }] },
|
||||
});
|
||||
expect(semanticBlock.json).toBeUndefined();
|
||||
expect(semanticBlock.errors.some((issue) => issue.code === "positive-unit-size")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsupported project versions and allows other blocksSave projects to load", async () => {
|
||||
const p = await createProject(emptyPhy());
|
||||
const unsupported = await loadProject(JSON.stringify({ ...p, version: "0.2.0" }));
|
||||
expect(unsupported.project).toBeUndefined();
|
||||
expect(unsupported.errors.some((x) => x.code === "version")).toBe(true);
|
||||
p.idx.order = ["missing"];
|
||||
const blocked = await loadProject(JSON.stringify(p));
|
||||
expect(blocked.project).toBeDefined();
|
||||
expect(blocked.errors.some((x) => x.severity === "blocksSave")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks saving duplicate IDX keys", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [key("k")];
|
||||
const p = await createProject(phy);
|
||||
p.idx.order = ["k", "k"];
|
||||
expect(validateProject(p).some((x) => x.code === "schema" && x.severity === "blocksSave")).toBe(true);
|
||||
const result = await saveProject(p);
|
||||
expect(result.json).toBeUndefined();
|
||||
expect(result.errors.some((x) => x.code === "schema")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not retain a no-op grouped history entry", async () => {
|
||||
const p = await createProject(emptyPhy());
|
||||
const history = new ProjectHistory(p);
|
||||
history.begin();
|
||||
history.commit(p);
|
||||
expect(history.canUndo).toBe(false);
|
||||
const changed = { ...p, projectId: "changed" };
|
||||
history.begin();
|
||||
history.commit(changed);
|
||||
expect(history.canUndo).toBe(true);
|
||||
expect(history.undo().projectId).toBe(p.projectId);
|
||||
});
|
||||
|
||||
it("ignores decals when requiring index registration, wiring, and keymap assignments", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [key("decal", true)];
|
||||
const p = await createProject(phy);
|
||||
const relevant = validateProject(p).filter((issue) => issue.ids?.includes("decal"));
|
||||
expect(
|
||||
relevant.filter((issue) => ["idx-unregistered", "unwired-key", "unassigned-key"].includes(issue.code)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves extension fields while editing existing MAP and ELC entries", async () => {
|
||||
const p = await createProject(emptyPhy());
|
||||
const map = p.map as MapFile;
|
||||
map.layers[0]!.keymap = [{ keyId: "k", keycode: "KC_A", vendorAssignment: { keep: true } }];
|
||||
const editedMap = assignKey(map, "Base", "k", "KC_B");
|
||||
expect(editedMap.layers[0]!.keymap[0]).toEqual({
|
||||
keyId: "k",
|
||||
keycode: "KC_B",
|
||||
vendorAssignment: { keep: true },
|
||||
});
|
||||
expect(() => assignKey(map, "Base", "k", "VENDOR_ACTION")).toThrow();
|
||||
expect(() => assignKey(map, "Base", "k", "MO(Missing)")).toThrow();
|
||||
|
||||
const elc = p.elc as ElcFile;
|
||||
elc.providers = [{ id: "m", kind: "mcu", part: "RP2040", vendorProvider: 9 }];
|
||||
const editedProvider = setProvider(elc, { id: "m", kind: "mcu", part: "RP2040B" });
|
||||
expect(editedProvider.providers[0]).toMatchObject({ part: "RP2040B", vendorProvider: 9 });
|
||||
elc.keys = [
|
||||
{
|
||||
key: "k",
|
||||
detect: "contact",
|
||||
terminals: { a: "m.GP0", b: "m.GP1", vendorTerminal: "x" },
|
||||
vendorWiring: true,
|
||||
} as ContactWiring,
|
||||
];
|
||||
const editedElc = setKeyWiring(elc, {
|
||||
key: "k",
|
||||
detect: "contact",
|
||||
terminals: { a: "m.GP2", b: "m.GP3" },
|
||||
});
|
||||
expect(editedElc.keys[0]).toMatchObject({
|
||||
vendorWiring: true,
|
||||
terminals: { a: "m.GP2", b: "m.GP3", vendorTerminal: "x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("validates pin ownership, provider pin ranges, duplicate ELC IDs and MAP layer references", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [key("k")];
|
||||
const p = await createProject(phy);
|
||||
p.elc.providers = [
|
||||
{ id: "m", kind: "mcu", part: "RP2040" },
|
||||
{ id: "m", kind: "mcu", part: "RP2040" },
|
||||
{ id: "mux", kind: "analog-mux", channels: 2, select: ["missing.X"], common: "m.GP30" },
|
||||
];
|
||||
p.elc.keys = [
|
||||
{ key: "k", detect: "contact", terminals: { a: "mux.2", b: "missing.P" } },
|
||||
{ key: "k", detect: "contact", terminals: { a: "VCC", b: "GND" } },
|
||||
];
|
||||
p.elc.ledChains = [
|
||||
{ id: "chain", data: "m.GP0", order: [] },
|
||||
{ id: "chain", data: "m.GP1", order: [] },
|
||||
];
|
||||
p.map.layers[0]!.keymap = [{ keyId: "k", keycode: "MO(Missing)" }];
|
||||
const codes = new Set(validateProject(p).map((issue) => issue.code));
|
||||
for (const code of [
|
||||
"duplicate-provider-id",
|
||||
"duplicate-key-wiring",
|
||||
"duplicate-led-chain-id",
|
||||
"missing-pin-provider",
|
||||
"unknown-provider-pin",
|
||||
"missing-map-layer-reference",
|
||||
])
|
||||
expect(codes.has(code), code).toBe(true);
|
||||
const unknownPart = validateProject({
|
||||
...p,
|
||||
elc: {
|
||||
...p.elc,
|
||||
providers: [{ id: "custom", kind: "mcu", part: "UnknownPart" }],
|
||||
keys: [{ key: "k", detect: "contact", terminals: { a: "custom.any_pin", b: "VCC" } }],
|
||||
ledChains: [],
|
||||
},
|
||||
});
|
||||
expect(unknownPart.some((issue) => issue.code === "unknown-provider-pin")).toBe(false);
|
||||
expect(() => setProvider(p.elc, { id: "bad.id", kind: "mcu", part: "RP2040" })).toThrow();
|
||||
});
|
||||
|
||||
it("selects nested variants and treats sibling alternatives as exclusive", async () => {
|
||||
const phy = emptyPhy();
|
||||
phy.root = [
|
||||
variantGroup("outer", "outer-a", [
|
||||
[
|
||||
"outer-a",
|
||||
"A",
|
||||
[
|
||||
key("common"),
|
||||
variantGroup("inner", "inner-a", [
|
||||
["inner-a", "IA", [key("ia")]],
|
||||
["inner-b", "IB", [key("ib")]],
|
||||
]),
|
||||
],
|
||||
],
|
||||
["outer-b", "B", [key("other")]],
|
||||
]),
|
||||
];
|
||||
const p = await createProject(phy);
|
||||
p.idx.order = ["common", "ia", "ib", "other"];
|
||||
p.output.variantSelection = { outer: "outer-a", inner: "inner-b" };
|
||||
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||
p.output.matrix = { rowPins: ["m.GP0", "m.GP2"], colPins: ["m.GP1", "m.GP3"] };
|
||||
p.elc.keys = ["common", "ia", "ib", "other"].map((id) => ({
|
||||
key: id,
|
||||
detect: "contact",
|
||||
terminals: id === "common" || id === "other" ? { a: "m.GP2", b: "m.GP3" } : { a: "m.GP0", b: "m.GP1" },
|
||||
diode: "b-to-a",
|
||||
}));
|
||||
p.map.layers[0]!.keymap = ["common", "ia", "ib", "other"].map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||
expect(validateProject(p).some((issue) => issue.code === "wiring-collision")).toBe(false);
|
||||
const generated = generateMockTarget(p);
|
||||
expect(generated.target?.idx).toEqual(["common", "ib"]);
|
||||
expect(p.idx.order).toEqual(["common", "ia", "ib", "other"]);
|
||||
});
|
||||
|
||||
it("uses the independently calculated JCS checksum and reports stale references", async () => {
|
||||
const phy = emptyPhy();
|
||||
expect(await makePhyReference(phy)).toEqual({
|
||||
source: "project:phy",
|
||||
checksum: "sha256-jcs:277e45f2cae4b3883480599c5569e73fbeec9e1a22bfb9bc323aecc4cb91d843",
|
||||
});
|
||||
const p = await createProject(phy);
|
||||
p.phy.metadata.name = "changed after reference creation";
|
||||
const loaded = await loadProject(JSON.stringify(p));
|
||||
expect(
|
||||
loaded.errors.filter((issue) => issue.code === "phy-reference-mismatch").map((issue) => issue.path),
|
||||
).toEqual(["/idx/phyReference", "/elc/phyReference", "/map/phyReference"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user