Files
Beyond-KLE/mock/tests/phase1.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

258 lines
8.0 KiB
TypeScript

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