Files
Beyond-KLE/mock/tests/idxEdit.test.ts
T
ewdernaandClaude Opus 5.5 c180642fa8 mock: IDX Editorを作り直す(T-31)
provisional/idx-editor-feedback.md の合意事項と T-31 の仮決定に沿って、IdxPanel を捨てて ui/idx/ に一から書き直した。
並びのリストは置かず、左にバリアントのツリー、中央のキャンバスでドラッグして並べ替え、右にプリセット・取り込み・未登録の登録を置いた。

- order の操作(隠れたキーのくっつき先、挿入、入れ替え、連番の並べ直し)を domain/idxOrder.ts に、
  走査・半身の分割・行の割り当てのずらし・マトリクス順を domain/idxScan.ts に、純粋関数として置いた。行と列の判定は rows.ts をそのまま使う
- QMK keyboard.json・VIA定義・KLE JSON の取り込みは、位置と回転で表示中のキーに照合する(io/layoutImport.ts、domain/idxImport.ts)
- 回転したキーでも出力番号を正立させる
- Codex の設定ファイルが壊れていて起動できなかったため、実装は Claude が直接行った
- 検証: vue-tsc 0件、Vitest 101件(うち新規17件)、Playwright 48件(IDX 8件を書き直し)、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzX13A7HseQz9n6etGbFDg
2026-10-25 10:59:44 +09:00

323 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import {
insertAtGap,
insertNextTo,
keyVariantGroups,
orderSegments,
renumber,
swapKeys,
} from "../src/domain/idxOrder";
import type { OrderContext } from "../src/domain/idxOrder";
import {
assignLines,
keyRect,
matrixSequence,
scanKeys,
splitHalves,
type ScanOptions,
} from "../src/domain/idxScan";
import { matchPlacements } from "../src/domain/idxImport";
import { readLayoutPlacements } from "../src/io/layoutImport";
import { importKle } from "../src/io/kleImport";
import { flattenKeys } from "../src/domain/geometry";
import type { KeyRect } from "../src/domain/rows";
import type { FlatKey, PhyFile } from "../src/domain/phy";
const sample = (path: string) =>
JSON.parse(readFileSync(new URL(`../../samples/${path}`, import.meta.url), "utf8")) as unknown;
const gen = () => {
let i = 0;
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
};
/** Context where `hidden` keys are in group g (with every `grouped` visible key) and the rest are common. */
const ctx = (visible: string[], grouped: string[] = []): OrderContext => ({
visible: new Set(visible),
groups: new Map(grouped.map((id) => [id, ["g"]])),
});
describe("IDX order editing", () => {
it("keeps the ISO Enter next to the ANSI Enter when inserting after it", () => {
// a = ANSI Enter (shown), h = ISO Enter (hidden), b = common key
const c = ctx(["a", "b", "x"], ["a", "h"]);
expect(orderSegments(["a", "h", "b"], c)).toEqual([["a", "h"], ["b"]]);
expect(insertNextTo(["a", "h", "b", "x"], c, "x", "a", true)).toEqual(["a", "h", "x", "b"]);
expect(insertNextTo(["a", "h", "b", "x"], c, "x", "b", false)).toEqual(["a", "h", "x", "b"]);
});
it("attaches hidden keys forward only to a key sharing their group", () => {
const c = ctx(["p", "q"], ["h1", "h2", "q"]);
expect(orderSegments(["p", "h1", "h2", "q"], c)).toEqual([["p"], ["h1", "h2", "q"]]);
// shares with neither: stays with the key before
expect(orderSegments(["p", "z", "q"], ctx(["p", "q"]))).toEqual([["p", "z"], ["q"]]);
// shares with both: stays with the key before
expect(orderSegments(["p", "h", "q"], ctx(["p", "q"], ["p", "h", "q"]))).toEqual([["p", "h"], ["q"]]);
// once a hidden key goes forward, the rest of the run follows to keep the order
const mixed = {
visible: new Set(["p", "q"]),
groups: new Map([
["h1", ["g"]],
["q", ["g"]],
["h2", ["k"]],
["p", ["k"]],
]),
};
expect(orderSegments(["p", "h1", "h2", "q"], mixed)).toEqual([["p"], ["h1", "h2", "q"]]);
// leading hidden keys attach to the first visible key, trailing ones to the last
expect(orderSegments(["z", "p", "q", "y"], ctx(["p", "q"]))).toEqual([
["z", "p"],
["q", "y"],
]);
expect(orderSegments(["z"], ctx([]))).toEqual([]);
});
it("moves a key between two others like a list move", () => {
const c = ctx(["k0", "k1", "k2", "k3", "k4", "k5"]);
const order = ["k0", "k1", "k2", "k3", "k4", "k5"];
// n = 4 dropped between i = 1 and i + 1 = 2: lands on 2, 2..3 shift back
expect(insertNextTo(order, c, "k4", "k1", true)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]);
expect(insertNextTo(order, c, "k4", "k2", false)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]);
// n = 1 dropped between i = 3 and 4: lands on 3, 2..3 shift forward
expect(insertNextTo(order, c, "k1", "k3", true)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]);
expect(insertNextTo(order, c, "k1", "k4", false)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]);
expect(insertNextTo(order, c, "k1", "k1", true)).toEqual(order);
expect(insertAtGap(order, c, "k0", 99)).toEqual(["k1", "k2", "k3", "k4", "k5", "k0"]);
});
it("leaves hidden keys in place when their visible key moves away", () => {
const c = ctx(["a", "b", "c"], ["a", "h"]);
expect(insertNextTo(["a", "h", "b", "c"], c, "a", "c", true)).toEqual(["h", "b", "c", "a"]);
});
it("registers an unregistered key at the drop position", () => {
const c = ctx(["a", "b", "new"]);
expect(insertNextTo(["a", "b"], c, "new", "b", false)).toEqual(["a", "new", "b"]);
});
it("swaps only the two keys", () => {
expect(swapKeys(["a", "h", "b", "c"], "a", "c")).toEqual(["c", "h", "b", "a"]);
expect(() => swapKeys(["a"], "a", "zz")).toThrow();
});
it("renumbers a sequence from the position of its first key, moving attached hidden keys along", () => {
const c = ctx(["a", "b", "c", "d"], ["b", "h"]);
const order = ["a", "b", "h", "c", "d"];
expect(renumber(order, c, ["d", "b"])).toEqual(["a", "c", "d", "b", "h"]);
expect(renumber(order, c, ["b", "a"])).toEqual(["b", "h", "a", "c", "d"]);
expect(renumber(order, c, ["c", "a"])).toEqual(["b", "h", "c", "a", "d"]);
// unregistered keys are registered inside the block; an all-new sequence goes to the end
expect(renumber(["a", "b"], ctx(["a", "b", "n"]), ["n", "b"])).toEqual(["a", "n", "b"]);
expect(renumber(["a"], ctx(["a", "n"]), ["n"])).toEqual(["a", "n"]);
});
it("never duplicates or drops keys", () => {
const c = ctx(["a", "b", "c", "d"], ["a", "h1", "c", "h2"]);
const order = ["h1", "a", "b", "h2", "c", "d", "z"];
const hiddenOf = (o: string[]) => o.filter((id) => !c.visible.has(id));
const moved = renumber(order, c, ["d", "c", "b", "a"]);
expect([...moved].sort()).toEqual([...order].sort());
// hidden keys travel with their visible key here
expect(moved).toEqual(["d", "z", "h2", "c", "b", "h1", "a"]);
for (const r of [
insertNextTo(order, c, "a", "d", true),
insertNextTo(order, c, "d", "a", false),
swapKeys(order, "b", "d"),
]) {
expect([...r].sort()).toEqual([...order].sort());
expect(hiddenOf(r)).toEqual(hiddenOf(order));
}
});
it("lists VariantGroup ancestors including nested groups", () => {
const { phy } = importKle(sample("kle/macropad-4x4.json"), { idGenerator: gen() });
const [a, b] = phy.root as Extract<PhyFile["root"][number], { type: "key" }>[];
const nested: PhyFile = {
...phy,
root: [
{
type: "variantGroup",
id: "outer",
name: "o",
default: "v1",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{
type: "variant",
id: "v1",
name: "1",
children: [
{
type: "variantGroup",
id: "inner",
name: "i",
default: "v2",
transform: { x: 0, y: 0, rotation: 0 },
children: [{ type: "variant", id: "v2", name: "2", children: [a!] }],
},
],
},
],
},
b!,
],
};
const groups = keyVariantGroups(nested);
expect(groups.get(a!.id)).toEqual(["outer", "inner"]);
expect(groups.get(b!.id)).toEqual([]);
});
});
const r = (id: string, x: number, y: number, w = 1, h = 1): KeyRect => ({ id, x, y, w, h });
const options = (o: Partial<ScanOptions>): ScanOptions => ({
kind: "rows",
direction: "normal",
left: "normal",
right: "normal",
offsets: new Map(),
...o,
});
describe("IDX preset scans", () => {
// Two 2x2 halves with a 2U gap
const split = [r("l00", 0, 0), r("l01", 1, 0), r("l10", 0, 1), r("l11", 1, 1)].concat([
r("r00", 4, 0),
r("r01", 5, 0),
r("r10", 4, 1),
r("r11", 5, 1),
]);
it("scans rows, columns, halves and alternating halves", () => {
expect(scanKeys(split, options({})).sequence).toEqual([
"l00",
"l01",
"r00",
"r01",
"l10",
"l11",
"r10",
"r11",
]);
expect(scanKeys(split, options({ direction: "reversed" })).sequence.slice(0, 4)).toEqual([
"r01",
"r00",
"l01",
"l00",
]);
expect(scanKeys(split, options({ kind: "columns" })).sequence.slice(0, 4)).toEqual([
"l00",
"l10",
"l01",
"l11",
]);
expect(scanKeys(split, options({ kind: "columns", direction: "reversed" })).sequence.slice(0, 2)).toEqual(
["r01", "r11"],
);
const halves = scanKeys(split, options({ kind: "halves", right: "reversed" }));
expect(halves.sequence).toEqual(["l00", "l01", "l10", "l11", "r01", "r00", "r11", "r10"]);
expect(halves.boundary).toBe(3);
expect(scanKeys(split, options({ kind: "alternating" })).sequence).toEqual([
"l00",
"l01",
"r00",
"r01",
"l10",
"l11",
"r10",
"r11",
]);
});
it("splits a board without a gap at the middle of its x range", () => {
const halves = splitHalves([r("a", 0, 0), r("b", 1, 0), r("c", 2, 0), r("d", 3, 0, 2)]);
expect(halves.boundary).toBe(2.5);
expect([...halves.left]).toEqual(["a", "b"]);
});
it("shifts the row assignment of one column", () => {
// column-staggered: the outer column sits a full row lower and is detected one row off
const keys = [r("o0", 0, 1), r("o1", 0, 2), r("a0", 1, 0), r("a1", 1, 1), r("a2", 1, 2)];
const before = assignLines(keys, "row", new Map());
expect(before.lines).toEqual([["a0"], ["o0", "a1"], ["o1", "a2"]]);
const outer = before.crossLines.findIndex((col) => col.includes("o0"));
const after = assignLines(keys, "row", new Map([[outer, -1]]));
expect(after.lines).toEqual([["o0", "a0"], ["o1", "a1"], ["a2"]]);
});
it("uses the bounding box of rotated keys", () => {
const rotated = keyRect({
key: {
type: "key",
id: "k",
profile: "p",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 2, h: 1 },
},
x: 0,
y: 0,
rotation: 90,
path: [],
});
expect(rotated.x).toBeCloseTo(-1);
expect(rotated.w).toBeCloseTo(1);
expect(rotated.h).toBeCloseTo(2);
});
it("orders by matrix row then col and leaves unwired keys at the end", () => {
const positions = new Map([
["a", { row: 1, col: 0 }],
["b", { row: 0, col: 2 }],
["c", { row: 0, col: 1 }],
]);
expect(matrixSequence(["x", "a", "b", "y", "c"], positions)).toEqual(["c", "b", "a", "x", "y"]);
});
});
describe("IDX import from existing definitions", () => {
const shown = (phy: PhyFile): FlatKey[] => flattenKeys(phy).filter((k) => !k.key.decal);
it("matches every key of the KLE the project came from, in KLE order", () => {
for (const file of ["kle/macropad-4x4.json", "kle/cr0wn60.json"]) {
const { phy, keyOrder } = importKle(sample(file), { idGenerator: gen() });
const result = matchPlacements(readLayoutPlacements(sample(file)).placements, shown(phy));
expect(result.unmatched, file).toBe(0);
expect(result.ids).toEqual(keyOrder.filter((id) => shown(phy).some((k) => k.key.id === id)));
}
});
it("matches the VIA definition of cr0wn60 against its KLE", () => {
const { phy } = importKle(sample("kle/cr0wn60.json"), { idGenerator: gen() });
const keys = shown(phy);
const result = matchPlacements(readLayoutPlacements(sample("via/cr0wn60.json")).placements, keys);
expect(result.ids.length).toBe(keys.length);
expect(new Set(result.ids).size).toBe(result.ids.length);
});
it("reads QMK layouts, asks for a name when there are several, and applies rotation", () => {
const kle = [
[{ r: 30, rx: 2, ry: 1, x: 0.5 }, "A"],
[{ x: -1 }, "B"],
];
const { phy } = importKle(kle, { idGenerator: gen() });
const keys = shown(phy);
const qmk = {
layouts: {
LAYOUT_a: {
layout: [
{ matrix: [0, 1], x: 1, y: 2, r: 30, rx: 2, ry: 1 },
{ matrix: [0, 0], x: 2.5, y: 1, r: 30, rx: 2, ry: 1 },
],
},
LAYOUT_b: { layout: [{ matrix: [0, 0], x: 0, y: 0 }] },
},
};
expect(readLayoutPlacements(qmk)).toEqual({ placements: [], layouts: ["LAYOUT_a", "LAYOUT_b"] });
const read = readLayoutPlacements(qmk, "LAYOUT_a");
const result = matchPlacements(read.placements, keys);
expect(result.unmatched).toBe(0);
const label = (id: string) => keys.find((k) => k.key.id === id)!.key.labels?.find(Boolean);
expect(result.ids.map(label)).toEqual(["B", "A"]);
expect(matchPlacements(readLayoutPlacements(qmk, "LAYOUT_b").placements, keys).unmatched).toBe(1);
});
});