Files
Beyond-KLE/mock/src/domain/validate.ts
T
ewdernaandClaude Opus 5.5 fccbc28bee mock: 仮決定に沿ってモックを実装する(PHY Editor・IDX・ELC・MAP・Generator)
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
2026-09-25 22:42:30 +09:00

355 lines
13 KiB
TypeScript

import type { ProjectFile } from "./project";
import { flattenKeys } from "./geometry";
import type { Node } from "./phy";
import { eligibleKeyIds, unregisteredKeys } from "./idx";
import { parseKeycode } from "./map";
import { validateSchema } from "../io/validateSchema";
export type ValidationIssue = {
severity: "blocksSave" | "blocksOutput" | "warning";
code: string;
message: string;
path?: string;
ids?: string[];
};
/** Performs semantic validation of a complete mock project. T-13/T-16/T-21/T-25. */
export function validateProject(p: ProjectFile): ValidationIssue[] {
const out: ValidationIssue[] = [];
for (const layer of ["phy", "idx", "elc", "map"] as const)
for (const e of validateSchema(layer, p[layer]))
out.push({ severity: "blocksSave", code: "schema", message: e.message, path: `${layer}${e.path}` });
if (p.format !== "beyond-kle-mock-project")
out.push({
severity: "blocksSave",
code: "project-format",
message: "Unsupported ProjectFile format",
path: "/format",
});
if (p.version !== "0.1.0")
out.push({
severity: "blocksSave",
code: "version",
message: "Unsupported ProjectFile version",
path: "/version",
});
for (const layer of ["phy", "idx", "elc", "map"] as const)
if (p[layer].version !== "0.1.0")
out.push({
severity: "blocksSave",
code: "version",
message: `Unsupported ${layer} version`,
path: `/${layer}/version`,
});
const ids = new Map<string, string[]>();
const add = (id: string, path: string) => ids.set(id, [...(ids.get(id) ?? []), path]);
const walk = (ns: Node[], path: string) =>
ns.forEach((n, i) => {
const q = `${path}/${i}`;
add(n.id, q);
if (n.type === "cluster") walk(n.children, q + "/children");
if (n.type === "variantGroup") {
if (!n.children.some((v) => v.id === n.default))
out.push({
severity: "blocksSave",
code: "missing-default-variant",
message: "VariantGroup default does not reference a child",
path: q + "/default",
ids: [n.id, n.default],
});
for (const v of n.children) {
add(v.id, q + "/children");
walk(v.children, q + `/children/${v.id}/children`);
}
}
});
walk(p.phy.root, "/phy/root");
for (const [id, paths] of ids)
if (paths.length > 1)
out.push({
severity: "blocksSave",
code: "duplicate-id",
message: "Duplicate PHY node ID",
ids: [id],
path: paths[1],
});
const nodes = new Set(ids.keys()),
keys = new Set(eligibleKeyIds(p.phy));
const ref = (id: string, path: string, valid: Set<string> = nodes) => {
if (!valid.has(id))
out.push({
severity: "blocksSave",
code: "missing-reference",
message: `Unknown reference: ${id}`,
path,
ids: [id],
});
};
p.idx.order.forEach((id, i) => ref(id, `/idx/order/${i}`, keys));
p.elc.keys.forEach((k, i) => ref(k.key, `/elc/keys/${i}/key`, keys));
p.elc.ledChains?.forEach((c, i) => c.order.forEach((id, j) => ref(id, `/elc/ledChains/${i}/order/${j}`)));
p.map.layers.forEach((l, i) =>
l.keymap.forEach((a, j) => {
ref(a.keyId, `/map/layers/${i}/keymap/${j}/keyId`, keys);
const keycode = parseKeycode(a.keycode);
if (keycode.kind === "layer" && !p.map.layers.some((candidate) => candidate.name === keycode.layer))
out.push({
severity: "blocksSave",
code: "missing-map-layer-reference",
message: `Unknown MAP layer reference: ${keycode.layer}`,
path: `/map/layers/${i}/keymap/${j}/keycode`,
ids: [l.name, keycode.layer ?? ""],
});
}),
);
const providerIds = new Map<string, number>();
p.elc.providers.forEach((provider, index) => {
const first = providerIds.get(provider.id);
if (first !== undefined)
out.push({
severity: "blocksSave",
code: "duplicate-provider-id",
message: "Provider IDs must be unique",
path: `/elc/providers/${index}/id`,
ids: [provider.id],
});
else providerIds.set(provider.id, index);
if (!provider.id || provider.id.includes("."))
out.push({
severity: "blocksSave",
code: "invalid-provider-id",
message: "Provider IDs must be nonempty and cannot contain '.'",
path: `/elc/providers/${index}/id`,
ids: [provider.id],
});
});
const chainIds = new Set<string>();
p.elc.ledChains?.forEach((chain, index) => {
if (chainIds.has(chain.id))
out.push({
severity: "blocksSave",
code: "duplicate-led-chain-id",
message: "LED chain IDs must be unique",
path: `/elc/ledChains/${index}/id`,
ids: [chain.id],
});
chainIds.add(chain.id);
});
const wiredKeys = new Set<string>();
p.elc.keys.forEach((wiring, index) => {
if (wiredKeys.has(wiring.key))
out.push({
severity: "blocksSave",
code: "duplicate-key-wiring",
message: "A key may have only one ELC wiring entry",
path: `/elc/keys/${index}/key`,
ids: [wiring.key],
});
wiredKeys.add(wiring.key);
});
const validatePinRef = (pin: string, path: string) => {
if (pin === "VCC" || pin === "GND") return;
const parts = pin.split(".");
const providerId = parts[0];
const pinName = parts[1];
if (parts.length !== 2 || !providerId || !pinName) {
out.push({
severity: "blocksSave",
code: "invalid-pin-reference",
message: `Malformed PinRef: ${pin}`,
path,
ids: [pin],
});
return;
}
const provider = p.elc.providers.find((candidate) => candidate.id === providerId);
if (!provider) {
out.push({
severity: "blocksSave",
code: "missing-pin-provider",
message: `Unknown PinRef provider: ${providerId}`,
path,
ids: [providerId, pin],
});
return;
}
let valid = true;
if (provider.kind === "analog-mux")
valid = /^(0|[1-9]\d*)$/.test(pinName) && Number(pinName) < provider.channels;
else if (provider.part.toUpperCase() === "RP2040") valid = /^GP(?:[0-9]|[12][0-9])$/.test(pinName);
if (!valid)
out.push({
severity: "blocksSave",
code: "unknown-provider-pin",
message: `Unknown pin ${pinName} on provider ${providerId}`,
path,
ids: [providerId, pinName],
});
};
p.elc.providers.forEach((provider, index) => {
if (provider.kind === "analog-mux") {
provider.select.forEach((pin, i) => validatePinRef(pin, `/elc/providers/${index}/select/${i}`));
validatePinRef(provider.common, `/elc/providers/${index}/common`);
if (provider.enable !== undefined) validatePinRef(provider.enable, `/elc/providers/${index}/enable`);
}
});
p.elc.keys.forEach((wiring, index) =>
Object.entries(wiring.terminals).forEach(([terminal, pin]) =>
validatePinRef(pin, `/elc/keys/${index}/terminals/${terminal}`),
),
);
p.elc.ledChains?.forEach((chain, index) => validatePinRef(chain.data, `/elc/ledChains/${index}/data`));
p.output.matrix?.rowPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/rowPins/${index}`));
p.output.matrix?.colPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/colPins/${index}`));
const profileIds = new Set(p.phy.profiles.map((x) => x.id));
const checkProfiles = (ns: Node[]) =>
ns.forEach((n, i) => {
if (n.type === "key" && !profileIds.has(n.profile))
out.push({
severity: "blocksSave",
code: "missing-profile",
message: `Unknown profile: ${n.profile}`,
path: `/phy/key/${n.id}/profile`,
ids: [n.id, n.profile],
});
if (n.type === "cluster") checkProfiles(n.children);
if (n.type === "variantGroup") n.children.forEach((v) => checkProfiles(v.children));
});
checkProfiles(p.phy.root);
for (const [i, pr] of p.phy.profiles.entries()) {
if (pr.unitSize.x <= 0 || pr.unitSize.y <= 0)
out.push({
severity: "blocksSave",
code: "positive-unit-size",
message: "unitSize values must be positive",
path: `/phy/profiles/${i}/unitSize`,
});
}
const keyWalk = (ns: Node[]) =>
ns.forEach((n) => {
if (n.type === "key" && (n.size.w <= 0 || n.size.h <= 0))
out.push({
severity: "blocksSave",
code: "positive-key-size",
message: "Key size values must be positive",
path: `/phy/key/${n.id}/size`,
ids: [n.id],
});
if (n.type === "cluster") keyWalk(n.children);
if (n.type === "variantGroup") n.children.forEach((v) => keyWalk(v.children));
});
keyWalk(p.phy.root);
const unreg = unregisteredKeys(p.phy, p.idx);
for (const id of unreg)
out.push({
severity: "blocksOutput",
code: "idx-unregistered",
message: "Eligible key is not in IDX order",
ids: [id],
});
const profileSizes = p.phy.profiles.map((x) => `${x.unitSize.x},${x.unitSize.y}`);
if (new Set(profileSizes).size > 1)
out.push({
severity: "warning",
code: "unit-size-mismatch",
message: "Profile unitSize values differ; geometry editing is restricted",
path: "/phy/profiles",
});
let active: string[] = [];
try {
active = flattenKeys(p.phy, p.output.variantSelection)
.filter((x) => !x.key.decal)
.map((x) => x.key.id);
} catch (e) {
out.push({ severity: "blocksSave", code: "invalid-variant-selection", message: String(e) });
}
for (const id of active) {
if (!p.elc.keys.some((k) => k.key === id))
out.push({
severity: "blocksOutput",
code: "unwired-key",
message: "Selected key has no wiring",
ids: [id],
});
for (const l of p.map.layers)
if (!l.keymap.some((a) => a.keyId === id))
out.push({
severity: "blocksOutput",
code: "unassigned-key",
message: `Selected key is not assigned in layer ${l.name}`,
ids: [id, l.name],
});
}
const branches = new Map<string, Array<[string, string]>>();
const exclusivity = (ns: Node[], branch: Array<[string, string]>) =>
ns.forEach((n) => {
if (n.type === "key") branches.set(n.id, branch);
if (n.type === "cluster") exclusivity(n.children, branch);
if (n.type === "variantGroup")
for (const v of n.children) exclusivity(v.children, [...branch, [n.id, v.id]]);
});
exclusivity(p.phy.root, []);
const mutuallyExclusive = (a: string, b: string) => {
for (const [group, variant] of branches.get(a) ?? [])
if ((branches.get(b) ?? []).some(([g, v]) => g === group && v !== variant)) return true;
return false;
};
const unordered = (a: string, b: string) => (a < b ? `${a}\0${b}` : `${b}\0${a}`);
const contacts = p.elc.keys.filter((w) => w.detect === "contact");
for (let i = 0; i < contacts.length; i++)
for (let j = i + 1; j < contacts.length; j++) {
const a = contacts[i]!,
b = contacts[j]!;
if (a.key === b.key || mutuallyExclusive(a.key, b.key)) continue;
const ap = a.diode
? a.diode === "a-to-b"
? [a.terminals.a, a.terminals.b]
: [a.terminals.b, a.terminals.a]
: null;
const bp = b.diode
? b.diode === "a-to-b"
? [b.terminals.a, b.terminals.b]
: [b.terminals.b, b.terminals.a]
: null;
const same =
ap && bp
? ap[0] === bp[0] && ap[1] === bp[1]
: unordered(a.terminals.a, a.terminals.b) === unordered(b.terminals.a, b.terminals.b);
if (same)
out.push({
severity: "blocksOutput",
code: "wiring-collision",
message: "Non-exclusive keys share the same electrical connection",
ids: [a.key, b.key],
});
}
const layerNames = new Set<string>();
for (const [i, l] of p.map.layers.entries()) {
if (layerNames.has(l.name))
out.push({
severity: "blocksSave",
code: "duplicate-layer-name",
message: "Layer names must be unique",
path: `/map/layers/${i}/name`,
ids: [l.name],
});
layerNames.add(l.name);
}
for (const [li, l] of p.map.layers.entries()) {
const assignments = new Map<string, number>();
for (const [ai, a] of l.keymap.entries()) {
const prev = assignments.get(a.keyId);
if (prev !== undefined)
out.push({
severity: "blocksSave",
code: "duplicate-map-key",
message: "A key may occur only once in a layer keymap",
path: `/map/layers/${li}/keymap/${ai}/keyId`,
ids: [a.keyId, l.name],
});
assignments.set(a.keyId, ai);
}
}
return out;
}