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
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { createProject } from "../src/domain/project";
|
|
import type { PhyFile, KeyNode, VariantGroupNode } from "../src/domain/phy";
|
|
import { generateMockTarget } from "../src/domain/generator";
|
|
import { validateProject } from "../src/domain/validate";
|
|
import { assignKey, type MapFile } from "../src/domain/map";
|
|
import { setKeyWiring, setProvider, type ContactWiring, type ElcFile } from "../src/domain/elc";
|
|
import { listDeleteReferences, deleteNodeCascade } from "../src/domain/deleteCascade";
|
|
import { loadProject, saveProject } from "../src/io/projectFile";
|
|
import { makePhyReference } from "../src/domain/phyReference";
|
|
import { ProjectHistory } from "../src/domain/history";
|
|
|
|
const emptyPhy = (): PhyFile => ({
|
|
version: "0.1.0",
|
|
metadata: {},
|
|
profiles: [{ id: "d", name: "D", unitSize: { x: 1, y: 1 } }],
|
|
root: [],
|
|
});
|
|
const key = (id: string, decal = false): KeyNode => ({
|
|
type: "key",
|
|
id,
|
|
profile: "d",
|
|
transform: { x: 0, y: 0, rotation: 0 },
|
|
size: { w: 1, h: 1 },
|
|
...(decal ? { decal: true } : {}),
|
|
});
|
|
const variantGroup = (
|
|
id: string,
|
|
defaultId: string,
|
|
variants: Array<[string, string, (KeyNode | VariantGroupNode)[]]>,
|
|
): VariantGroupNode => ({
|
|
type: "variantGroup",
|
|
id,
|
|
name: id,
|
|
transform: { x: 0, y: 0, rotation: 0 },
|
|
default: defaultId,
|
|
children: variants.map(([variantId, name, children]) => ({
|
|
type: "variant",
|
|
id: variantId,
|
|
name,
|
|
children,
|
|
})),
|
|
});
|
|
|
|
describe("review regressions", () => {
|
|
it("keeps sibling Variant references when deleting a Variant and moves the selection", async () => {
|
|
const phy = emptyPhy();
|
|
phy.root = [
|
|
variantGroup("vg", "va", [
|
|
["va", "A", [key("ka")]],
|
|
["vb", "B", [key("kb")]],
|
|
["vc", "C", [key("kc")]],
|
|
]),
|
|
];
|
|
const p = await createProject(phy);
|
|
p.idx.order = ["ka", "kb", "kc"];
|
|
p.output.variantSelection = { vg: "va" };
|
|
p.elc.keys = ["ka", "kb", "kc"].map((id) => ({
|
|
key: id,
|
|
detect: "contact",
|
|
terminals: { a: "VCC", b: "GND" },
|
|
}));
|
|
p.elc.ledChains = [{ id: "chain", data: "VCC", order: ["ka", "kb", "kc"] }];
|
|
p.map.layers[0]!.keymap = ["ka", "kb", "kc"].map((keyId) => ({ keyId, keycode: "KC_A" }));
|
|
expect(listDeleteReferences(p, "va").map((ref) => ref.id)).toEqual(["ka", "ka", "ka", "ka"]);
|
|
const result = deleteNodeCascade(p, "va");
|
|
expect(
|
|
result.phy.root[0]!.type === "variantGroup" && result.phy.root[0]!.children.map((v) => v.id),
|
|
).toEqual(["vb", "vc"]);
|
|
expect(result.phy.root[0]!.type === "variantGroup" && result.phy.root[0]!.default).toBe("vb");
|
|
expect(result.output.variantSelection.vg).toBe("vb");
|
|
expect(result.idx.order).toEqual(["kb", "kc"]);
|
|
expect(result.elc.keys.map((w) => w.key)).toEqual(["kb", "kc"]);
|
|
expect(result.elc.ledChains?.[0]?.order).toEqual(["kb", "kc"]);
|
|
expect(result.map.layers[0]!.keymap.map((assignment) => assignment.keyId)).toEqual(["kb", "kc"]);
|
|
});
|
|
|
|
it("returns schema issues without running semantic validation on malformed input", async () => {
|
|
const p = await createProject(emptyPhy());
|
|
const badOutput = await loadProject(JSON.stringify({ ...p, output: {} }));
|
|
expect(badOutput.errors.some((issue) => issue.path === "/output/variantSelection")).toBe(true);
|
|
expect(badOutput.errors.some((issue) => issue.code === "positive-unit-size")).toBe(false);
|
|
const badPhy = await loadProject(
|
|
JSON.stringify({ ...p, phy: { version: "0.1.0", profiles: null, root: [] } }),
|
|
);
|
|
expect(badPhy.errors.every((issue) => issue.code === "schema")).toBe(true);
|
|
expect(badPhy.errors.length).toBeGreaterThan(0);
|
|
const blockedSave = await saveProject({ ...p, phy: { ...p.phy, profiles: [] } });
|
|
expect(blockedSave.json).toBeUndefined();
|
|
expect(blockedSave.errors.some((issue) => issue.severity === "blocksSave")).toBe(true);
|
|
const semanticBlock = await saveProject({
|
|
...p,
|
|
phy: { ...p.phy, profiles: [{ ...p.phy.profiles[0]!, unitSize: { x: 0, y: 1 } }] },
|
|
});
|
|
expect(semanticBlock.json).toBeUndefined();
|
|
expect(semanticBlock.errors.some((issue) => issue.code === "positive-unit-size")).toBe(true);
|
|
});
|
|
|
|
it("rejects unsupported project versions and allows other blocksSave projects to load", async () => {
|
|
const p = await createProject(emptyPhy());
|
|
const unsupported = await loadProject(JSON.stringify({ ...p, version: "0.2.0" }));
|
|
expect(unsupported.project).toBeUndefined();
|
|
expect(unsupported.errors.some((x) => x.code === "version")).toBe(true);
|
|
p.idx.order = ["missing"];
|
|
const blocked = await loadProject(JSON.stringify(p));
|
|
expect(blocked.project).toBeDefined();
|
|
expect(blocked.errors.some((x) => x.severity === "blocksSave")).toBe(true);
|
|
});
|
|
|
|
it("blocks saving duplicate IDX keys", async () => {
|
|
const phy = emptyPhy();
|
|
phy.root = [key("k")];
|
|
const p = await createProject(phy);
|
|
p.idx.order = ["k", "k"];
|
|
expect(validateProject(p).some((x) => x.code === "schema" && x.severity === "blocksSave")).toBe(true);
|
|
const result = await saveProject(p);
|
|
expect(result.json).toBeUndefined();
|
|
expect(result.errors.some((x) => x.code === "schema")).toBe(true);
|
|
});
|
|
|
|
it("does not retain a no-op grouped history entry", async () => {
|
|
const p = await createProject(emptyPhy());
|
|
const history = new ProjectHistory(p);
|
|
history.begin();
|
|
history.commit(p);
|
|
expect(history.canUndo).toBe(false);
|
|
const changed = { ...p, projectId: "changed" };
|
|
history.begin();
|
|
history.commit(changed);
|
|
expect(history.canUndo).toBe(true);
|
|
expect(history.undo().projectId).toBe(p.projectId);
|
|
});
|
|
|
|
it("ignores decals when requiring index registration, wiring, and keymap assignments", async () => {
|
|
const phy = emptyPhy();
|
|
phy.root = [key("decal", true)];
|
|
const p = await createProject(phy);
|
|
const relevant = validateProject(p).filter((issue) => issue.ids?.includes("decal"));
|
|
expect(
|
|
relevant.filter((issue) => ["idx-unregistered", "unwired-key", "unassigned-key"].includes(issue.code)),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("preserves extension fields while editing existing MAP and ELC entries", async () => {
|
|
const p = await createProject(emptyPhy());
|
|
const map = p.map as MapFile;
|
|
map.layers[0]!.keymap = [{ keyId: "k", keycode: "KC_A", vendorAssignment: { keep: true } }];
|
|
const editedMap = assignKey(map, "Base", "k", "KC_B");
|
|
expect(editedMap.layers[0]!.keymap[0]).toEqual({
|
|
keyId: "k",
|
|
keycode: "KC_B",
|
|
vendorAssignment: { keep: true },
|
|
});
|
|
expect(() => assignKey(map, "Base", "k", "VENDOR_ACTION")).toThrow();
|
|
expect(() => assignKey(map, "Base", "k", "MO(Missing)")).toThrow();
|
|
|
|
const elc = p.elc as ElcFile;
|
|
elc.providers = [{ id: "m", kind: "mcu", part: "RP2040", vendorProvider: 9 }];
|
|
const editedProvider = setProvider(elc, { id: "m", kind: "mcu", part: "RP2040B" });
|
|
expect(editedProvider.providers[0]).toMatchObject({ part: "RP2040B", vendorProvider: 9 });
|
|
elc.keys = [
|
|
{
|
|
key: "k",
|
|
detect: "contact",
|
|
terminals: { a: "m.GP0", b: "m.GP1", vendorTerminal: "x" },
|
|
vendorWiring: true,
|
|
} as ContactWiring,
|
|
];
|
|
const editedElc = setKeyWiring(elc, {
|
|
key: "k",
|
|
detect: "contact",
|
|
terminals: { a: "m.GP2", b: "m.GP3" },
|
|
});
|
|
expect(editedElc.keys[0]).toMatchObject({
|
|
vendorWiring: true,
|
|
terminals: { a: "m.GP2", b: "m.GP3", vendorTerminal: "x" },
|
|
});
|
|
});
|
|
|
|
it("validates pin ownership, provider pin ranges, duplicate ELC IDs and MAP layer references", async () => {
|
|
const phy = emptyPhy();
|
|
phy.root = [key("k")];
|
|
const p = await createProject(phy);
|
|
p.elc.providers = [
|
|
{ id: "m", kind: "mcu", part: "RP2040" },
|
|
{ id: "m", kind: "mcu", part: "RP2040" },
|
|
{ id: "mux", kind: "analog-mux", channels: 2, select: ["missing.X"], common: "m.GP30" },
|
|
];
|
|
p.elc.keys = [
|
|
{ key: "k", detect: "contact", terminals: { a: "mux.2", b: "missing.P" } },
|
|
{ key: "k", detect: "contact", terminals: { a: "VCC", b: "GND" } },
|
|
];
|
|
p.elc.ledChains = [
|
|
{ id: "chain", data: "m.GP0", order: [] },
|
|
{ id: "chain", data: "m.GP1", order: [] },
|
|
];
|
|
p.map.layers[0]!.keymap = [{ keyId: "k", keycode: "MO(Missing)" }];
|
|
const codes = new Set(validateProject(p).map((issue) => issue.code));
|
|
for (const code of [
|
|
"duplicate-provider-id",
|
|
"duplicate-key-wiring",
|
|
"duplicate-led-chain-id",
|
|
"missing-pin-provider",
|
|
"unknown-provider-pin",
|
|
"missing-map-layer-reference",
|
|
])
|
|
expect(codes.has(code), code).toBe(true);
|
|
const unknownPart = validateProject({
|
|
...p,
|
|
elc: {
|
|
...p.elc,
|
|
providers: [{ id: "custom", kind: "mcu", part: "UnknownPart" }],
|
|
keys: [{ key: "k", detect: "contact", terminals: { a: "custom.any_pin", b: "VCC" } }],
|
|
ledChains: [],
|
|
},
|
|
});
|
|
expect(unknownPart.some((issue) => issue.code === "unknown-provider-pin")).toBe(false);
|
|
expect(() => setProvider(p.elc, { id: "bad.id", kind: "mcu", part: "RP2040" })).toThrow();
|
|
});
|
|
|
|
it("selects nested variants and treats sibling alternatives as exclusive", async () => {
|
|
const phy = emptyPhy();
|
|
phy.root = [
|
|
variantGroup("outer", "outer-a", [
|
|
[
|
|
"outer-a",
|
|
"A",
|
|
[
|
|
key("common"),
|
|
variantGroup("inner", "inner-a", [
|
|
["inner-a", "IA", [key("ia")]],
|
|
["inner-b", "IB", [key("ib")]],
|
|
]),
|
|
],
|
|
],
|
|
["outer-b", "B", [key("other")]],
|
|
]),
|
|
];
|
|
const p = await createProject(phy);
|
|
p.idx.order = ["common", "ia", "ib", "other"];
|
|
p.output.variantSelection = { outer: "outer-a", inner: "inner-b" };
|
|
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
|
p.output.matrix = { rowPins: ["m.GP0", "m.GP2"], colPins: ["m.GP1", "m.GP3"] };
|
|
p.elc.keys = ["common", "ia", "ib", "other"].map((id) => ({
|
|
key: id,
|
|
detect: "contact",
|
|
terminals: id === "common" || id === "other" ? { a: "m.GP2", b: "m.GP3" } : { a: "m.GP0", b: "m.GP1" },
|
|
diode: "b-to-a",
|
|
}));
|
|
p.map.layers[0]!.keymap = ["common", "ia", "ib", "other"].map((keyId) => ({ keyId, keycode: "KC_A" }));
|
|
expect(validateProject(p).some((issue) => issue.code === "wiring-collision")).toBe(false);
|
|
const generated = generateMockTarget(p);
|
|
expect(generated.target?.idx).toEqual(["common", "ib"]);
|
|
expect(p.idx.order).toEqual(["common", "ia", "ib", "other"]);
|
|
});
|
|
|
|
it("uses the independently calculated JCS checksum and reports stale references", async () => {
|
|
const phy = emptyPhy();
|
|
expect(await makePhyReference(phy)).toEqual({
|
|
source: "project:phy",
|
|
checksum: "sha256-jcs:277e45f2cae4b3883480599c5569e73fbeec9e1a22bfb9bc323aecc4cb91d843",
|
|
});
|
|
const p = await createProject(phy);
|
|
p.phy.metadata.name = "changed after reference creation";
|
|
const loaded = await loadProject(JSON.stringify(p));
|
|
expect(
|
|
loaded.errors.filter((issue) => issue.code === "phy-reference-mismatch").map((issue) => issue.path),
|
|
).toEqual(["/idx/phyReference", "/elc/phyReference", "/map/phyReference"]);
|
|
});
|
|
});
|