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
375 lines
13 KiB
TypeScript
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"]);
|
|
});
|
|
});
|