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
355 lines
13 KiB
TypeScript
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;
|
|
}
|