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
258 lines
8.0 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|