Files
Beyond-KLE/mock/tests/kle-oracle.test.ts
T
ewdernaandClaude Opus 5.5 fccbc28bee 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
2026-09-25 22:42:30 +09:00

375 lines
13 KiB
TypeScript

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"]);
});
});