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[]; 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 => ({ 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); }); });