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
323 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|