数値欄の change がフォーカスを外したときにもう一度届くと、同じ値の変更が履歴に2回積まれ、 Undo 1回で戻らなかった。グループ編集の commit と同じく、直前と同じなら積まない。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017yoUu9Xd8dhwyQdCfKfaXG
276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { createProjectFromKle } from "../src/domain/project";
|
|
import { saveProject, loadProject } from "../src/io/projectFile";
|
|
import { validateSchema } from "../src/io/validateSchema";
|
|
import { appendKeys, unregisteredKeys } from "../src/domain/idx";
|
|
import { assignKey, isEditableKeycode, parseKeycode, renameLayer, deleteLayer } from "../src/domain/map";
|
|
import { getKeycodeLabel } from "../src/domain/keycodeLabels";
|
|
import { qmkBasicKeycodes } from "../src/domain/qmkBasicKeycodes";
|
|
import { generateMockTarget } from "../src/domain/generator";
|
|
import { deleteNodeCascade } from "../src/domain/deleteCascade";
|
|
import { ProjectHistory } from "../src/domain/history";
|
|
import { validateProject } from "../src/domain/validate";
|
|
import { mcuPinCandidates } from "../src/domain/elc";
|
|
const kle = JSON.parse(
|
|
readFileSync(new URL("../../samples/kle/macropad-4x4.json", import.meta.url), "utf8"),
|
|
) as unknown;
|
|
const gen = (() => {
|
|
let i = 0;
|
|
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
|
|
})();
|
|
describe("domain project flow", () => {
|
|
it("supports QMK basic keycode names and aliases while preserving entered spelling", async () => {
|
|
const editable = [
|
|
"KC_ENT",
|
|
"KC_ENTER",
|
|
"KC_ESCAPE",
|
|
"KC_ESC",
|
|
"KC_P0",
|
|
"KC_KP_0",
|
|
"KC_LCTL",
|
|
"KC_LEFT_CTRL",
|
|
"KC_INT1",
|
|
"KC_LNG1",
|
|
];
|
|
for (const code of editable) expect(isEditableKeycode(code), code).toBe(true);
|
|
|
|
const formalNames = qmkBasicKeycodes.filter((entry) => entry.group !== "internal");
|
|
expect(formalNames.length).toBeGreaterThanOrEqual(100);
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
|
const keyId = p.idx.order[0]!;
|
|
for (const code of editable) {
|
|
p.map = assignKey(p.map, "Base", keyId, code);
|
|
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
|
|
}
|
|
p.map = assignKey(p.map, "Base", keyId, "LCTL(KC_ENT)");
|
|
expect(isEditableKeycode("LCTL(KC_ENT)")).toBe(true);
|
|
p.map.layers.push({ name: "Nav", keymap: [] });
|
|
p.map = assignKey(p.map, "Base", keyId, "LT(Nav, KC_LNG1)");
|
|
expect(isEditableKeycode("LT(Nav, KC_LNG1)")).toBe(true);
|
|
p.map = assignKey(p.map, "Base", keyId, "KC_P0");
|
|
const roundTrip = await loadProject((await saveProject(p)).json!);
|
|
expect(roundTrip.project!.map.layers[0]!.keymap[0]!.keycode).toBe("KC_P0");
|
|
|
|
for (const code of ["KC_NO", "XXXXXXX", "KC_TRANSPARENT", "KC_TRNS", "_______"]) {
|
|
expect(isEditableKeycode(code), code).toBe(true);
|
|
p.map = assignKey(p.map, "Base", keyId, code);
|
|
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
|
|
}
|
|
|
|
for (const code of ["KC_FOO", "QK_BOOT"]) {
|
|
expect(parseKeycode(code).kind).toBe("unknown");
|
|
expect(isEditableKeycode(code)).toBe(false);
|
|
expect(() => assignKey(p.map, "Base", keyId, code)).toThrow();
|
|
}
|
|
p.map.layers[0]!.keymap[0]!.keycode = "KC_FOO";
|
|
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe("KC_FOO");
|
|
|
|
expect(getKeycodeLabel("KC_A")).toBe("A");
|
|
expect(getKeycodeLabel("KC_ENTER")).toBe("Enter");
|
|
expect(getKeycodeLabel("KC_ENT")).toBe("Enter");
|
|
expect(getKeycodeLabel("KC_ESCAPE")).toBe("Escape");
|
|
expect(getKeycodeLabel("KC_ESC")).toBe("Escape");
|
|
});
|
|
it("imports KLE, saves, reloads and validates schemas", async () => {
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
|
const saved = await saveProject(p);
|
|
expect(saved.errors.some((issue) => issue.severity === "blocksSave")).toBe(false);
|
|
const loaded = await loadProject(saved.json!);
|
|
expect(loaded.errors.filter((x) => x.severity === "blocksSave")).toEqual([]);
|
|
expect(JSON.stringify(loaded.project)).toBe(JSON.stringify(p));
|
|
for (const l of ["phy", "idx", "elc", "map"] as const) expect(validateSchema(l, p[l])).toEqual([]);
|
|
});
|
|
it("preserves unknown data and IDX order", async () => {
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
|
p.extra = { x: 1 };
|
|
(p.phy as typeof p.phy & { extraPhy: boolean }).extraPhy = true;
|
|
p.idx.extraIdx = true;
|
|
p.elc.extraElc = true;
|
|
p.map.extraMap = true;
|
|
const q = (await loadProject((await saveProject(p)).json!)).project!;
|
|
expect(q.extra).toEqual({ x: 1 });
|
|
expect((q.phy as typeof q.phy & { extraPhy: boolean }).extraPhy).toBe(true);
|
|
expect(q.idx.extraIdx).toBe(true);
|
|
expect(q.elc.extraElc).toBe(true);
|
|
expect(q.map.extraMap).toBe(true);
|
|
expect(unregisteredKeys(q.phy, appendKeys(q.idx, []))).toEqual([]);
|
|
});
|
|
it("supports MAP layer reference changes, duplicate checks and safe parsing", async () => {
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
|
p.map.layers.push({ name: "Nav", keymap: [] });
|
|
p.map = assignKey(p.map, "Base", p.idx.order[0]!, "MO(Nav)");
|
|
p.map = assignKey(p.map, "Base", p.idx.order[1]!, "LT(Nav, KC_SPC)");
|
|
p.map = renameLayer(p.map, "Nav", "Fn");
|
|
expect(p.map.layers[0]!.keymap.map((x) => x.keycode)).toEqual(["MO(Fn)", "LT(Fn, KC_SPC)"]);
|
|
expect(parseKeycode("MO(Fn)").layer).toBe("Fn");
|
|
expect(() => deleteLayer(p.map, "Fn")).toThrow();
|
|
p.map.layers[0]!.keymap.push({ ...p.map.layers[0]!.keymap[0]! });
|
|
expect(validateProject(p).some((x) => x.code === "duplicate-map-key")).toBe(true);
|
|
});
|
|
it("generates ordered matrix/keymap previews and cascades deletion", async () => {
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
|
const ids = p.idx.order;
|
|
p.output.matrix = {
|
|
rowPins: ["m.GP0", "m.GP1", "m.GP2", "m.GP3"],
|
|
colPins: ["m.GP4", "m.GP5", "m.GP6", "m.GP7"],
|
|
};
|
|
p.elc.providers.push({ id: "m", kind: "mcu", part: "RP2040" });
|
|
p.elc.ledChains = [{ id: "chain", data: "m.GP8", order: [ids[0]!, ids[1]!] }];
|
|
p.map.layers[0]!.keymap = ids.map((keyId, i) => ({ keyId, keycode: `KC_${i % 10}` }));
|
|
p.elc.keys = ids.map((key, i) => ({
|
|
key,
|
|
detect: "contact",
|
|
terminals: { a: `m.GP${Math.floor(i / 4)}`, b: `m.GP${4 + (i % 4)}` },
|
|
diode: "b-to-a",
|
|
}));
|
|
const out = generateMockTarget(p);
|
|
expect(out.errors).toEqual([]);
|
|
expect(out.target?.idx).toEqual(ids);
|
|
expect(out.target?.matrix.rows.slice(0, 5)).toEqual([0, 0, 0, 0, 1]);
|
|
expect(out.target?.matrix.cols.slice(0, 5)).toEqual([0, 1, 2, 3, 0]);
|
|
expect(out.target?.keymaps[0]!.keycodes).toHaveLength(16);
|
|
const q = deleteNodeCascade(p, ids[0]!);
|
|
expect(q.idx.order).not.toContain(ids[0]);
|
|
expect(q.elc.keys.some((x) => x.key === ids[0])).toBe(false);
|
|
expect(q.elc.ledChains?.[0]?.order).not.toContain(ids[0]);
|
|
expect(q.map.layers[0]!.keymap.some((x) => x.keyId === ids[0])).toBe(false);
|
|
});
|
|
it("groups history edits", async () => {
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen }),
|
|
h = new ProjectHistory(p);
|
|
const q = structuredClone(p);
|
|
q.phy.metadata.name = "drag";
|
|
h.begin();
|
|
h.push(q);
|
|
h.replaceTop(q);
|
|
h.commit(q);
|
|
expect(h.canUndo).toBe(true);
|
|
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
|
expect(h.redo().phy.metadata.name).toBe("drag");
|
|
});
|
|
it("does not add a history step for an unchanged snapshot", async () => {
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen }),
|
|
h = new ProjectHistory(p);
|
|
const q = structuredClone(p);
|
|
q.phy.metadata.name = "edit";
|
|
h.push(q);
|
|
h.push(structuredClone(q));
|
|
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
|
expect(h.canUndo).toBe(false);
|
|
});
|
|
it("imports the VIA geometry sample", async () => {
|
|
const via = JSON.parse(
|
|
readFileSync(new URL("../../samples/via/cr0wn60.json", import.meta.url), "utf8"),
|
|
) as unknown;
|
|
const p = await createProjectFromKle(via, { idGenerator: gen });
|
|
expect(validateSchema("phy", p.phy)).toEqual([]);
|
|
});
|
|
it("detects T-25 collisions and permits reversed diode pairs and exclusive variants", async () => {
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
|
const ids = p.idx.order.slice(0, 2);
|
|
p.phy.root = p.phy.root.filter((n) => n.type === "key" && ids.includes(n.id));
|
|
p.idx.order = ids;
|
|
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
|
p.elc.keys = [
|
|
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
|
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
|
|
];
|
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
|
p.elc.keys = [
|
|
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "b-to-a" },
|
|
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" }, diode: "b-to-a" },
|
|
];
|
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
|
|
p.elc.keys = [
|
|
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
|
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
|
];
|
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
|
p.elc.keys = [
|
|
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "a-to-b" },
|
|
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
|
|
];
|
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
|
const a = p.phy.root[0]!,
|
|
b = p.phy.root[1]!;
|
|
p.phy.root = [
|
|
{
|
|
type: "variantGroup",
|
|
id: "vg",
|
|
name: "Choice",
|
|
transform: { x: 0, y: 0, rotation: 0 },
|
|
default: "va",
|
|
children: [
|
|
{ type: "variant", id: "va", name: "A", children: [a] },
|
|
{ type: "variant", id: "vb", name: "B", children: [b] },
|
|
],
|
|
},
|
|
];
|
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
|
|
expect(mcuPinCandidates(p.elc.providers[0] as { id: string; kind: "mcu"; part: string })).toHaveLength(
|
|
30,
|
|
);
|
|
});
|
|
it("filters variant output without rewriting stored IDX order", async () => {
|
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
|
const ids = p.idx.order.slice(0, 2);
|
|
p.phy.root = [
|
|
{
|
|
type: "variantGroup",
|
|
id: "vgx",
|
|
name: "Choice",
|
|
transform: { x: 0, y: 0, rotation: 0 },
|
|
default: "vax",
|
|
children: [
|
|
{
|
|
type: "variant",
|
|
id: "vax",
|
|
name: "A",
|
|
children: [
|
|
{
|
|
type: "key",
|
|
id: ids[0]!,
|
|
profile: "default",
|
|
transform: { x: 0, y: 0, rotation: 0 },
|
|
size: { w: 1, h: 1 },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "variant",
|
|
id: "vbx",
|
|
name: "B",
|
|
children: [
|
|
{
|
|
type: "key",
|
|
id: ids[1]!,
|
|
profile: "default",
|
|
transform: { x: 1, y: 0, rotation: 0 },
|
|
size: { w: 1, h: 1 },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
p.output.variantSelection = { vgx: "vax" };
|
|
p.idx.order = ids;
|
|
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
|
p.output.matrix = { rowPins: ["m.GP0"], colPins: ["m.GP1"] };
|
|
p.elc.keys = ids.map((key) => ({
|
|
key,
|
|
detect: "contact",
|
|
terminals: { a: "m.GP0", b: "m.GP1" },
|
|
diode: "b-to-a",
|
|
}));
|
|
p.map.layers[0]!.keymap = ids.map((keyId) => ({ keyId, keycode: "KC_A" }));
|
|
let g = generateMockTarget(p);
|
|
expect(g.target?.idx).toEqual([ids[0]]);
|
|
p.output.variantSelection = { vgx: "vbx" };
|
|
g = generateMockTarget(p);
|
|
expect(g.target?.idx).toEqual([ids[1]]);
|
|
expect(p.idx.order).toEqual(ids);
|
|
});
|
|
});
|