From fccbc28beeacc085e373a32d01eb7d8c18102e49 Mon Sep 17 00:00:00 2001 From: EwdErna Date: Fri, 25 Sep 2026 22:42:30 +0900 Subject: [PATCH] =?UTF-8?q?mock:=20=E4=BB=AE=E6=B1=BA=E5=AE=9A=E3=81=AB?= =?UTF-8?q?=E6=B2=BF=E3=81=A3=E3=81=A6=E3=83=A2=E3=83=83=E3=82=AF=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=99=E3=82=8B(PHY=20Editor=E3=83=BBIDX?= =?UTF-8?q?=E3=83=BBELC=E3=83=BBMAP=E3=83=BBGenerator)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT --- mock/.gitignore | 4 + mock/.prettierrc.json | 1 + mock/e2e/elc.spec.ts | 170 + mock/e2e/generator.spec.ts | 73 + mock/e2e/idx.spec.ts | 44 + mock/e2e/map.spec.ts | 103 + mock/e2e/phy.spec.ts | 231 + mock/index.html | 5 + mock/package.json | 28 + mock/playwright.config.ts | 11 + mock/pnpm-lock.yaml | 991 ++++ mock/src/domain/deleteCascade.ts | 129 + mock/src/domain/elc.ts | 131 + mock/src/domain/elcMatrix.ts | 26 + mock/src/domain/generator.ts | 102 + mock/src/domain/geometry.ts | 71 + mock/src/domain/history.ts | 76 + mock/src/domain/ids.ts | 14 + mock/src/domain/idx.ts | 57 + mock/src/domain/keycodeLabels.ts | 76 + mock/src/domain/map.ts | 149 + mock/src/domain/phy.ts | 53 + mock/src/domain/phyCommands.ts | 426 ++ mock/src/domain/phyReference.ts | 11 + mock/src/domain/project.ts | 64 + mock/src/domain/qmkBasicKeycodes.ts | 1209 +++++ mock/src/domain/validate.ts | 354 ++ mock/src/env.d.ts | 5 + mock/src/io/json.ts | 26 + mock/src/io/kleExport.ts | 88 + mock/src/io/kleImport.ts | 192 + mock/src/io/projectFile.ts | 177 + mock/src/io/schemas/elc.schema.json | 134 + mock/src/io/schemas/idx.schema.json | 28 + mock/src/io/schemas/map.schema.json | 54 + mock/src/io/schemas/phy.schema.json | 253 ++ mock/src/io/validateSchema.ts | 27 + mock/src/main.ts | 4 + mock/src/ui/App.vue | 904 ++++ mock/src/ui/common/KeyboardView.vue | 135 + mock/src/ui/defaultLayout.ts | 2 + mock/src/ui/panels/ElcPanel.vue | 564 +++ mock/src/ui/panels/GeneratorPanel.vue | 275 ++ mock/src/ui/panels/IdxPanel.vue | 254 ++ mock/src/ui/panels/MapPanel.vue | 337 ++ mock/src/ui/phy/PhyCanvas.vue | 35 + mock/src/ui/phy/PhyNodeView.vue | 126 + mock/src/ui/phy/PhyTree.vue | 54 + mock/src/ui/phy/dragMath.ts | 13 + mock/src/ui/types.ts | 2 + mock/tests/domain.test.ts | 265 ++ mock/tests/dragMath.test.ts | 12 + mock/tests/elcMatrix.test.ts | 42 + mock/tests/fixtures/kle-oracle.json | 5967 +++++++++++++++++++++++++ mock/tests/kle-oracle.test.ts | 374 ++ mock/tests/phase1.test.ts | 257 ++ mock/tests/review.test.ts | 270 ++ mock/tsconfig.json | 17 + mock/vite.config.ts | 7 + 59 files changed, 15509 insertions(+) create mode 100644 mock/.gitignore create mode 100644 mock/.prettierrc.json create mode 100644 mock/e2e/elc.spec.ts create mode 100644 mock/e2e/generator.spec.ts create mode 100644 mock/e2e/idx.spec.ts create mode 100644 mock/e2e/map.spec.ts create mode 100644 mock/e2e/phy.spec.ts create mode 100644 mock/index.html create mode 100644 mock/package.json create mode 100644 mock/playwright.config.ts create mode 100644 mock/pnpm-lock.yaml create mode 100644 mock/src/domain/deleteCascade.ts create mode 100644 mock/src/domain/elc.ts create mode 100644 mock/src/domain/elcMatrix.ts create mode 100644 mock/src/domain/generator.ts create mode 100644 mock/src/domain/geometry.ts create mode 100644 mock/src/domain/history.ts create mode 100644 mock/src/domain/ids.ts create mode 100644 mock/src/domain/idx.ts create mode 100644 mock/src/domain/keycodeLabels.ts create mode 100644 mock/src/domain/map.ts create mode 100644 mock/src/domain/phy.ts create mode 100644 mock/src/domain/phyCommands.ts create mode 100644 mock/src/domain/phyReference.ts create mode 100644 mock/src/domain/project.ts create mode 100644 mock/src/domain/qmkBasicKeycodes.ts create mode 100644 mock/src/domain/validate.ts create mode 100644 mock/src/env.d.ts create mode 100644 mock/src/io/json.ts create mode 100644 mock/src/io/kleExport.ts create mode 100644 mock/src/io/kleImport.ts create mode 100644 mock/src/io/projectFile.ts create mode 100644 mock/src/io/schemas/elc.schema.json create mode 100644 mock/src/io/schemas/idx.schema.json create mode 100644 mock/src/io/schemas/map.schema.json create mode 100644 mock/src/io/schemas/phy.schema.json create mode 100644 mock/src/io/validateSchema.ts create mode 100644 mock/src/main.ts create mode 100644 mock/src/ui/App.vue create mode 100644 mock/src/ui/common/KeyboardView.vue create mode 100644 mock/src/ui/defaultLayout.ts create mode 100644 mock/src/ui/panels/ElcPanel.vue create mode 100644 mock/src/ui/panels/GeneratorPanel.vue create mode 100644 mock/src/ui/panels/IdxPanel.vue create mode 100644 mock/src/ui/panels/MapPanel.vue create mode 100644 mock/src/ui/phy/PhyCanvas.vue create mode 100644 mock/src/ui/phy/PhyNodeView.vue create mode 100644 mock/src/ui/phy/PhyTree.vue create mode 100644 mock/src/ui/phy/dragMath.ts create mode 100644 mock/src/ui/types.ts create mode 100644 mock/tests/domain.test.ts create mode 100644 mock/tests/dragMath.test.ts create mode 100644 mock/tests/elcMatrix.test.ts create mode 100644 mock/tests/fixtures/kle-oracle.json create mode 100644 mock/tests/kle-oracle.test.ts create mode 100644 mock/tests/phase1.test.ts create mode 100644 mock/tests/review.test.ts create mode 100644 mock/tsconfig.json create mode 100644 mock/vite.config.ts diff --git a/mock/.gitignore b/mock/.gitignore new file mode 100644 index 0000000..f0a232b --- /dev/null +++ b/mock/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ diff --git a/mock/.prettierrc.json b/mock/.prettierrc.json new file mode 100644 index 0000000..4415cc8 --- /dev/null +++ b/mock/.prettierrc.json @@ -0,0 +1 @@ +{ "printWidth": 110 } diff --git a/mock/e2e/elc.spec.ts b/mock/e2e/elc.spec.ts new file mode 100644 index 0000000..cc0b059 --- /dev/null +++ b/mock/e2e/elc.spec.ts @@ -0,0 +1,170 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; +import { readFile } from "node:fs/promises"; + +async function projectJson(page: import("@playwright/test").Page) { + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "プロジェクトを保存" }).click(), + ]); + const file = path.join(test.info().outputDir, download.suggestedFilename()); + await download.saveAs(file); + return JSON.parse(await readFile(file, "utf8")); +} +async function loadProject(page: import("@playwright/test").Page, project: unknown) { + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "プロジェクトを開く" }).click(); + await ( + await chooser + ).setFiles({ + name: "case.bkle.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(project)), + }); +} + +test("provider, key terminals and diode edit wiring and show collisions", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: "ELC" }).click(); + await page.getByTestId("provider-id").fill("MCU"); + await page.getByRole("button", { name: "MCUを追加・更新" }).click(); + await expect(page.getByText(/MCU · mcu · RP2040/)).toBeVisible(); + const view = page.locator(".wiring-box [data-testid=keyboard-view]"); + const first = view.locator(".kv-key").nth(0); + await first.click(); + await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0"); + await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1"); + await page.locator(".edit-form select").nth(2).selectOption("a-to-b"); + await page.getByRole("button", { name: "配線を保存" }).click(); + await expect(page.getByTestId("unwired-count")).toHaveText("15"); + await expect(first).toHaveClass(/ok/); + await expect(first).toContainText("GP0/GP1"); + + await view.locator(".kv-key").nth(1).click(); + await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0"); + await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1"); + await page.locator(".edit-form select").nth(2).selectOption("a-to-b"); + await page.getByRole("button", { name: "配線を保存" }).click(); + await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible(); + await expect(view.locator(".kv-key.error")).toHaveCount(2); +}); + +test("batch wiring applies selected keys in row-major PHY order", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: "ELC" }).click(); + await page.getByTestId("provider-id").fill("MCU"); + await page.getByRole("button", { name: "MCUを追加・更新" }).click(); + const views = page.locator(".wiring-box [data-testid=keyboard-view]"); + const batchView = views.nth(1); + await batchView.locator(".kv-key").nth(0).click(); + await batchView.locator(".kv-key").nth(1).click(); + await page.getByTestId("batch-row-select").selectOption("MCU.GP0"); + await page.getByRole("button", { name: "rowへ追加" }).click(); + await page.getByTestId("batch-col-select").selectOption("MCU.GP1"); + await page.getByRole("button", { name: "colへ追加" }).click(); + await page.getByTestId("batch-col-select").selectOption("MCU.GP2"); + await page.getByRole("button", { name: "colへ追加" }).click(); + await page.getByTestId("batch-wire").click(); + await expect(page.getByTestId("batch-preview")).toBeVisible(); + await expect(page.getByTestId("batch-preview").locator("tbody tr").nth(0)).toContainText("MCU.GP0"); + await page.getByTestId("batch-confirm").click(); + await expect(page.getByTestId("unwired-count")).toHaveText("14"); + await expect(views.first().locator(".kv-key").nth(0)).toContainText("GP0/GP1"); + await expect(views.first().locator(".kv-key").nth(1)).toContainText("GP0/GP2"); +}); + +test("batch wiring previews overwritten assignments and blocks insufficient capacity", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: "ELC" }).click(); + await page.getByTestId("provider-id").fill("MCU"); + await page.getByRole("button", { name: "MCUを追加・更新" }).click(); + const view = page.locator(".wiring-box [data-testid=keyboard-view]"); + await view.locator(".kv-key").first().click(); + await page.locator(".edit-form select").nth(0).selectOption("MCU.GP5"); + await page.locator(".edit-form select").nth(1).selectOption("MCU.GP6"); + await page.getByRole("button", { name: "配線を保存" }).click(); + const batchView = page.locator(".wiring-box [data-testid=keyboard-view]").nth(1); + for (let i = 0; i < 3; i++) await batchView.locator(".kv-key").nth(i).click(); + await page.getByTestId("batch-row-select").selectOption("MCU.GP0"); + await page.getByRole("button", { name: "rowへ追加" }).click(); + await page.getByTestId("batch-col-select").selectOption("MCU.GP1"); + await page.getByRole("button", { name: "colへ追加" }).click(); + await page.getByTestId("batch-wire").click(); + await expect(page.getByTestId("batch-capacity-error")).toBeVisible(); + await expect(page.getByTestId("batch-confirm")).toBeDisabled(); + await page.getByTestId("batch-col-select").selectOption("MCU.GP2"); + await page.getByRole("button", { name: "colへ追加" }).click(); + await page.getByTestId("batch-wire").click(); + const rows = page.getByTestId("batch-preview").locator("tbody tr"); + await expect(rows).toHaveCount(3); + await expect(rows.first()).toContainText("上書き"); +}); + +test("diode direction is part of collision identity and unknown MCU parts accept free pin names", async ({ + page, +}) => { + await page.goto("/"); + let source = await projectJson(page); + const [k1, k2] = source.idx.order; + source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }]; + source.elc.keys = [ + { key: k1, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "a-to-b" }, + { key: k2, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "b-to-a" }, + ]; + await loadProject(page, source); + await page.getByRole("button", { name: "ELC" }).click(); + await expect(page.getByText("wiring-collision", { exact: false })).toHaveCount(0); + source.elc.keys[1].diode = "a-to-b"; + await loadProject(page, source); + await page.getByRole("button", { name: "ELC" }).click(); + await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible(); + + await page.getByTestId("provider-id").fill("ALT"); + await page.locator("input[list=mcu-parts]").fill("Unknown-MCU"); + await page.getByRole("button", { name: "MCUを追加・更新" }).click(); + await expect(page.getByText("未知の品番はピン名を自由入力します。")).toBeVisible(); + const key = page.locator(".wiring-box .kv-key").nth(2); + await key.click(); + await page.getByLabel("a端子自由入力").fill("ALT.PIN_X"); + await page.getByLabel("b端子自由入力").fill("ALT.PIN_Y"); + await page.getByRole("button", { name: "配線を保存" }).click(); + await expect(page.getByTestId("unwired-count")).toHaveText("13"); +}); + +test("LED chain candidates include only per-key LED keys and @led parts across all variants", async ({ + page, +}) => { + await page.goto("/"); + const source = await projectJson(page); + const keys = [] as any[]; + const walk = (nodes: any[]) => + nodes.forEach((n) => { + if (n.type === "key") keys.push(n); + else if (n.type === "cluster") walk(n.children); + else if (n.type === "variantGroup") n.children.forEach((v: any) => walk(v.children)); + }); + walk(source.phy.root); + keys[0].perKeyLED = true; + keys[1].perKeyLED = false; + source.phy.root.push({ + type: "component", + id: "cmp-test-led", + transform: { x: 0, y: 0, rotation: 0 }, + partGroup: "@led", + }); + source.phy.root.push({ + type: "component", + id: "cmp-test-other", + transform: { x: 0, y: 0, rotation: 0 }, + partGroup: "@screw", + }); + await loadProject(page, source); + await page.getByRole("button", { name: "ELC" }).click(); + await page.getByLabel("チェーンID").fill("LED1"); + await page.getByLabel("データピン").fill("MCU.DATA"); + await page.getByRole("button", { name: "作成" }).click(); + await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[0].id}"]`)).toHaveCount(1); + await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-led"]')).toHaveCount(1); + await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[1].id}"]`)).toHaveCount(0); + await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-other"]')).toHaveCount(0); +}); diff --git a/mock/e2e/generator.spec.ts b/mock/e2e/generator.spec.ts new file mode 100644 index 0000000..7408b48 --- /dev/null +++ b/mock/e2e/generator.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +test("generates the 4x4 preview and blocks output when a wire is removed", async ({ page }) => { + await page.goto("/"); + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "プロジェクトを保存" }).click(), + ]); + const file = path.join(test.info().outputDir, download.suggestedFilename()); + await download.saveAs(file); + const source = JSON.parse(await (await import("node:fs/promises")).readFile(file, "utf8")); + const keyIds: string[] = source.idx.order; + source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }]; + source.elc.keys = keyIds.map((key, i) => ({ + key, + detect: "contact", + terminals: { a: `MCU.GP${Math.floor(i / 4)}`, b: `MCU.GP${4 + (i % 4)}` }, + diode: "a-to-b", + })); + source.map.layers[0].keymap = keyIds.map((key, i) => ({ keyId: key, keycode: `KC_${i % 10}` })); + const uploadProject = async (obj: unknown) => { + const chooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "プロジェクトを開く" }).click(); + await ( + await chooserPromise + ).setFiles({ + name: "fixture.bkle.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(obj)), + }); + }; + await uploadProject(source); + await page.getByRole("button", { name: "Generator" }).click(); + for (let i = 0; i < 4; i++) { + await page.getByTestId("row-pin-select").selectOption(`MCU.GP${i}`); + await page.getByRole("button", { name: "row追加" }).click(); + await page.getByTestId("col-pin-select").selectOption(`MCU.GP${i + 4}`); + await page.getByRole("button", { name: "col追加" }).click(); + } + await page.getByTestId("generate-button").click(); + await expect(page.getByTestId("mapping-count")).toHaveText("16行"); + const rows = page.locator(".preview tbody tr"); + await expect(rows).toHaveCount(16); + await expect(rows.nth(0).locator("td").nth(2)).toHaveText("0"); + await expect(rows.nth(0).locator("td").nth(3)).toHaveText("0"); + await expect(rows.nth(4).locator("td").nth(2)).toHaveText("1"); + await expect(rows.nth(4).locator("td").nth(3)).toHaveText("0"); + + await page.getByRole("button", { name: "ELC" }).click(); + await page.locator(".wiring-box [data-testid=keyboard-view] .kv-key").first().click(); + await page.getByRole("button", { name: "配線解除" }).click(); + await page.getByRole("button", { name: "Generator" }).click(); + await page.getByTestId("generate-button").click(); + await expect(page.locator(".errors").getByText("unwired-key", { exact: true })).toBeVisible(); + await expect(page.getByTestId("mapping-count")).toHaveCount(0); +}); + +test("project undo invalidates a result generated from the newer project state", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: "ELC" }).click(); + await page.getByTestId("provider-id").fill("MCU"); + await page.getByRole("button", { name: "MCUを追加・更新" }).click(); + await page.getByRole("button", { name: "Generator" }).click(); + await page.getByTestId("generate-button").click(); + await expect(page.locator(".errors")).toBeVisible(); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.locator(".errors")).toHaveCount(0); + await expect(page.getByTestId("mapping-count")).toHaveCount(0); + await page.getByRole("button", { name: "Redo" }).click(); + await expect(page.locator(".errors")).toHaveCount(0); + await expect(page.getByTestId("mapping-count")).toHaveCount(0); +}); diff --git a/mock/e2e/idx.spec.ts b/mock/e2e/idx.spec.ts new file mode 100644 index 0000000..81488ee --- /dev/null +++ b/mock/e2e/idx.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: "IDX", exact: true }).click(); +}); + +test("register unindexed key, reorder its output number, and undo", async ({ page }) => { + await page.getByRole("button", { name: "PHY", exact: true }).click(); + await page.getByRole("button", { name: "キー追加" }).click(); + await page.getByRole("button", { name: "IDX", exact: true }).click(); + + const unregisteredRow = page.locator(".idx-all-keys li.unregistered").first(); + const keyId = (await unregisteredRow.locator("b").innerText()).trim(); + await unregisteredRow.getByRole("button", { name: "登録" }).click(); + const row = page.getByTestId(`idx-row-${keyId}`); + await expect(row).toBeVisible(); + const previewKey = page.locator(`[data-testid="keyboard-view"] [data-key-id="${keyId}"] text`); + await expect(previewKey).toHaveText("16"); + + await row.getByRole("button", { name: `${keyId}を上へ` }).click(); + await expect(previewKey).toHaveText("15"); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(previewKey).toHaveText("16"); +}); + +test("output numbers recalculate after switching to a variant that excludes ordered keys", async ({ + page, +}) => { + await page.getByRole("button", { name: "PHY", exact: true }).click(); + const treeKeys = page.locator(".tree-node").filter({ hasText: "キー ·" }); + const firstId = await treeKeys.nth(0).getAttribute("data-tree-id"); + const secondId = await treeKeys.nth(1).getAttribute("data-tree-id"); + await treeKeys.nth(0).click(); + await treeKeys.nth(1).click({ modifiers: ["Shift"] }); + await page.getByRole("button", { name: "選択からVariantGroup" }).click(); + await page.getByRole("button", { name: "IDX", exact: true }).click(); + await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力 0"); + await page.getByLabel("Layout 出力バリアント").selectOption({ label: "Option B" }); + await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力対象外"); + await expect(page.getByTestId(`idx-row-${secondId}`)).toContainText("出力対象外"); + const remaining = page.locator(".idx-all-keys .idx-preview").filter({ hasText: "出力 0" }); + await expect(remaining).toHaveCount(1); +}); diff --git a/mock/e2e/map.spec.ts b/mock/e2e/map.spec.ts new file mode 100644 index 0000000..fceb826 --- /dev/null +++ b/mock/e2e/map.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; +import path from "node:path"; +import { readFile } from "node:fs/promises"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: "MAP", exact: true }).click(); +}); + +test("assign codes and rename referenced layers safely", async ({ page }) => { + await page.getByTestId("map-add-layer").click(); + await expect(page.getByRole("alert")).toContainText("レイヤー名を入力してください"); + await page.getByLabel("追加するレイヤー名").fill("Base"); + await page.getByTestId("map-add-layer").click(); + await expect(page.getByRole("alert")).toContainText("already exists"); + + const keys = page.locator('[data-testid="keyboard-view"] [data-key-id]'); + const firstId = await keys.nth(0).getAttribute("data-key-id"); + const secondId = await keys.nth(1).getAttribute("data-key-id"); + await keys.nth(0).click(); + await page.getByLabel("キーコード").fill("KC_A"); + await page.getByRole("button", { name: "割り当て", exact: true }).click(); + await expect(page.locator(`[data-key-id="${firstId}"] text`)).toContainText("KC_A"); + + await page.getByLabel("追加するレイヤー名").fill("Nav"); + await page.getByTestId("map-add-layer").click(); + await expect(page.getByRole("tab", { name: "Nav" })).toHaveAttribute("aria-selected", "true"); + await keys.nth(1).click(); + await page.getByLabel("キーコード").fill("MO(Nav)"); + await page.getByRole("button", { name: "割り当て", exact: true }).click(); + await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Nav)"); + + await page.getByLabel("新しい名前").fill("Navigation"); + await page.getByTestId("map-rename-layer").click(); + await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Navigation)"); + await page.getByTestId("map-delete-layer").click(); + await expect(page.getByRole("alert")).toContainText("referenced"); +}); + +test("assignment click creates one undo step and unknown existing codes stay read-only", async ({ page }) => { + const key = page.locator('[data-testid="keyboard-view"] [data-key-id]').first(); + const id = await key.getAttribute("data-key-id"); + await key.click(); + await page.getByLabel("キーコード").fill("KC_A"); + await page.getByRole("button", { name: "割り当て", exact: true }).click(); + await expect(page.locator(`[data-key-id="${id}"] text`)).toContainText("KC_A"); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.locator(`[data-key-id="${id}"] text`)).not.toContainText("KC_A"); + + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "プロジェクトを保存" }).click(), + ]); + const filePath = path.join(test.info().outputDir, download.suggestedFilename()); + await download.saveAs(filePath); + const file = await readFile(filePath, "utf8"); + const source = JSON.parse(file); + source.map.layers[0].keymap = [{ keyId: id, keycode: "KC_UNKNOWN_VENDOR_CODE" }]; + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "プロジェクトを開く" }).click(); + await ( + await chooser + ).setFiles({ + name: "unknown.bkle.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(source)), + }); + await page.getByRole("button", { name: "MAP", exact: true }).click(); + await page.locator(`[data-testid="keyboard-view"] [data-key-id="${id}"]`).click(); + await expect(page.getByRole("status")).toContainText("未知のキーコード"); + await expect(page.getByRole("button", { name: "割り当て", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "割り当て解除" })).toBeDisabled(); +}); + +test("MAP editing controls are disabled when profile unit sizes differ", async ({ page }) => { + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "プロジェクトを保存" }).click(), + ]); + const filePath = path.join(test.info().outputDir, download.suggestedFilename()); + await download.saveAs(filePath); + const source = JSON.parse(await readFile(filePath, "utf8")); + source.phy.profiles.push({ + ...source.phy.profiles[0], + id: "alternate", + name: "Alternate", + unitSize: { x: 18, y: 18 }, + }); + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "プロジェクトを開く" }).click(); + await ( + await chooser + ).setFiles({ + name: "unit-mismatch.bkle.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(source)), + }); + await page.getByRole("button", { name: "MAP", exact: true }).click(); + await expect(page.getByTestId("map-add-layer")).toBeDisabled(); + await expect(page.getByTestId("map-rename-layer")).toBeDisabled(); + await expect(page.getByTestId("map-delete-layer")).toBeDisabled(); + await expect(page.getByLabel("キーコード")).toHaveCount(0); +}); diff --git a/mock/e2e/phy.spec.ts b/mock/e2e/phy.spec.ts new file mode 100644 index 0000000..0f46ef3 --- /dev/null +++ b/mock/e2e/phy.spec.ts @@ -0,0 +1,231 @@ +import { expect, test } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await expect(page.locator('[data-node-type="key"]')).toHaveCount(16); +}); + +test("drag snaps to quarter units and undo/redo", async ({ page }) => { + const key = page.locator('[data-node-type="key"]').first(); + const before = await key.getAttribute("transform"); + const box = await key.boundingBox(); + if (!box) throw new Error("Key has no bounds"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2 + 2, { steps: 3 }); + await page.mouse.up(); + const moved = await key.getAttribute("transform"); + expect(moved).not.toBe(before); + const x = Number(moved?.match(/translate\(([-\d.]+)/)?.[1]); + expect(x / 0.25).toBeCloseTo(Math.round(x / 0.25), 6); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(key).toHaveAttribute("transform", before!); + await page.getByRole("button", { name: "Redo" }).click(); + await expect(key).toHaveAttribute("transform", moved!); +}); + +test("dragging inside a rotated cluster tracks pointer movement", async ({ page }) => { + const treeNodes = page.locator("[data-tree-id]"); + await treeNodes.nth(0).click(); + await treeNodes.nth(1).click({ modifiers: ["Shift"] }); + await page.getByRole("button", { name: "クラスター化" }).click(); + const cluster = page.locator('[data-node-type="cluster"]').first(); + const clusterId = await cluster.getAttribute("data-node-id"); + await page.locator(`[data-tree-id="${clusterId}"]`).click(); + const rotation = page.getByLabel("Rotation"); + await rotation.fill("30"); + await rotation.dispatchEvent("change"); + const key = page.locator('[data-node-type="key"]').first(); + const box = await key.boundingBox(); + if (!box) throw new Error("Key bounds missing"); + const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + const before = { ...start }; + const localBefore = await key.getAttribute("transform"); + const matrix = await key.evaluate((el) => { + const m = (el.parentElement as unknown as SVGGElement).getScreenCTM()!; + return [m.a, m.b, m.c, m.d] as const; + }); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 32, start.y, { steps: 4 }); + await page.mouse.up(); + const after = await key.boundingBox(); + if (!after) throw new Error("Key bounds missing"); + const screenDx = after.x + after.width / 2 - before.x; + const localAfter = await key.getAttribute("transform"); + const parse = (value: string | null) => { + const match = value?.match(/translate\(([-\d.]+),([-\d.]+)\)/); + return { x: Number(match?.[1]), y: Number(match?.[2]) }; + }; + const delta = { + x: parse(localAfter).x - parse(localBefore).x, + y: parse(localAfter).y - parse(localBefore).y, + }; + expect(Math.abs(screenDx - (matrix[0] * delta.x + matrix[2] * delta.y))).toBeLessThan(4); + expect( + Math.abs(after.y + after.height / 2 - before.y - (matrix[1] * delta.x + matrix[3] * delta.y)), + ).toBeLessThan(4); + expect(Math.abs(screenDx - 32)).toBeLessThan(15); +}); + +test("cluster and drag leave every other key at the same screen coordinates", async ({ page }) => { + const positions = async (): Promise> => + page.locator('[data-node-type="key"]').evaluateAll((els) => + Object.fromEntries( + els.map((el) => { + const r = el.getBoundingClientRect(); + return [el.getAttribute("data-node-id"), [r.x + r.width / 2, r.y + r.height / 2]]; + }), + ), + ) as Promise>; + const before = await positions(); + const treeNodes = page.locator("[data-tree-id]"); + const targetId = await page.locator('[data-node-type="key"]').first().getAttribute("data-node-id"); + await treeNodes.nth(0).click(); + await treeNodes.nth(1).click({ modifiers: ["Shift"] }); + await page.getByRole("button", { name: "クラスター化" }).click(); + const clustered = await positions(); + for (const [id, point] of Object.entries(before)) { + expect(clustered[id]?.[0]).toBeCloseTo(point[0], 0); + expect(clustered[id]?.[1]).toBeCloseTo(point[1], 0); + } + + const target = page.locator(`[data-node-id="${targetId}"]`).first(); + const box = await target.boundingBox(); + if (!box) throw new Error("Target key bounds missing"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2, { steps: 3 }); + await page.mouse.up(); + const after = await positions(); + for (const id of Object.keys(before)) { + if (id === targetId) continue; + expect(after[id]?.[0]).toBeCloseTo(before[id]![0]!, 0); + expect(after[id]?.[1]).toBeCloseTo(before[id]![1]!, 0); + } +}); + +test("variant deletion uses the reference review and removes IDX references", async ({ page }) => { + const initialKey = page.locator('[data-node-type="key"]').first(); + const keyId = await initialKey.getAttribute("data-node-id"); + await page.locator(`[data-tree-id="${keyId}"]`).click(); + await page.getByRole("button", { name: "選択からVariantGroup" }).click(); + const controls = page.locator(".canvas-head label").last(); + await controls.locator("button").first().click(); // Add a third Variant so deletion can proceed. + await controls.locator("button").last().click(); // Request deletion of the selected/default Variant. + await expect(page.locator(".confirm-box")).toContainText("削除される参照"); + await expect(page.locator(".confirm-box")).toContainText("idx"); + await page.getByRole("button", { name: "参照と一緒に削除" }).click(); + await page.getByRole("button", { name: "IDX", exact: true }).click(); + await expect(page.getByTestId(`idx-row-${keyId}`)).toHaveCount(0); +}); + +test("multi-key rotation is one undo step", async ({ page }) => { + const keys = page.locator('[data-node-type="key"]'); + const ids = [ + await keys.nth(0).getAttribute("data-node-id"), + await keys.nth(1).getAttribute("data-node-id"), + ]; + const before = await Promise.all( + ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")), + ); + const tree = page.locator("[data-tree-id]"); + await tree.nth(0).click(); + await tree.nth(1).click({ modifiers: ["Shift"] }); + await page.getByRole("button", { name: "選択を+15°回転" }).click(); + const after = await Promise.all( + ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")), + ); + expect(after).not.toEqual(before); + await page.getByRole("button", { name: "Undo" }).click(); + const undone = await Promise.all( + ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")), + ); + expect(undone).toEqual(before); +}); + +test("unsupported versions are rejected; save-blocked projects stay open and mismatches block panel edits", async ({ + page, +}) => { + const first = page.locator('[data-node-type="key"]').first(); + const firstId = await first.getAttribute("data-node-id"); + await page.locator(`[data-tree-id="${firstId}"]`).click(); + await page.getByRole("button", { name: "選択からVariantGroup" }).click(); + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "プロジェクトを保存" }).click(), + ]); + const filepath = path.join(test.info().outputDir, download.suggestedFilename()); + await download.saveAs(filepath); + const fs = await import("node:fs/promises"); + const original = JSON.parse(await fs.readFile(filepath, "utf8")); + const load = async (value: unknown, name: string) => { + const chooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "プロジェクトを開く" }).click(); + await ( + await chooserPromise + ).setFiles({ + name, + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(value)), + }); + }; + await load({ ...original, version: "0.2.0" }, "unsupported.bkle.json"); + await expect(page.locator('[data-node-type="key"]')).toHaveCount(16); + await expect(page.locator(".error")).toContainText("Unsupported ProjectFile version"); + + const duplicate = structuredClone(original); + duplicate.idx.order[0] = "missing-key-reference"; + await load(duplicate, "missing-reference.bkle.json"); + await expect(page.getByTestId("save-blocked")).toBeVisible(); + + const mismatch = structuredClone(original); + mismatch.phy.profiles.push({ ...mismatch.phy.profiles[0], id: "other", unitSize: { x: 2, y: 2 } }); + await load(mismatch, "unit-mismatch.bkle.json"); + await expect(page.getByText("unitSize が揃っていないPHYは編集できません。", { exact: true })).toBeVisible(); + await page.getByLabel("Layout").selectOption({ label: "Option B" }); + await expect(page.locator('[data-node-type="key"]')).toHaveCount(15); + await page.getByRole("button", { name: "MAP", exact: true }).click(); + await expect(page.getByLabel("追加するレイヤー名")).toBeDisabled(); + await expect(page.getByTestId("map-add-layer")).toBeDisabled(); + await expect(page.getByRole("tab", { name: "Blocked" })).toHaveCount(0); +}); + +test("project download can be loaded back", async ({ page }) => { + const key = page.locator('[data-node-type="key"]').first(); + const box = await key.boundingBox(); + if (!box) throw new Error("Key bounds missing"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 16, box.y + box.height / 2, { steps: 2 }); + await page.mouse.up(); + const savedTransform = await key.getAttribute("transform"); + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "プロジェクトを保存" }).click(), + ]); + const filepath = path.join(test.info().outputDir, download.suggestedFilename()); + await download.saveAs(filepath); + const chooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "プロジェクトを開く" }).click(); + const chooser = await chooserPromise; + await chooser.setFiles(filepath); + await expect(page.locator('[data-node-type="key"]')).toHaveCount(16); + await expect(page.locator('[data-node-type="key"]').first()).toHaveAttribute("transform", savedTransform!); + await expect(page.locator('[data-testid="phy-canvas"]')).toBeVisible(); +}); + +test("KLE import creates sixty visible keys", async ({ page }) => { + const kle = JSON.parse(await readFile(path.resolve(process.cwd(), "../samples/kle/cr0wn60.json"), "utf8")); + const chooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "KLE JSONから新規" }).click(); + const chooser = await chooserPromise; + await chooser.setFiles({ + name: "cr0wn60.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(kle)), + }); + await expect(page.locator('[data-node-type="key"]')).toHaveCount(60); +}); diff --git a/mock/index.html b/mock/index.html new file mode 100644 index 0000000..ca0e851 --- /dev/null +++ b/mock/index.html @@ -0,0 +1,5 @@ + + + Beyond KLE Mock +
+ diff --git a/mock/package.json b/mock/package.json new file mode 100644 index 0000000..71da051 --- /dev/null +++ b/mock/package.json @@ -0,0 +1,28 @@ +{ + "name": "beyond-kle-mock", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run", + "e2e": "playwright test" + }, + "dependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "vue": "^3.5.43" + }, + "devDependencies": { + "@playwright/test": "^1.63.0", + "@types/node": "^26.6.2", + "@vitejs/plugin-vue": "^6.0.9", + "prettier": "^3.9.9", + "typescript": "^6.0.3", + "vite": "^8.3.1", + "vitest": "^5.0.1", + "vue-tsc": "^3.3.11" + } +} diff --git a/mock/playwright.config.ts b/mock/playwright.config.ts new file mode 100644 index 0000000..8dee9da --- /dev/null +++ b/mock/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + use: { browserName: "chromium", headless: true, baseURL: "http://127.0.0.1:5173" }, + webServer: { + command: "npx vite --host 127.0.0.1", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/mock/pnpm-lock.yaml b/mock/pnpm-lock.yaml new file mode 100644 index 0000000..bdb9813 --- /dev/null +++ b/mock/pnpm-lock.yaml @@ -0,0 +1,991 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + vue: + specifier: ^3.5.43 + version: 3.5.43(typescript@6.0.3) + devDependencies: + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 + '@types/node': + specifier: ^26.6.2 + version: 26.6.2 + '@vitejs/plugin-vue': + specifier: ^6.0.9 + version: 6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3)) + prettier: + specifier: ^3.9.9 + version: 3.9.9 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@26.6.2) + vitest: + specifier: ^5.0.1 + version: 5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2)) + vue-tsc: + specifier: ^3.3.11 + version: 3.3.11(typescript@6.0.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + + '@vitejs/plugin-vue@6.0.9': + resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/language-core@3.3.11': + resolution: {integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==} + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-tsc@3.3.11: + resolution: {integrity: sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.151.0': {} + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@rolldown/binding-android-arm-eabi@1.2.11': + optional: true + + '@rolldown/binding-android-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-x64@1.2.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.11': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.11': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.6.2': + dependencies: + undici-types: 8.9.0 + + '@vitejs/plugin-vue@6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.1(@types/node@26.6.2) + vue: 3.5.43(typescript@6.0.3) + + '@vitest/mocker@5.0.1(vite@8.3.1(@types/node@26.6.2))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.1 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(@types/node@26.6.2) + + '@vitest/spy@5.0.1': {} + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/language-core@3.3.11': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.7 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@3.2.1: {} + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + entities@7.0.1: {} + + es-module-lexer@2.3.2: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + json-schema-traverse@1.0.0: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + muggle-string@0.4.1: {} + + nanoid@3.3.19: {} + + obug@2.2.1: {} + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.9: {} + + require-from-string@2.0.2: {} + + rolldown@1.2.11: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@6.0.3: {} + + undici-types@8.9.0: {} + + vite@8.3.1(@types/node@26.6.2): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.2 + fsevents: 2.3.3 + + vitest@5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.1(@types/node@26.6.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.6.2 + transitivePeerDependencies: + - msw + + vscode-uri@3.2.0: {} + + vue-tsc@3.3.11(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.11 + typescript: 6.0.3 + + vue@3.5.43(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 6.0.3 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/mock/src/domain/deleteCascade.ts b/mock/src/domain/deleteCascade.ts new file mode 100644 index 0000000..2424e4b --- /dev/null +++ b/mock/src/domain/deleteCascade.ts @@ -0,0 +1,129 @@ +import type { ProjectFile } from "./project"; +import type { Node, Variant, VariantGroupNode } from "./phy"; + +export type CascadeReference = { layer: "idx" | "elc" | "map" | "ledChains"; location: string; id: string }; +type DeleteTarget = + { kind: "node"; node: Node } | { kind: "variant"; variant: Variant; group: VariantGroupNode }; + +function descendants(node: Node | Variant): string[] { + const ids = [node.id]; + if (node.type === "cluster") node.children.forEach((child) => ids.push(...descendants(child))); + if (node.type === "variantGroup") for (const variant of node.children) ids.push(...descendants(variant)); + if (node.type === "variant") node.children.forEach((child) => ids.push(...descendants(child))); + return ids; +} + +/** Lists all saved references to a PHY node, Variant, and its descendants. T-15. */ +export function listDeleteReferences(p: ProjectFile, nodeId: string): CascadeReference[] { + const target = findTarget(p.phy.root, nodeId); + if (!target) throw new Error(`Unknown node: ${nodeId}`); + const ids = new Set(descendants(target.kind === "node" ? target.node : target.variant)); + const out: CascadeReference[] = []; + p.idx.order.forEach((id) => { + if (ids.has(id)) out.push({ layer: "idx", location: "/idx/order", id }); + }); + p.elc.keys.forEach((w, i) => { + if (ids.has(w.key)) out.push({ layer: "elc", location: `/elc/keys/${i}`, id: w.key }); + }); + p.elc.ledChains?.forEach((chain) => + chain.order.forEach((id) => { + if (ids.has(id)) out.push({ layer: "ledChains", location: `/elc/ledChains/${chain.id}`, id }); + }), + ); + p.map.layers.forEach((layer) => + layer.keymap.forEach((assignment) => { + if (ids.has(assignment.keyId)) + out.push({ layer: "map", location: `/map/layers/${layer.name}`, id: assignment.keyId }); + }), + ); + return out; +} + +/** Removes a PHY node or Variant and its references in one immutable project result. T-15. */ +export function deleteNodeCascade(p: ProjectFile, nodeId: string): ProjectFile { + const o = structuredClone(p); + const target = findTarget(o.phy.root, nodeId); + if (!target) throw new Error(`Unknown node: ${nodeId}`); + const deletedIds = new Set(descendants(target.kind === "node" ? target.node : target.variant)); + removeTarget(o.phy.root, target); + + o.idx.order = o.idx.order.filter((id) => !deletedIds.has(id)); + o.elc.keys = o.elc.keys.filter((w) => !deletedIds.has(w.key)); + if (o.elc.ledChains) + o.elc.ledChains = o.elc.ledChains.map((chain) => ({ + ...chain, + order: chain.order.filter((id) => !deletedIds.has(id)), + })); + o.map.layers = o.map.layers.map((layer) => ({ + ...layer, + keymap: layer.keymap.filter((assignment) => !deletedIds.has(assignment.keyId)), + })); + + if (target.kind === "variant") { + if (target.group.default === nodeId) target.group.default = target.group.children[0]!.id; + if (o.output.variantSelection[target.group.id] === nodeId) + o.output.variantSelection[target.group.id] = target.group.default; + } + pruneVariantSelection(o); + return o; +} + +function findTarget(nodes: Node[], id: string): DeleteTarget | undefined { + for (const node of nodes) { + if (node.id === id) return { kind: "node", node }; + if (node.type === "cluster") { + const found = findTarget(node.children, id); + if (found) return found; + } + if (node.type === "variantGroup") + for (const variant of node.children) { + if (variant.id === id) return { kind: "variant", variant, group: node }; + const found = findTarget(variant.children, id); + if (found) return found; + } + } + return undefined; +} + +function removeTarget(nodes: Node[], target: DeleteTarget): void { + const id = target.kind === "node" ? target.node.id : target.variant.id; + if (target.kind === "variant") { + const { group, variant } = target; + if (group.children.length <= 2) + throw new Error("Cannot delete a variant leaving fewer than two variants"); + group.children.splice(group.children.indexOf(variant), 1); + if (group.default === id) group.default = group.children[0]!.id; + return; + } + const index = nodes.findIndex((node) => node.id === id); + if (index >= 0) { + nodes.splice(index, 1); + return; + } + for (const node of nodes) { + if (node.type === "cluster") removeTarget(node.children, target); + if (node.type === "variantGroup") + for (const variant of node.children) removeTarget(variant.children, target); + } +} + +function pruneVariantSelection(project: ProjectFile): void { + const valid = new Map; defaultId: string }>(); + const walk = (nodes: Node[]) => + nodes.forEach((node) => { + if (node.type === "cluster") walk(node.children); + if (node.type === "variantGroup") { + valid.set(node.id, { + ids: new Set(node.children.map((variant) => variant.id)), + defaultId: node.default, + }); + node.children.forEach((variant) => walk(variant.children)); + } + }); + walk(project.phy.root); + for (const [groupId, variantId] of Object.entries(project.output.variantSelection)) { + const group = valid.get(groupId); + if (!group) delete project.output.variantSelection[groupId]; + else if (!group.ids.has(variantId)) project.output.variantSelection[groupId] = group.defaultId; + } +} diff --git a/mock/src/domain/elc.ts b/mock/src/domain/elc.ts new file mode 100644 index 0000000..ca60231 --- /dev/null +++ b/mock/src/domain/elc.ts @@ -0,0 +1,131 @@ +import type { PhyReference } from "./idx"; +export type PinRef = string; +export type McuProvider = { id: string; kind: "mcu"; part: string; [key: string]: unknown }; +export type AnalogMuxProvider = { + id: string; + kind: "analog-mux"; + channels: number; + select: PinRef[]; + common: PinRef; + enable?: PinRef; + [key: string]: unknown; +}; +export type ContactWiring = { + key: string; + detect: "contact"; + terminals: { a: PinRef; b: PinRef }; + diode?: "a-to-b" | "b-to-a"; + [key: string]: unknown; +}; +export type AnalogWiring = { + key: string; + detect: "analog"; + terminals: { out: PinRef; vcc: PinRef; gnd: PinRef }; + [key: string]: unknown; +}; +export type KeyWiring = ContactWiring | AnalogWiring; +export type LedChain = { id: string; data: PinRef; order: string[]; [key: string]: unknown }; +export type ElcFile = { + version: string; + phyReference: PhyReference; + providers: (McuProvider | AnalogMuxProvider)[]; + keys: KeyWiring[]; + ledChains?: LedChain[]; + [key: string]: unknown; +}; +/** Returns RP2040 GPIO pin suggestions, or no fixed list for unknown parts. T-26. */ +export function mcuPinCandidates(provider: McuProvider): string[] { + return provider.part.toUpperCase() === "RP2040" ? Array.from({ length: 30 }, (_, i) => `GP${i}`) : []; +} +/** Creates an empty ELC file. */ +export function createElc(ref: PhyReference): ElcFile { + return { version: "0.1.0", phyReference: structuredClone(ref), providers: [], keys: [], ledChains: [] }; +} +/** Adds or replaces provider settings, rejecting IDs that conflict with PinRef syntax. T-26. */ +export function setProvider(elc: ElcFile, p: McuProvider | AnalogMuxProvider): ElcFile { + if (!p.id || p.id.includes(".")) throw new Error("Provider ID must be nonempty and cannot contain '.'"); + const o = structuredClone(elc); + const i = o.providers.findIndex((x) => x.id === p.id); + if (i < 0) o.providers.push(structuredClone(p)); + else { + const old = o.providers[i]!; + const known = new Set(["id", "kind", "part", "channels", "select", "common", "enable"]); + const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key))); + o.providers[i] = { ...extensions, ...(old.kind === p.kind ? old : {}), ...p } as + McuProvider | AnalogMuxProvider; + } + return o; +} +/** Removes a provider and its dependent references. */ +export function removeProvider(elc: ElcFile, id: string): ElcFile { + const o = structuredClone(elc), + removed = new Set([id]); + let changed = true; + while (changed) { + changed = false; + for (const p of o.providers) + if (p.kind === "analog-mux" && !removed.has(p.id)) { + const refs = [...p.select, p.common, ...(p.enable ? [p.enable] : [])]; + if (refs.some((ref) => [...removed].some((owner) => ref.startsWith(owner + ".")))) { + removed.add(p.id); + changed = true; + } + } + } + const pin = (s: string) => [...removed].some((owner) => s.startsWith(owner + ".")); + o.providers = o.providers.filter((p) => !removed.has(p.id)); + o.keys = o.keys.filter((k) => !Object.values(k.terminals).some((v) => pin(v))); + if (o.ledChains) o.ledChains = o.ledChains.filter((c) => !pin(c.data)); + return o; +} +/** Adds or edits terminal and diode configuration for a key. */ +export function setKeyWiring(elc: ElcFile, w: KeyWiring): ElcFile { + const o = structuredClone(elc); + const i = o.keys.findIndex((x) => x.key === w.key); + if (i < 0) o.keys.push(structuredClone(w)); + else { + const old = o.keys[i]!; + const known = new Set(["key", "detect", "terminals", "diode"]); + const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key))); + const knownTerminals = old.detect === "contact" ? new Set(["a", "b"]) : new Set(["out", "vcc", "gnd"]); + const terminalExtensions = Object.fromEntries( + Object.entries(old.terminals).filter(([key]) => !knownTerminals.has(key)), + ); + const updated = { + ...extensions, + ...(old.detect === w.detect ? old : {}), + ...w, + terminals: { ...terminalExtensions, ...(old.detect === w.detect ? old.terminals : {}), ...w.terminals }, + } as KeyWiring; + if (w.detect === "contact" && w.diode === undefined) delete (updated as ContactWiring).diode; + o.keys[i] = updated; + } + return o; +} +/** Removes wiring for a key. */ +export function removeKeyWiring(elc: ElcFile, keyId: string): ElcFile { + const o = structuredClone(elc); + o.keys = o.keys.filter((x) => x.key !== keyId); + return o; +} +/** Returns keys without any ELC entry. */ +export function unwiredKeys(keyIds: string[], elc: ElcFile): string[] { + const wired = new Set(elc.keys.map((k) => k.key)); + return keyIds.filter((k) => !wired.has(k)); +} +/** Creates an LED chain with an independent ordered node list. T-11. */ +export function createLedChain(elc: ElcFile, id: string, data: PinRef): ElcFile { + const o = structuredClone(elc); + o.ledChains ??= []; + if (o.ledChains.some((c) => c.id === id)) throw new Error("Duplicate LED chain ID"); + o.ledChains.push({ id, data, order: [] }); + return o; +} +/** Replaces LED order while preserving the chain's other fields. */ +export function setLedOrder(elc: ElcFile, id: string, order: string[]): ElcFile { + const o = structuredClone(elc); + const c = o.ledChains?.find((x) => x.id === id); + if (!c) throw new Error("Unknown LED chain"); + c.order = [...order]; + return o; +} diff --git a/mock/src/domain/elcMatrix.ts b/mock/src/domain/elcMatrix.ts new file mode 100644 index 0000000..efae434 --- /dev/null +++ b/mock/src/domain/elcMatrix.ts @@ -0,0 +1,26 @@ +import { setKeyWiring, type ContactWiring, type ElcFile } from "./elc"; + +/** Assigns a row-major matrix to the supplied keys. The input order is preserved. */ +export function wireMatrixKeys( + elc: ElcFile, + keyIds: string[], + rowPins: string[], + colPins: string[], + diode?: "a-to-b" | "b-to-a", +): ElcFile { + if (!rowPins.length || !colPins.length) throw new Error("rowPins and colPins are required"); + if (keyIds.length > rowPins.length * colPins.length) + throw new Error("Matrix does not have enough row/column positions"); + let next = elc; + keyIds.forEach((key, i) => { + const wiring: ContactWiring = { + key, + detect: "contact", + terminals: { a: rowPins[Math.floor(i / colPins.length)]!, b: colPins[i % colPins.length]! }, + ...(diode ? { diode } : {}), + }; + // Keep unknown extension fields on an existing ELC entry, just like normal edits. + next = setKeyWiring(next, wiring); + }); + return next; +} diff --git a/mock/src/domain/generator.ts b/mock/src/domain/generator.ts new file mode 100644 index 0000000..24f84cb --- /dev/null +++ b/mock/src/domain/generator.ts @@ -0,0 +1,102 @@ +import type { ProjectFile } from "./project"; +import { flattenKeys } from "./geometry"; +import { validateProject, type ValidationIssue } from "./validate"; +export type MockTarget = { + format: "mock-target"; + version: "0.1.0"; + layout: Array<{ keyId: string; x: number; y: number; rotation: number; w: number; h: number }>; + idx: string[]; + matrix: { rowPins: string[]; colPins: string[]; rows: number[]; cols: number[] }; + keymaps: Array<{ name: string; keycodes: string[] }>; + mapping: Array<{ number: number; keyId: string; row: number; col: number; label: string }>; +}; +export type GeneratorResult = { target?: MockTarget; errors: ValidationIssue[] }; +/** Generates preview output after validation, without modifying the project. T-2/T-19. */ +export function generateMockTarget(p: ProjectFile): GeneratorResult { + const errors = validateProject(p).filter( + (x) => x.severity === "blocksOutput" || x.severity === "blocksSave", + ); + if (!p.output.matrix) + errors.push({ + severity: "blocksOutput", + code: "matrix-settings-missing", + message: "Matrix rowPins and colPins must be configured", + }); + if (p.output.matrix?.kind && p.output.matrix.kind !== "matrix") + errors.push({ + severity: "blocksOutput", + code: "unsupported-scan-mode", + message: `${p.output.matrix.kind} scan mode is not supported`, + }); + if (errors.length) return { errors }; + const selected = flattenKeys(p.phy, p.output.variantSelection), + active = new Set(selected.map((x) => x.key.id)), + order = p.idx.order.filter((id) => active.has(id)), + matrix = p.output.matrix!; + const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])), + colIndex = new Map(matrix.colPins.map((x, i) => [x, i])); + const wiring = new Map(p.elc.keys.map((x) => [x.key, x])); + const row: number[] = [], + col: number[] = []; + for (const id of order) { + const w = wiring.get(id); + if (!w || w.detect !== "contact") + return { + errors: [ + { + severity: "blocksOutput", + code: "unsupported-detection", + message: "Only contact matrix wiring is supported", + ids: [id], + }, + ], + }; + const a = w.terminals.a, + b = w.terminals.b; + let r: number | undefined, c: number | undefined; + if (rowIndex.has(a) && colIndex.has(b)) { + r = rowIndex.get(a); + c = colIndex.get(b); + } else if (rowIndex.has(b) && colIndex.has(a)) { + r = rowIndex.get(b); + c = colIndex.get(a); + } + if (r === undefined || c === undefined) + return { + errors: [ + { + severity: "blocksOutput", + code: "unsupported-matrix-wiring", + message: "Wiring is not a supported row/column matrix", + ids: [id], + }, + ], + }; + row.push(r); + col.push(c); + } + const poses = new Map(selected.map((x) => [x.key.id, x])); + const keymaps = p.map.layers.map((l) => ({ + name: l.name, + keycodes: order.map((id) => l.keymap.find((a) => a.keyId === id)!.keycode), + })); + const target: MockTarget = { + format: "mock-target", + version: "0.1.0", + layout: order.map((id) => { + const x = poses.get(id)!; + return { keyId: id, x: x.x, y: x.y, rotation: x.rotation, w: x.key.size.w, h: x.key.size.h }; + }), + idx: order, + matrix: { rowPins: [...matrix.rowPins], colPins: [...matrix.colPins], rows: row, cols: col }, + keymaps, + mapping: order.map((id, i) => ({ + number: i, + keyId: id, + row: row[i]!, + col: col[i]!, + label: poses.get(id)!.key.labels?.find(Boolean) ?? "", + })), + }; + return { target, errors: [] }; +} diff --git a/mock/src/domain/geometry.ts b/mock/src/domain/geometry.ts new file mode 100644 index 0000000..e94f6b3 --- /dev/null +++ b/mock/src/domain/geometry.ts @@ -0,0 +1,71 @@ +import type { FlatKey, Node, PhyFile, Transform } from "./phy"; + +/** Translate·Rotate のアフィン行列を返す。 */ +export function matrix(t: Transform): Affine { + const a = (t.rotation * Math.PI) / 180, + c = Math.cos(a), + s = Math.sin(a); + return { a: c, b: s, c: -s, d: c, e: t.x, f: t.y }; +} +export type Affine = { a: number; b: number; c: number; d: number; e: number; f: number }; +/** 左から右の順でなく、通常の行列積 left·right を返す。 */ +export function compose(left: Affine, right: Affine): Affine { + return { + a: left.a * right.a + left.c * right.b, + b: left.b * right.a + left.d * right.b, + c: left.a * right.c + left.c * right.d, + d: left.b * right.c + left.d * right.d, + e: left.a * right.e + left.c * right.f + left.e, + f: left.b * right.e + left.d * right.f + left.f, + }; +} +/** 剛体変換の逆行列を返す。 */ +export function inverse(m: Affine): Affine { + return { a: m.a, b: m.c, c: m.b, d: m.d, e: -(m.a * m.e + m.b * m.f), f: -(m.c * m.e + m.d * m.f) }; +} +/** 点に行列を適用する。 */ +export function apply(m: Affine, p: { x: number; y: number }): { x: number; y: number } { + return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f }; +} +/** PHY ノードのワールド行列を計算する。 */ +export function worldTransform(file: PhyFile, id: string): Affine { + const walk = (nodes: Node[], parent: Affine): Affine | undefined => { + for (const n of nodes) { + const own = compose(parent, matrix(n.transform)); + if (n.id === id) return own; + if (n.type === "cluster") { + const r = walk(n.children, own); + if (r) return r; + } + if (n.type === "variantGroup") + for (const v of n.children) { + const r = walk(v.children, own); + if (r) return r; + } + } + return undefined; + }; + const found = walk(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + if (!found) throw new Error(`Unknown node: ${id}`); + return found; +} +/** 選択したVariantに従いキーを平坦化する。省略時は各Groupのdefaultを使う。 */ +export function flattenKeys(file: PhyFile, selection: Record = {}): FlatKey[] { + const out: FlatKey[] = []; + const visit = (nodes: Node[], parent: Affine, parentRotation: number, path: string[]) => { + for (const n of nodes) { + const wm = compose(parent, matrix(n.transform)), + p = [...path, n.id], + absoluteRotation = parentRotation + n.transform.rotation; + if (n.type === "key") out.push({ key: n, x: wm.e, y: wm.f, rotation: absoluteRotation, path: p }); + else if (n.type === "cluster") visit(n.children, wm, absoluteRotation, p); + else if (n.type === "variantGroup") { + const v = n.children.find((x) => x.id === (selection[n.id] ?? n.default)); + if (!v) throw new Error(`Unknown variant for ${n.id}`); + visit(v.children, wm, absoluteRotation, [...p, v.id]); + } + } + }; + visit(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, 0, []); + return out; +} diff --git a/mock/src/domain/history.ts b/mock/src/domain/history.ts new file mode 100644 index 0000000..f50071d --- /dev/null +++ b/mock/src/domain/history.ts @@ -0,0 +1,76 @@ +import type { ProjectFile } from "./project"; +/** Snapshot history for whole-project undo/redo. T-17. */ +export class ProjectHistory { + private items: ProjectFile[]; + private cursor = 0; + private pending = false; + constructor( + initial: ProjectFile, + private readonly limit = 100, + ) { + this.items = [structuredClone(initial)]; + } + /** Adds one undoable snapshot unless a grouped edit is active. */ + push(value: ProjectFile): void { + if (this.pending) { + this.replaceTop(value); + return; + } + this.add(value); + } + /** Starts grouping subsequent pushes into a single operation. T-17. */ + begin(): void { + if (this.pending) throw new Error("History group already active"); + this.items = this.items.slice(0, this.cursor + 1); + this.items.push(structuredClone(this.items[this.cursor]!)); + this.cursor = this.items.length - 1; + if (this.items.length > this.limit) { + this.items.shift(); + this.cursor--; + } + this.pending = true; + } + /** Commits the final grouped snapshot as one undo step. T-17. */ + commit(value?: ProjectFile): void { + if (!this.pending) throw new Error("No history group active"); + if (value) this.items[this.cursor] = structuredClone(value); + if (JSON.stringify(this.items[this.cursor]) === JSON.stringify(this.items[this.cursor - 1])) { + this.items.pop(); + this.cursor--; + } + this.pending = false; + } + /** Replaces the current snapshot during a grouped drag. T-17. */ + replaceTop(value: ProjectFile): void { + this.items[this.cursor] = structuredClone(value); + } + /** Moves to the previous snapshot. */ + undo(): ProjectFile { + if (!this.canUndo) throw new Error("Nothing to undo"); + return structuredClone(this.items[--this.cursor]!); + } + /** Moves to the next snapshot. */ + redo(): ProjectFile { + if (!this.canRedo) throw new Error("Nothing to redo"); + return structuredClone(this.items[++this.cursor]!); + } + get canUndo(): boolean { + return this.cursor > 0; + } + get canRedo(): boolean { + return this.cursor < this.items.length - 1; + } + /** Returns a copy of the current snapshot. */ + current(): ProjectFile { + return structuredClone(this.items[this.cursor]!); + } + private add(value: ProjectFile) { + this.items = this.items.slice(0, this.cursor + 1); + this.items.push(structuredClone(value)); + this.cursor = this.items.length - 1; + if (this.items.length > this.limit) { + this.items.shift(); + this.cursor--; + } + } +} diff --git a/mock/src/domain/ids.ts b/mock/src/domain/ids.ts new file mode 100644 index 0000000..68cddc4 --- /dev/null +++ b/mock/src/domain/ids.ts @@ -0,0 +1,14 @@ +/** T-11: UUID先頭12文字を種別接頭辞に付ける。生成器を注入できる。 */ +export function createId(prefix: string, uuid: () => string = () => globalThis.crypto.randomUUID()): string { + return `${prefix}${uuid().replaceAll("-", "").slice(0, 12)}`; +} +/** T-11: ノード種別ごとのIDを生成する。 */ +export function createNodeId( + kind: "key" | "cluster" | "component" | "variantGroup" | "variant", + uuid?: () => string, +): string { + return createId( + { key: "key-", cluster: "cl-", component: "cmp-", variantGroup: "vg-", variant: "v-" }[kind], + uuid, + ); +} diff --git a/mock/src/domain/idx.ts b/mock/src/domain/idx.ts new file mode 100644 index 0000000..21eba00 --- /dev/null +++ b/mock/src/domain/idx.ts @@ -0,0 +1,57 @@ +import type { PhyFile, Node, KeyNode } from "./phy"; +export type PhyReference = { source: string; checksum: string; [key: string]: unknown }; +export type IdxFile = { + version: string; + phyReference: PhyReference; + order: string[]; + [key: string]: unknown; +}; +/** Collects keys in document order, including ghost keys and excluding decals. T-18. */ +export function eligibleKeyIds(phy: PhyFile): string[] { + const ids: string[] = []; + const visit = (nodes: Node[]) => + nodes.forEach((n) => { + if (n.type === "key") { + if (!n.decal) ids.push(n.id); + } else if (n.type === "cluster") visit(n.children); + else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children)); + }); + visit(phy.root); + return ids; +} +/** Makes initial index order from KLE occurrence order. T-18. */ +export function createIdx(phy: PhyFile, keyOrder: string[], phyReference: PhyReference): IdxFile { + const valid = new Set(eligibleKeyIds(phy)); + return { + version: "0.1.0", + phyReference: structuredClone(phyReference), + order: keyOrder.filter((id) => valid.has(id)), + }; +} +/** Lists eligible PHY keys omitted from the saved order. T-18. */ +export function unregisteredKeys(phy: PhyFile, idx: IdxFile): string[] { + const registered = new Set(idx.order); + return eligibleKeyIds(phy).filter((id) => !registered.has(id)); +} +/** Registers new keys at the end without rebuilding existing order. T-18. */ +export function appendKeys(idx: IdxFile, ids: string[]): IdxFile { + const out = structuredClone(idx); + for (const id of ids) if (!out.order.includes(id)) out.order.push(id); + return out; +} +/** Removes one key from index order. */ +export function excludeKey(idx: IdxFile, keyId: string): IdxFile { + const o = structuredClone(idx); + o.order = o.order.filter((x) => x !== keyId); + return o; +} +/** Reorders the saved key list. */ +export function reorderKeys(idx: IdxFile, order: string[]): IdxFile { + if ( + order.length !== idx.order.length || + new Set(order).size !== order.length || + order.some((x) => !idx.order.includes(x)) + ) + throw new Error("Order must contain every registered key exactly once"); + return { ...structuredClone(idx), order: [...order] }; +} diff --git a/mock/src/domain/keycodeLabels.ts b/mock/src/domain/keycodeLabels.ts new file mode 100644 index 0000000..72d16bf --- /dev/null +++ b/mock/src/domain/keycodeLabels.ts @@ -0,0 +1,76 @@ +import { qmkBasicKeycodes } from "./qmkBasicKeycodes"; + +/** Small US keyboard display map for common QMK basic keys. T-23. */ +export const keycodeLabels: Record = { + KC_A: "A", + KC_B: "B", + KC_C: "C", + KC_D: "D", + KC_E: "E", + KC_F: "F", + KC_G: "G", + KC_H: "H", + KC_I: "I", + KC_J: "J", + KC_K: "K", + KC_L: "L", + KC_M: "M", + KC_N: "N", + KC_O: "O", + KC_P: "P", + KC_Q: "Q", + KC_R: "R", + KC_S: "S", + KC_T: "T", + KC_U: "U", + KC_V: "V", + KC_W: "W", + KC_X: "X", + KC_Y: "Y", + KC_Z: "Z", + KC_1: "1", + KC_2: "2", + KC_3: "3", + KC_4: "4", + KC_5: "5", + KC_6: "6", + KC_7: "7", + KC_8: "8", + KC_9: "9", + KC_0: "0", + KC_SPC: "Space", + KC_ENTER: "Enter", + KC_ESC: "Escape", + KC_TAB: "Tab", + KC_MINUS: "-", + KC_EQUAL: "=", + KC_LBRC: "[", + KC_RBRC: "]", + KC_BSLS: "\\", + KC_SCLN: ";", + KC_QUOT: "'", + KC_GRV: "`", + KC_COMM: ",", + KC_DOT: ".", + KC_SLSH: "/", +}; + +const qmkCodeByName = new Map( + qmkBasicKeycodes.flatMap((entry) => + [entry.key, ...entry.aliases].map((name) => [name, entry.key] as const), + ), +); + +/** Looks up the existing US display label, resolving a QMK alias to its formal name. */ +export function getKeycodeLabel(code: string): string | undefined { + const formalName = qmkCodeByName.get(code); + if (!formalName) return keycodeLabels[code]; + return ( + keycodeLabels[code] ?? + keycodeLabels[formalName] ?? + qmkBasicKeycodes + .find((entry) => entry.key === formalName) + ?.aliases.map((alias) => keycodeLabels[alias]) + .find((label): label is string => label !== undefined) + ); +} diff --git a/mock/src/domain/map.ts b/mock/src/domain/map.ts new file mode 100644 index 0000000..39273c7 --- /dev/null +++ b/mock/src/domain/map.ts @@ -0,0 +1,149 @@ +import type { PhyReference } from "./idx"; +import { qmkBasicKeycodes } from "./qmkBasicKeycodes"; +export type KeyAssignment = { keyId: string; keycode: string; [key: string]: unknown }; +export type MapLayer = { name: string; keymap: KeyAssignment[]; [key: string]: unknown }; +export type MapFile = { + version: string; + phyReference: PhyReference; + layers: MapLayer[]; + [key: string]: unknown; +}; +export type ParsedKeycode = { + kind: "basic" | "modifier" | "none" | "transparent" | "layer" | "modified" | "unknown"; + code: string; + layer?: string; + inner?: ParsedKeycode; +}; +const codesFor = (groups: QmkKeyGroup[]) => + new Set( + qmkBasicKeycodes + .filter((keycode) => groups.includes(keycode.group)) + .flatMap(({ key, aliases }) => [key, ...aliases]), + ); +type QmkKeyGroup = (typeof qmkBasicKeycodes)[number]["group"]; +const codesForKey = (key: string) => { + const entry = qmkBasicKeycodes.find((keycode) => keycode.key === key); + return new Set(entry ? [entry.key, ...entry.aliases] : []); +}; +const basic = codesFor(["basic"]); +const modifiers = codesFor(["modifiers"]); +const noneCodes = codesForKey("KC_NO"); +const transparentCodes = codesForKey("KC_TRANSPARENT"); +/** Parses supported QMK keycode syntax without evaluation. T-22. */ +export function parseKeycode(s: string): ParsedKeycode { + const m = s.match(/^(MO|TG|TO|TT|DF|OSL)\(([^()]+)\)$/); + if (m) return { kind: "layer", code: m[1]!, layer: m[2]! }; + const lt = s.match(/^LT\(([^(),]+),\s*([A-Za-z0-9_]+)\)$/); + if (lt) return { kind: "layer", code: "LT", layer: lt[1]!, inner: parseKeycode(lt[2]!) }; + const mod = s.match(/^(LCTL|LSFT|LALT|LGUI|RCTL|RSFT|RALT|RGUI)\(([A-Za-z0-9_]+)\)$/); + if (mod) return { kind: "modified", code: mod[1]!, inner: parseKeycode(mod[2]!) }; + if (noneCodes.has(s)) return { kind: "none", code: s }; + if (transparentCodes.has(s)) return { kind: "transparent", code: s }; + if (basic.has(s)) return { kind: "basic", code: s }; + if (modifiers.has(s)) return { kind: "modifier", code: s }; + return { kind: "unknown", code: s }; +} + +function simpleEditableKeycode(parsed: ParsedKeycode): boolean { + return ( + parsed.kind === "basic" || + parsed.kind === "modifier" || + parsed.kind === "none" || + parsed.kind === "transparent" + ); +} + +/** Returns whether a keycode uses syntax editable under T-22. */ +export function isEditableKeycode(keycode: string): boolean { + const parsed = parseKeycode(keycode); + if (parsed.kind === "unknown") return false; + if (parsed.kind === "modified") return !!parsed.inner && simpleEditableKeycode(parsed.inner); + if (parsed.kind === "layer") + return parsed.code !== "LT" || (!!parsed.inner && simpleEditableKeycode(parsed.inner)); + return true; +} +/** Creates a MAP with one empty Base layer. T-22. */ +export function createMap(ref: PhyReference): MapFile { + return { version: "0.1.0", phyReference: structuredClone(ref), layers: [{ name: "Base", keymap: [] }] }; +} +/** Adds a uniquely named layer. T-22. */ +export function addLayer(map: MapFile, name: string): MapFile { + if (map.layers.some((l) => l.name === name)) throw new Error("Layer name already exists"); + const o = structuredClone(map); + o.layers.push({ name, keymap: [] }); + return o; +} +/** Renames a layer and rewrites encoded layer references. T-22. */ +export function renameLayer(map: MapFile, oldName: string, newName: string): MapFile { + if (map.layers.some((l) => l.name === newName)) throw new Error("Layer name already exists"); + const o = structuredClone(map); + const l = o.layers.find((x) => x.name === oldName); + if (!l) throw new Error("Unknown layer"); + l.name = newName; + for (const x of o.layers) + for (const a of x.keymap) a.keycode = rewriteLayerRef(a.keycode, oldName, newName); + return o; +} +function rewriteLayerRef(s: string, oldName: string, newName: string): string { + const p = parseKeycode(s); + if (p.kind === "layer" && p.layer === oldName) { + if (p.code === "LT") return `LT(${newName}, ${p.inner?.code ?? ""})`; + return `${p.code}(${newName})`; + } + return s; +} +/** Moves a layer to a new array position. */ +export function reorderLayer(map: MapFile, name: string, index: number): MapFile { + const o = structuredClone(map), + i = o.layers.findIndex((x) => x.name === name); + if (i < 0) throw new Error("Unknown layer"); + const [l] = o.layers.splice(i, 1); + o.layers.splice(Math.max(0, Math.min(index, o.layers.length)), 0, l!); + return o; +} +/** Deletes an unreferenced layer; references block deletion. T-22. */ +export function deleteLayer(map: MapFile, name: string): MapFile { + if (map.layers.length < 2) throw new Error("At least one layer is required"); + if ( + map.layers.some((l) => + l.keymap.some((a) => { + const p = parseKeycode(a.keycode); + return p.kind === "layer" && p.layer === name; + }), + ) + ) + throw new Error("Layer is referenced"); + const o = structuredClone(map); + o.layers = o.layers.filter((l) => l.name !== name); + return o; +} +/** Assigns a keycode to a key, replacing an existing assignment. T-22. */ +export function assignKey(map: MapFile, layerName: string, keyId: string, keycode: string): MapFile { + if (!isEditableKeycode(keycode)) throw new Error(`Unsupported editable keycode: ${keycode}`); + const parsed = parseKeycode(keycode); + if (parsed.kind === "layer" && !map.layers.some((layer) => layer.name === parsed.layer)) + throw new Error(`Unknown MAP layer reference: ${parsed.layer}`); + const o = structuredClone(map), + l = o.layers.find((x) => x.name === layerName); + if (!l) throw new Error("Unknown layer"); + const i = l.keymap.findIndex((a) => a.keyId === keyId), + old = i < 0 ? undefined : l.keymap[i], + a = { ...old, keyId, keycode }; + if (i < 0) l.keymap.push(a); + else l.keymap[i] = a; + return o; +} +/** Removes a key assignment. */ +export function unassignKey(map: MapFile, layerName: string, keyId: string): MapFile { + const o = structuredClone(map), + l = o.layers.find((x) => x.name === layerName); + if (!l) throw new Error("Unknown layer"); + l.keymap = l.keymap.filter((a) => a.keyId !== keyId); + return o; +} +/** Lists keys not assigned in a layer. T-22. */ +export function unassignedKeys(map: MapFile, layerName: string, keyIds: string[]): string[] { + const l = map.layers.find((x) => x.name === layerName); + const assigned = new Set(l?.keymap.map((x) => x.keyId)); + return keyIds.filter((x) => !assigned?.has(x)); +} diff --git a/mock/src/domain/phy.ts b/mock/src/domain/phy.ts new file mode 100644 index 0000000..55a5bcd --- /dev/null +++ b/mock/src/domain/phy.ts @@ -0,0 +1,53 @@ +/** PHY JSON Schema §1 の型。D-1: Schema の必須 type/name/compatibility を採用。 */ +export type Point = { x: number; y: number }; +export type Transform = { x: number; y: number; rotation: number }; +export type Size = { w: number; h: number; x2?: number; y2?: number; w2?: number; h2?: number }; +export type Profile = { + id: string; + name: string; + unitSize: Point; + visualOffset?: { top: number; right: number; bottom: number; left: number; radius: number }; +}; +export type KleCompatibility = Record; +export type KeyNode = { + type: "key"; + id: string; + profile: string; + transform: Transform; + size: Size; + switchOffset?: Point; + partGroup?: string; + orientation?: 0 | 90 | 180 | 270; + perKeyLED?: boolean; + decal?: boolean; + ghost?: boolean; + labels?: string[]; + kleCompatibility?: KleCompatibility; +}; +export type ComponentNode = { type: "component"; id: string; transform: Transform; partGroup?: string }; +export type ClusterNode = { + type: "cluster"; + id: string; + name?: string; + transform: Transform; + children: Node[]; +}; +export type Variant = { type: "variant"; id: string; name: string; children: Node[] }; +export type VariantGroupNode = { + type: "variantGroup"; + id: string; + name: string; + transform: Transform; + default: string; + children: Variant[]; +}; +export type Node = KeyNode | ComponentNode | ClusterNode | VariantGroupNode; +export type PhyFile = { + version: string; + metadata: { name?: string; author?: string; description?: string; created?: string; modified?: string }; + profiles: Profile[]; + root: Node[]; + kleCompatibility?: KleCompatibility; +}; + +export type FlatKey = { key: KeyNode; x: number; y: number; rotation: number; path: string[] }; diff --git a/mock/src/domain/phyCommands.ts b/mock/src/domain/phyCommands.ts new file mode 100644 index 0000000..8d1354c --- /dev/null +++ b/mock/src/domain/phyCommands.ts @@ -0,0 +1,426 @@ +import type { ClusterNode, KeyNode, Node, PhyFile, Transform, Variant, VariantGroupNode } from "./phy"; +import { apply, compose, inverse, matrix } from "./geometry"; +import { createNodeId } from "./ids"; + +const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; +type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number }; +function containers(f: PhyFile): Map { + const m = new Map(); + const rec = (ns: Node[], w: typeof ident, angle: number) => { + for (const n of ns) { + m.set(n.id, { nodes: ns, parentWorld: w, parentRotation: angle }); + const nw = compose(w, matrix(n.transform)); + const nextAngle = angle + n.transform.rotation; + if (n.type === "cluster") rec(n.children, nw, nextAngle); + if (n.type === "variantGroup") + for (const v of n.children) { + m.set(v.id, { nodes: v.children, parentWorld: nw, parentRotation: nextAngle }); + rec(v.children, nw, nextAngle); + } + } + }; + rec(f.root, ident, 0); + return m; +} +function find(f: PhyFile, id: string): Node { + const c = containers(f).get(id); + const n = c?.nodes.find((x) => x.id === id); + if (!n) throw new Error(`Unknown node: ${id}`); + return n; +} +function world(f: PhyFile, id: string): typeof ident { + const c = containers(f).get(id); + const n = c?.nodes.find((x) => x.id === id); + if (!n) throw new Error(`Unknown node: ${id}`); + return compose(c!.parentWorld, matrix(n.transform)); +} +function worldRotation(f: PhyFile, id: string): number { + const c = containers(f).get(id); + const n = c?.nodes.find((x) => x.id === id); + if (!n || !c) throw new Error(`Unknown node: ${id}`); + return c.parentRotation + n.transform.rotation; +} +function mutable(f: PhyFile): PhyFile { + return structuredClone(f); +} +function nodeId(kind: Parameters[0], gen?: () => string) { + return createNodeId(kind, gen); +} +/** 新しいPHYを返す。T-11。 */ +export function addKey( + f: PhyFile, + at: Transform, + size: { w: number; h: number }, + profile = f.profiles[0]!.id, + gen?: () => string, +): PhyFile { + const o = mutable(f); + o.root.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } }); + return o; +} +/** 新しいPHYを返す。T-11。 */ +export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?: () => string): PhyFile { + const o = mutable(f); + o.root.push({ + type: "component", + id: nodeId("component", gen), + transform: { ...at }, + ...(partGroup === undefined ? {} : { partGroup }), + }); + return o; +} +/** ローカル座標の差分でノードを移動する。 */ +export function moveNode(f: PhyFile, id: string, dx: number, dy: number): PhyFile { + const o = mutable(f), + n = find(o, id); + n.transform.x += dx; + n.transform.y += dy; + return o; +} +/** ノードを回転する。 */ +export function rotateNode(f: PhyFile, id: string, delta: number): PhyFile { + const o = mutable(f), + n = find(o, id); + n.transform.rotation += delta; + return o; +} +/** キーのサイズを変更する。 */ +export function resizeKey(f: PhyFile, id: string, size: KeyNode["size"]): PhyFile { + const o = mutable(f), + n = find(o, id); + if (n.type !== "key") throw new Error(`${id} is not a key`); + n.size = { ...size }; + return o; +} +/** 同じ親のノードを新クラスターにまとめ、絶対変換を保つ。T-11。 */ +export function clusterNodes(f: PhyFile, ids: string[], name = "Cluster", gen?: () => string): PhyFile { + if (ids.length < 1) throw new Error("Select at least one node"); + const o = mutable(f), + cs = containers(o), + first = cs.get(ids[0]!); + if (!first || ids.some((id) => cs.get(id)?.nodes !== first.nodes)) + throw new Error("Selected nodes must be siblings"); + const selected = ids.map((id) => first.nodes.find((n) => n.id === id)!); + if (selected.some((n) => !n)) throw new Error("Unknown selection"); + const angles = selected.map((n) => world(o, n.id)); + const selectedAngles = selected.map((n) => first.parentRotation + n.transform.rotation); + const angle = selectedAngles[0]!; + const same = selectedAngles.every((candidate) => Math.abs(candidate - angle) < 1e-9); + const basis = matrix({ x: 0, y: 0, rotation: same ? angle : 0 }), + basisInverse = inverse(basis); + const shapePoints = selected.flatMap((n, i) => boundsPoints(n, angles[i]!)); + if (shapePoints.length === 0) angles.forEach((m) => shapePoints.push({ x: m.e, y: m.f })); + const localOrigins = shapePoints.map((p) => apply(basisInverse, p)); + const min = { x: Math.min(...localOrigins.map((p) => p.x)), y: Math.min(...localOrigins.map((p) => p.y)) }; + const origin = apply(basis, min); + const wt = matrix({ x: origin.x, y: origin.y, rotation: same ? angle : 0 }); + const local = compose(inverse(first.parentWorld), wt); + const id = nodeId("cluster", gen), + cluster: ClusterNode = { + type: "cluster", + id, + name, + transform: { x: local.e, y: local.f, rotation: (same ? angle : 0) - first.parentRotation }, + children: [], + }; + const inv = inverse(wt); + cluster.children = selected.map((n) => { + const w = world(o, n.id), + rel = compose(inv, w); + return { + ...n, + transform: { x: rel.e, y: rel.f, rotation: selectedAngles[selected.indexOf(n)]! - (same ? angle : 0) }, + } as Node; + }); + const indices = selected.map((n) => first.nodes.indexOf(n)).sort((a, b) => a - b); + const insert = indices[0]!; + first.nodes.splice(0, first.nodes.length, ...first.nodes.filter((n) => !ids.includes(n.id))); + first.nodes.splice(insert, 0, cluster); + return o; +} +/** clusterの子を同じ親へ戻し、絶対変換を保つ。 */ +export function uncluster(f: PhyFile, id: string): PhyFile { + const o = mutable(f), + cs = containers(o), + c = cs.get(id), + cluster = c?.nodes.find((n) => n.id === id); + if (!cluster || cluster.type !== "cluster") throw new Error(`${id} is not a cluster`); + const clusterWorldRotation = c!.parentRotation + cluster.transform.rotation; + const cw = compose(c!.parentWorld, matrix(cluster.transform)), + converted = cluster.children.map((n) => { + const w = compose(cw, matrix(n.transform)), + rel = compose(inverse(c!.parentWorld), w); + return { + ...n, + transform: { + x: rel.e, + y: rel.f, + rotation: clusterWorldRotation + n.transform.rotation - c!.parentRotation, + }, + } as Node; + }); + const index = c!.nodes.indexOf(cluster); + c!.nodes.splice(index, 1, ...converted); + return o; +} +/** ノードを任意の親/rootへ移し、絶対変換を保つ。T-27。 */ +export function reparent(f: PhyFile, id: string, newParentId: string | null, index?: number): PhyFile { + const o = mutable(f), + cs = containers(o), + src = cs.get(id), + n = src?.nodes.find((x) => x.id === id); + if (!n) throw new Error(`Unknown node: ${id}`); + const oldW = world(o, id), + oldRotation = worldRotation(o, id); + let target: Node[] = o.root, + parentW = ident, + parentRotation = 0; + if (newParentId) { + const p = find(o, newParentId); + if (p.type !== "cluster") + throw new Error("Target parent must be a cluster; use reparentToVariant for variant children"); + if (containsNode(n, newParentId)) throw new Error("Cannot reparent a node into its descendant"); + target = p.children; + parentW = world(o, p.id); + parentRotation = worldRotation(o, p.id); + } + const oldIndex = src!.nodes.indexOf(n), + sameParent = target === src!.nodes; + src!.nodes.splice(oldIndex, 1); + const rel = compose(inverse(parentW), oldW); + n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation }; + let at = index ?? target.length; + if (sameParent && index !== undefined && oldIndex < index) at--; + target.splice(Math.max(0, Math.min(at, target.length)), 0, n); + return o; +} +/** variant内へ移動するreparent。T-27。 */ +export function reparentToVariant(f: PhyFile, id: string, variantId: string, index?: number): PhyFile { + const o = mutable(f), + cs = containers(o), + src = cs.get(id), + n = src?.nodes.find((x) => x.id === id); + const v = findVariant(o, variantId); + if (!n || !src) throw new Error(`Unknown node: ${id}`); + if (containsNode(n, variantId)) throw new Error("Cannot reparent a node into its descendant"); + const old = world(o, id), + oldRotation = worldRotation(o, id), + parent = srcVariantWorld(o, variantId), + rel = compose(inverse(parent), old); + const parentRotation = srcVariantRotation(o, variantId); + src.nodes.splice(src.nodes.indexOf(n), 1); + n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation }; + v.children.splice(index ?? v.children.length, 0, n); + return o; +} +function containsNode(n: Node, id: string): boolean { + if (n.id === id) return true; + if (n.type === "cluster") return n.children.some((c) => containsNode(c, id)); + if (n.type === "variantGroup") + return n.children.some((v) => v.id === id || v.children.some((c) => containsNode(c, id))); + return false; +} +function boundsPoints(n: Node, worldMatrix: ReturnType): { x: number; y: number }[] { + if (n.type === "key") { + const points = [ + { x: 0, y: 0 }, + { x: n.size.w, y: 0 }, + { x: n.size.w, y: n.size.h }, + { x: 0, y: n.size.h }, + ]; + if ( + n.size.w2 !== undefined || + n.size.h2 !== undefined || + n.size.x2 !== undefined || + n.size.y2 !== undefined + ) { + const x = n.size.x2 ?? 0; + const y = n.size.y2 ?? 0; + const w = n.size.w2 ?? n.size.w; + const h = n.size.h2 ?? n.size.h; + points.push({ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }); + } + return points.map((point) => apply(worldMatrix, point)); + } + if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }]; + if (n.type === "cluster") + return n.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform)))); + const selected = n.children.find((variant) => variant.id === n.default); + return selected + ? selected.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform)))) + : [{ x: worldMatrix.e, y: worldMatrix.f }]; +} +function findVariant(f: PhyFile, id: string): Variant { + for (const n of f.root) { + const r = walk(n, id); + if (r) return r; + } + throw new Error(`Unknown variant: ${id}`); +} +function walk(n: Node, id: string): Variant | undefined { + if (n.type === "variantGroup") { + for (const v of n.children) { + if (v.id === id) return v; + for (const c of v.children) { + const r = walk(c, id); + if (r) return r; + } + } + } + if (n.type === "cluster") + for (const c of n.children) { + const r = walk(c, id); + if (r) return r; + } + return undefined; +} +function srcVariantWorld(f: PhyFile, id: string): typeof ident { + const rec = (ns: Node[], w: typeof ident): typeof ident | undefined => { + for (const n of ns) { + const nw = compose(w, matrix(n.transform)); + if (n.type === "variantGroup") + for (const v of n.children) { + if (v.id === id) return nw; + const r = rec(v.children, nw); + if (r) return r; + } + if (n.type === "cluster") { + const r = rec(n.children, nw); + if (r) return r; + } + } + return undefined; + }; + const x = rec(f.root, ident); + if (!x) throw new Error(`Unknown variant ${id}`); + return x; +} +function srcVariantRotation(f: PhyFile, id: string): number { + const rec = (nodes: Node[], parentRotation: number): number | undefined => { + for (const n of nodes) { + const angle = parentRotation + n.transform.rotation; + if (n.type === "variantGroup") + for (const v of n.children) { + if (v.id === id) return angle; + const found = rec(v.children, angle); + if (found !== undefined) return found; + } + if (n.type === "cluster") { + const found = rec(n.children, angle); + if (found !== undefined) return found; + } + } + return undefined; + }; + const found = rec(f.root, 0); + if (found === undefined) throw new Error(`Unknown variant ${id}`); + return found; +} +/** クラスターの座標枠を変え、配下の絶対位置を保つ。PHY-P-13仮決定。 */ +export function reframe(f: PhyFile, id: string, frame: Transform): PhyFile { + const o = mutable(f), + n = find(o, id); + if (n.type !== "cluster") throw new Error(`${id} is not a cluster`); + const c = containers(o).get(id)!, + oldRotation = c.parentRotation + n.transform.rotation, + old = compose(c.parentWorld, matrix(n.transform)), + next = compose(c.parentWorld, matrix(frame)), + correction = compose(inverse(next), old); + n.children = n.children.map((ch) => { + const t = compose(correction, matrix(ch.transform)); + return { + ...ch, + transform: { + x: t.e, + y: t.f, + rotation: oldRotation + ch.transform.rotation - (c.parentRotation + frame.rotation), + }, + } as Node; + }); + n.transform = { ...frame }; + return o; +} +/** VariantGroupを作成する。T-11。 */ +export function createVariantGroup( + f: PhyFile, + name: string, + at: Transform, + variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }], + gen?: () => string, +): PhyFile { + const o = mutable(f), + children = variants.map((x) => ({ + type: "variant" as const, + id: nodeId("variant", gen), + name: x.name, + children: structuredClone(x.children ?? []), + })); + o.root.push({ + type: "variantGroup", + id: nodeId("variantGroup", gen), + name, + transform: { ...at }, + default: children[0]!.id, + children, + }); + return o; +} +/** Variantを追加する。T-11。 */ +export function addVariant(f: PhyFile, groupId: string, name: string, gen?: () => string): PhyFile { + const o = mutable(f), + g = find(o, groupId); + if (g.type !== "variantGroup") throw new Error("Not a variant group"); + g.children.push({ type: "variant", id: nodeId("variant", gen), name, children: [] }); + return o; +} +/** Variantを削除する。子が1つになる場合は拒否し、default削除時は先頭へ移す。T-15。 */ +export function deleteVariant(f: PhyFile, groupId: string, variantId: string): PhyFile { + const o = mutable(f), + g = find(o, groupId); + if (g.type !== "variantGroup") throw new Error("Not a variant group"); + if (g.children.length <= 2) throw new Error("Cannot leave a VariantGroup with fewer than two variants"); + const i = g.children.findIndex((v) => v.id === variantId); + if (i < 0) throw new Error("Unknown variant"); + g.children.splice(i, 1); + if (g.default === variantId) g.default = g.children[0]!.id; + return o; +} +/** default Variantを変更する。 */ +export function setDefaultVariant(f: PhyFile, groupId: string, variantId: string): PhyFile { + const o = mutable(f), + g = find(o, groupId); + if (g.type !== "variantGroup" || !g.children.some((v) => v.id === variantId)) + throw new Error("Invalid group or variant"); + g.default = variantId; + return o; +} +/** ノードを削除する。 */ +export function deleteNode(f: PhyFile, id: string): PhyFile { + const o = mutable(f), + c = containers(o).get(id); + if (!c) throw new Error(`Unknown node: ${id}`); + const i = c.nodes.findIndex((n) => n.id === id); + c.nodes.splice(i, 1); + return o; +} +/** ノードと配下を複製し、すべてのIDを新しくする。T-11。 */ +export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile { + const o = mutable(f), + n = find(o, id), + copy = structuredClone(n); + const renew = (x: Node) => { + x.id = nodeId(x.type, gen); + if (x.type === "cluster") x.children.forEach(renew); + if (x.type === "variantGroup") + for (const v of x.children) { + v.id = nodeId("variant", gen); + v.children.forEach(renew); + } + }; + renew(copy); + copy.transform.x += 0.25; + copy.transform.y += 0.25; + const c = containers(o).get(id)!; + c.nodes.splice(c.nodes.indexOf(n) + 1, 0, copy); + return o; +} diff --git a/mock/src/domain/phyReference.ts b/mock/src/domain/phyReference.ts new file mode 100644 index 0000000..d350867 --- /dev/null +++ b/mock/src/domain/phyReference.ts @@ -0,0 +1,11 @@ +import type { PhyFile } from "./phy"; +import type { PhyReference } from "./idx"; +import { sha256Jcs } from "../io/json"; +/** Computes the project-local PHY reference checksum. T-12. */ +export async function makePhyReference(phy: PhyFile, source = "project:phy"): Promise { + return { source, checksum: await sha256Jcs(phy) }; +} +/** Checks whether a stored checksum matches PHY. T-12. */ +export async function phyReferenceMatches(phy: PhyFile, ref: PhyReference): Promise { + return ref.checksum === (await sha256Jcs(phy)); +} diff --git a/mock/src/domain/project.ts b/mock/src/domain/project.ts new file mode 100644 index 0000000..d55d5e3 --- /dev/null +++ b/mock/src/domain/project.ts @@ -0,0 +1,64 @@ +import type { PhyFile, Node } from "./phy"; +import { createId } from "./ids"; +import { importKle } from "../io/kleImport"; +import { sha256Jcs } from "../io/json"; +import { createIdx, type IdxFile } from "./idx"; +import { createElc, type ElcFile } from "./elc"; +import { createMap, type MapFile } from "./map"; +export type MatrixOutputSettings = { + rowPins: string[]; + colPins: string[]; + kind?: "matrix" | "charlieplexing" | "direct"; + [key: string]: unknown; +}; +export type ProjectFile = { + format: "beyond-kle-mock-project"; + version: string; + projectId: string; + phy: PhyFile; + elc: ElcFile; + idx: IdxFile; + map: MapFile; + output: { variantSelection: Record; matrix?: MatrixOutputSettings; [key: string]: unknown }; + ui?: unknown; + [key: string]: unknown; +}; +/** Creates default variant selections from PHY. T-10/T-20. */ +export function defaultVariantSelection(phy: PhyFile): Record { + const out: Record = {}; + const walk = (nodes: Node[]) => + nodes.forEach((n) => { + if (n.type === "cluster") walk(n.children); + if (n.type === "variantGroup") { + out[n.id] = n.default; + const v = n.children.find((x) => x.id === n.default); + if (v) walk(v.children); + } + }); + walk(phy.root); + return out; +} +/** Creates an empty project with default reference values. T-10/T-13. */ +export async function createProject(phy: PhyFile, gen?: () => string): Promise { + const ref = { source: "project:phy", checksum: await sha256Jcs(phy) }; + return { + format: "beyond-kle-mock-project", + version: "0.1.0", + projectId: createId("project-", gen), + phy: structuredClone(phy), + elc: createElc(ref), + idx: createIdx(phy, [], ref), + map: createMap(ref), + output: { variantSelection: defaultVariantSelection(phy) }, + }; +} +/** Imports KLE as a new project and initializes IDX from occurrence order. T-18. */ +export async function createProjectFromKle( + input: unknown, + options: { idGenerator?: () => string; name?: string; projectIdGenerator?: () => string } = {}, +): Promise { + const { phy, keyOrder } = importKle(input, options); + const p = await createProject(phy, options.projectIdGenerator); + p.idx = createIdx(phy, keyOrder, p.idx.phyReference); + return p; +} diff --git a/mock/src/domain/qmkBasicKeycodes.ts b/mock/src/domain/qmkBasicKeycodes.ts new file mode 100644 index 0000000..1469046 --- /dev/null +++ b/mock/src/domain/qmkBasicKeycodes.ts @@ -0,0 +1,1209 @@ +// QMKの基本キーコード(group: basic / modifiers / internal)の正式名・別名・表示名。T-22, T-23 +// 出典: https://github.com/qmk/qmk_firmware/blob/1e340cd59c1012d5f4d48b483b5c14e3e99b2bc3/data/constants/keycodes/keycodes_0.0.1_basic.hjson +// 上記ファイルから機械的に抽出した(手で編集しない)。 +export type QmkKeycode = { + code: string; + group: "basic" | "modifiers" | "internal"; + key: string; + label: string; + aliases: string[]; +}; +export const qmkBasicKeycodes: QmkKeycode[] = [ + { + code: "0x0000", + group: "internal", + key: "KC_NO", + label: "N/A", + aliases: ["XXXXXXX"], + }, + { + code: "0x0001", + group: "internal", + key: "KC_TRANSPARENT", + label: "Transparent", + aliases: ["_______", "KC_TRNS"], + }, + { + code: "0x0004", + group: "basic", + key: "KC_A", + label: "A", + aliases: [], + }, + { + code: "0x0005", + group: "basic", + key: "KC_B", + label: "B", + aliases: [], + }, + { + code: "0x0006", + group: "basic", + key: "KC_C", + label: "C", + aliases: [], + }, + { + code: "0x0007", + group: "basic", + key: "KC_D", + label: "D", + aliases: [], + }, + { + code: "0x0008", + group: "basic", + key: "KC_E", + label: "E", + aliases: [], + }, + { + code: "0x0009", + group: "basic", + key: "KC_F", + label: "F", + aliases: [], + }, + { + code: "0x000A", + group: "basic", + key: "KC_G", + label: "G", + aliases: [], + }, + { + code: "0x000B", + group: "basic", + key: "KC_H", + label: "H", + aliases: [], + }, + { + code: "0x000C", + group: "basic", + key: "KC_I", + label: "I", + aliases: [], + }, + { + code: "0x000D", + group: "basic", + key: "KC_J", + label: "J", + aliases: [], + }, + { + code: "0x000E", + group: "basic", + key: "KC_K", + label: "K", + aliases: [], + }, + { + code: "0x000F", + group: "basic", + key: "KC_L", + label: "L", + aliases: [], + }, + { + code: "0x0010", + group: "basic", + key: "KC_M", + label: "M", + aliases: [], + }, + { + code: "0x0011", + group: "basic", + key: "KC_N", + label: "N", + aliases: [], + }, + { + code: "0x0012", + group: "basic", + key: "KC_O", + label: "O", + aliases: [], + }, + { + code: "0x0013", + group: "basic", + key: "KC_P", + label: "P", + aliases: [], + }, + { + code: "0x0014", + group: "basic", + key: "KC_Q", + label: "Q", + aliases: [], + }, + { + code: "0x0015", + group: "basic", + key: "KC_R", + label: "R", + aliases: [], + }, + { + code: "0x0016", + group: "basic", + key: "KC_S", + label: "S", + aliases: [], + }, + { + code: "0x0017", + group: "basic", + key: "KC_T", + label: "T", + aliases: [], + }, + { + code: "0x0018", + group: "basic", + key: "KC_U", + label: "U", + aliases: [], + }, + { + code: "0x0019", + group: "basic", + key: "KC_V", + label: "V", + aliases: [], + }, + { + code: "0x001A", + group: "basic", + key: "KC_W", + label: "W", + aliases: [], + }, + { + code: "0x001B", + group: "basic", + key: "KC_X", + label: "X", + aliases: [], + }, + { + code: "0x001C", + group: "basic", + key: "KC_Y", + label: "Y", + aliases: [], + }, + { + code: "0x001D", + group: "basic", + key: "KC_Z", + label: "Z", + aliases: [], + }, + { + code: "0x001E", + group: "basic", + key: "KC_1", + label: "1", + aliases: [], + }, + { + code: "0x001F", + group: "basic", + key: "KC_2", + label: "2", + aliases: [], + }, + { + code: "0x0020", + group: "basic", + key: "KC_3", + label: "3", + aliases: [], + }, + { + code: "0x0021", + group: "basic", + key: "KC_4", + label: "4", + aliases: [], + }, + { + code: "0x0022", + group: "basic", + key: "KC_5", + label: "5", + aliases: [], + }, + { + code: "0x0023", + group: "basic", + key: "KC_6", + label: "6", + aliases: [], + }, + { + code: "0x0024", + group: "basic", + key: "KC_7", + label: "7", + aliases: [], + }, + { + code: "0x0025", + group: "basic", + key: "KC_8", + label: "8", + aliases: [], + }, + { + code: "0x0026", + group: "basic", + key: "KC_9", + label: "9", + aliases: [], + }, + { + code: "0x0027", + group: "basic", + key: "KC_0", + label: "0", + aliases: [], + }, + { + code: "0x0028", + group: "basic", + key: "KC_ENTER", + label: "Enter", + aliases: ["KC_ENT"], + }, + { + code: "0x0029", + group: "basic", + key: "KC_ESCAPE", + label: "Esc", + aliases: ["KC_ESC"], + }, + { + code: "0x002A", + group: "basic", + key: "KC_BACKSPACE", + label: "Backspace", + aliases: ["KC_BSPC"], + }, + { + code: "0x002B", + group: "basic", + key: "KC_TAB", + label: "Tab", + aliases: [], + }, + { + code: "0x002C", + group: "basic", + key: "KC_SPACE", + label: "Spacebar", + aliases: ["KC_SPC"], + }, + { + code: "0x002D", + group: "basic", + key: "KC_MINUS", + label: "-", + aliases: ["KC_MINS"], + }, + { + code: "0x002E", + group: "basic", + key: "KC_EQUAL", + label: "=", + aliases: ["KC_EQL"], + }, + { + code: "0x002F", + group: "basic", + key: "KC_LEFT_BRACKET", + label: "[", + aliases: ["KC_LBRC"], + }, + { + code: "0x0030", + group: "basic", + key: "KC_RIGHT_BRACKET", + label: "]", + aliases: ["KC_RBRC"], + }, + { + code: "0x0031", + group: "basic", + key: "KC_BACKSLASH", + label: "\\", + aliases: ["KC_BSLS"], + }, + { + code: "0x0032", + group: "basic", + key: "KC_NONUS_HASH", + label: "#", + aliases: ["KC_NUHS"], + }, + { + code: "0x0033", + group: "basic", + key: "KC_SEMICOLON", + label: ";", + aliases: ["KC_SCLN"], + }, + { + code: "0x0034", + group: "basic", + key: "KC_QUOTE", + label: "'", + aliases: ["KC_QUOT"], + }, + { + code: "0x0035", + group: "basic", + key: "KC_GRAVE", + label: "`", + aliases: ["KC_GRV"], + }, + { + code: "0x0036", + group: "basic", + key: "KC_COMMA", + label: ",", + aliases: ["KC_COMM"], + }, + { + code: "0x0037", + group: "basic", + key: "KC_DOT", + label: ".", + aliases: [], + }, + { + code: "0x0038", + group: "basic", + key: "KC_SLASH", + label: "/", + aliases: ["KC_SLSH"], + }, + { + code: "0x0039", + group: "basic", + key: "KC_CAPS_LOCK", + label: "Caps Lock", + aliases: ["KC_CAPS"], + }, + { + code: "0x003A", + group: "basic", + key: "KC_F1", + label: "F1", + aliases: [], + }, + { + code: "0x003B", + group: "basic", + key: "KC_F2", + label: "F2", + aliases: [], + }, + { + code: "0x003C", + group: "basic", + key: "KC_F3", + label: "F3", + aliases: [], + }, + { + code: "0x003D", + group: "basic", + key: "KC_F4", + label: "F4", + aliases: [], + }, + { + code: "0x003E", + group: "basic", + key: "KC_F5", + label: "F5", + aliases: [], + }, + { + code: "0x003F", + group: "basic", + key: "KC_F6", + label: "F6", + aliases: [], + }, + { + code: "0x0040", + group: "basic", + key: "KC_F7", + label: "F7", + aliases: [], + }, + { + code: "0x0041", + group: "basic", + key: "KC_F8", + label: "F8", + aliases: [], + }, + { + code: "0x0042", + group: "basic", + key: "KC_F9", + label: "F9", + aliases: [], + }, + { + code: "0x0043", + group: "basic", + key: "KC_F10", + label: "F10", + aliases: [], + }, + { + code: "0x0044", + group: "basic", + key: "KC_F11", + label: "F11", + aliases: [], + }, + { + code: "0x0045", + group: "basic", + key: "KC_F12", + label: "F12", + aliases: [], + }, + { + code: "0x0046", + group: "basic", + key: "KC_PRINT_SCREEN", + label: "Print Screen", + aliases: ["KC_PSCR"], + }, + { + code: "0x0047", + group: "basic", + key: "KC_SCROLL_LOCK", + label: "Scroll Lock", + aliases: ["KC_SCRL", "KC_BRMD"], + }, + { + code: "0x0048", + group: "basic", + key: "KC_PAUSE", + label: "Pause", + aliases: ["KC_PAUS", "KC_BRK", "KC_BRMU"], + }, + { + code: "0x0049", + group: "basic", + key: "KC_INSERT", + label: "Insert", + aliases: ["KC_INS"], + }, + { + code: "0x004A", + group: "basic", + key: "KC_HOME", + label: "Home", + aliases: [], + }, + { + code: "0x004B", + group: "basic", + key: "KC_PAGE_UP", + label: "Page Up", + aliases: ["KC_PGUP"], + }, + { + code: "0x004C", + group: "basic", + key: "KC_DELETE", + label: "Delete", + aliases: ["KC_DEL"], + }, + { + code: "0x004D", + group: "basic", + key: "KC_END", + label: "End", + aliases: [], + }, + { + code: "0x004E", + group: "basic", + key: "KC_PAGE_DOWN", + label: "Page Down", + aliases: ["KC_PGDN"], + }, + { + code: "0x004F", + group: "basic", + key: "KC_RIGHT", + label: "Right", + aliases: ["KC_RGHT"], + }, + { + code: "0x0050", + group: "basic", + key: "KC_LEFT", + label: "Left", + aliases: [], + }, + { + code: "0x0051", + group: "basic", + key: "KC_DOWN", + label: "Down", + aliases: [], + }, + { + code: "0x0052", + group: "basic", + key: "KC_UP", + label: "Up", + aliases: [], + }, + { + code: "0x0053", + group: "basic", + key: "KC_NUM_LOCK", + label: "Num Lock", + aliases: ["KC_NUM"], + }, + { + code: "0x0054", + group: "basic", + key: "KC_KP_SLASH", + label: "/", + aliases: ["KC_PSLS"], + }, + { + code: "0x0055", + group: "basic", + key: "KC_KP_ASTERISK", + label: "*", + aliases: ["KC_PAST"], + }, + { + code: "0x0056", + group: "basic", + key: "KC_KP_MINUS", + label: "-", + aliases: ["KC_PMNS"], + }, + { + code: "0x0057", + group: "basic", + key: "KC_KP_PLUS", + label: "+", + aliases: ["KC_PPLS"], + }, + { + code: "0x0058", + group: "basic", + key: "KC_KP_ENTER", + label: "Enter", + aliases: ["KC_PENT"], + }, + { + code: "0x0059", + group: "basic", + key: "KC_KP_1", + label: "1", + aliases: ["KC_P1"], + }, + { + code: "0x005A", + group: "basic", + key: "KC_KP_2", + label: "2", + aliases: ["KC_P2"], + }, + { + code: "0x005B", + group: "basic", + key: "KC_KP_3", + label: "3", + aliases: ["KC_P3"], + }, + { + code: "0x005C", + group: "basic", + key: "KC_KP_4", + label: "4", + aliases: ["KC_P4"], + }, + { + code: "0x005D", + group: "basic", + key: "KC_KP_5", + label: "5", + aliases: ["KC_P5"], + }, + { + code: "0x005E", + group: "basic", + key: "KC_KP_6", + label: "6", + aliases: ["KC_P6"], + }, + { + code: "0x005F", + group: "basic", + key: "KC_KP_7", + label: "7", + aliases: ["KC_P7"], + }, + { + code: "0x0060", + group: "basic", + key: "KC_KP_8", + label: "8", + aliases: ["KC_P8"], + }, + { + code: "0x0061", + group: "basic", + key: "KC_KP_9", + label: "9", + aliases: ["KC_P9"], + }, + { + code: "0x0062", + group: "basic", + key: "KC_KP_0", + label: "0", + aliases: ["KC_P0"], + }, + { + code: "0x0063", + group: "basic", + key: "KC_KP_DOT", + label: ".", + aliases: ["KC_PDOT"], + }, + { + code: "0x0064", + group: "basic", + key: "KC_NONUS_BACKSLASH", + label: "\\", + aliases: ["KC_NUBS"], + }, + { + code: "0x0065", + group: "basic", + key: "KC_APPLICATION", + label: "App", + aliases: ["KC_APP"], + }, + { + code: "0x0066", + group: "basic", + key: "KC_KB_POWER", + label: "Power", + aliases: [], + }, + { + code: "0x0067", + group: "basic", + key: "KC_KP_EQUAL", + label: "=", + aliases: ["KC_PEQL"], + }, + { + code: "0x0068", + group: "basic", + key: "KC_F13", + label: "F13", + aliases: [], + }, + { + code: "0x0069", + group: "basic", + key: "KC_F14", + label: "F14", + aliases: [], + }, + { + code: "0x006A", + group: "basic", + key: "KC_F15", + label: "F15", + aliases: [], + }, + { + code: "0x006B", + group: "basic", + key: "KC_F16", + label: "F16", + aliases: [], + }, + { + code: "0x006C", + group: "basic", + key: "KC_F17", + label: "F17", + aliases: [], + }, + { + code: "0x006D", + group: "basic", + key: "KC_F18", + label: "F18", + aliases: [], + }, + { + code: "0x006E", + group: "basic", + key: "KC_F19", + label: "F19", + aliases: [], + }, + { + code: "0x006F", + group: "basic", + key: "KC_F20", + label: "F20", + aliases: [], + }, + { + code: "0x0070", + group: "basic", + key: "KC_F21", + label: "F21", + aliases: [], + }, + { + code: "0x0071", + group: "basic", + key: "KC_F22", + label: "F22", + aliases: [], + }, + { + code: "0x0072", + group: "basic", + key: "KC_F23", + label: "F23", + aliases: [], + }, + { + code: "0x0073", + group: "basic", + key: "KC_F24", + label: "F24", + aliases: [], + }, + { + code: "0x0074", + group: "basic", + key: "KC_EXECUTE", + label: "Execute", + aliases: ["KC_EXEC"], + }, + { + code: "0x0075", + group: "basic", + key: "KC_HELP", + label: "Help", + aliases: [], + }, + { + code: "0x0076", + group: "basic", + key: "KC_MENU", + label: "Menu", + aliases: [], + }, + { + code: "0x0077", + group: "basic", + key: "KC_SELECT", + label: "Select", + aliases: ["KC_SLCT"], + }, + { + code: "0x0078", + group: "basic", + key: "KC_STOP", + label: "Stop", + aliases: [], + }, + { + code: "0x0079", + group: "basic", + key: "KC_AGAIN", + label: "Again", + aliases: ["KC_AGIN"], + }, + { + code: "0x007A", + group: "basic", + key: "KC_UNDO", + label: "Undo", + aliases: [], + }, + { + code: "0x007B", + group: "basic", + key: "KC_CUT", + label: "Cut", + aliases: [], + }, + { + code: "0x007C", + group: "basic", + key: "KC_COPY", + label: "Copy", + aliases: [], + }, + { + code: "0x007D", + group: "basic", + key: "KC_PASTE", + label: "Paste", + aliases: ["KC_PSTE"], + }, + { + code: "0x007E", + group: "basic", + key: "KC_FIND", + label: "Find", + aliases: [], + }, + { + code: "0x007F", + group: "basic", + key: "KC_KB_MUTE", + label: "Mute", + aliases: [], + }, + { + code: "0x0080", + group: "basic", + key: "KC_KB_VOLUME_UP", + label: "Volume Up", + aliases: [], + }, + { + code: "0x0081", + group: "basic", + key: "KC_KB_VOLUME_DOWN", + label: "Volume Down", + aliases: [], + }, + { + code: "0x0082", + group: "basic", + key: "KC_LOCKING_CAPS_LOCK", + label: "Caps Lock", + aliases: ["KC_LCAP"], + }, + { + code: "0x0083", + group: "basic", + key: "KC_LOCKING_NUM_LOCK", + label: "Num Lock", + aliases: ["KC_LNUM"], + }, + { + code: "0x0084", + group: "basic", + key: "KC_LOCKING_SCROLL_LOCK", + label: "Scroll Lock", + aliases: ["KC_LSCR"], + }, + { + code: "0x0085", + group: "basic", + key: "KC_KP_COMMA", + label: ",", + aliases: ["KC_PCMM"], + }, + { + code: "0x0086", + group: "basic", + key: "KC_KP_EQUAL_AS400", + label: "=", + aliases: [], + }, + { + code: "0x0087", + group: "basic", + key: "KC_INTERNATIONAL_1", + label: "INT 1", + aliases: ["KC_INT1"], + }, + { + code: "0x0088", + group: "basic", + key: "KC_INTERNATIONAL_2", + label: "INT 2", + aliases: ["KC_INT2"], + }, + { + code: "0x0089", + group: "basic", + key: "KC_INTERNATIONAL_3", + label: "INT 3", + aliases: ["KC_INT3"], + }, + { + code: "0x008A", + group: "basic", + key: "KC_INTERNATIONAL_4", + label: "INT 4", + aliases: ["KC_INT4"], + }, + { + code: "0x008B", + group: "basic", + key: "KC_INTERNATIONAL_5", + label: "INT 5", + aliases: ["KC_INT5"], + }, + { + code: "0x008C", + group: "basic", + key: "KC_INTERNATIONAL_6", + label: "INT 6", + aliases: ["KC_INT6"], + }, + { + code: "0x008D", + group: "basic", + key: "KC_INTERNATIONAL_7", + label: "INT 7", + aliases: ["KC_INT7"], + }, + { + code: "0x008E", + group: "basic", + key: "KC_INTERNATIONAL_8", + label: "INT 8", + aliases: ["KC_INT8"], + }, + { + code: "0x008F", + group: "basic", + key: "KC_INTERNATIONAL_9", + label: "INT 9", + aliases: ["KC_INT9"], + }, + { + code: "0x0090", + group: "basic", + key: "KC_LANGUAGE_1", + label: "LANG 1", + aliases: ["KC_LNG1"], + }, + { + code: "0x0091", + group: "basic", + key: "KC_LANGUAGE_2", + label: "LANG 2", + aliases: ["KC_LNG2"], + }, + { + code: "0x0092", + group: "basic", + key: "KC_LANGUAGE_3", + label: "LANG 3", + aliases: ["KC_LNG3"], + }, + { + code: "0x0093", + group: "basic", + key: "KC_LANGUAGE_4", + label: "LANG 4", + aliases: ["KC_LNG4"], + }, + { + code: "0x0094", + group: "basic", + key: "KC_LANGUAGE_5", + label: "LANG 5", + aliases: ["KC_LNG5"], + }, + { + code: "0x0095", + group: "basic", + key: "KC_LANGUAGE_6", + label: "LANG 6", + aliases: ["KC_LNG6"], + }, + { + code: "0x0096", + group: "basic", + key: "KC_LANGUAGE_7", + label: "LANG 7", + aliases: ["KC_LNG7"], + }, + { + code: "0x0097", + group: "basic", + key: "KC_LANGUAGE_8", + label: "LANG 8", + aliases: ["KC_LNG8"], + }, + { + code: "0x0098", + group: "basic", + key: "KC_LANGUAGE_9", + label: "LANG 9", + aliases: ["KC_LNG9"], + }, + { + code: "0x0099", + group: "basic", + key: "KC_ALTERNATE_ERASE", + label: "Alternate Erase", + aliases: ["KC_ERAS"], + }, + { + code: "0x009A", + group: "basic", + key: "KC_SYSTEM_REQUEST", + label: "SysReq/Attention", + aliases: ["KC_SYRQ"], + }, + { + code: "0x009B", + group: "basic", + key: "KC_CANCEL", + label: "Cancel", + aliases: ["KC_CNCL"], + }, + { + code: "0x009C", + group: "basic", + key: "KC_CLEAR", + label: "Clear", + aliases: ["KC_CLR"], + }, + { + code: "0x009D", + group: "basic", + key: "KC_PRIOR", + label: "Prior", + aliases: ["KC_PRIR"], + }, + { + code: "0x009E", + group: "basic", + key: "KC_RETURN", + label: "Return", + aliases: ["KC_RETN"], + }, + { + code: "0x009F", + group: "basic", + key: "KC_SEPARATOR", + label: "Separator", + aliases: ["KC_SEPR"], + }, + { + code: "0x00A0", + group: "basic", + key: "KC_OUT", + label: "Out", + aliases: [], + }, + { + code: "0x00A1", + group: "basic", + key: "KC_OPER", + label: "Oper", + aliases: [], + }, + { + code: "0x00A2", + group: "basic", + key: "KC_CLEAR_AGAIN", + label: "Clear/Again", + aliases: ["KC_CLAG"], + }, + { + code: "0x00A3", + group: "basic", + key: "KC_CRSEL", + label: "CrSel/Props", + aliases: ["KC_CRSL"], + }, + { + code: "0x00A4", + group: "basic", + key: "KC_EXSEL", + label: "ExSel", + aliases: ["KC_EXSL"], + }, + { + code: "0x00E0", + group: "modifiers", + key: "KC_LEFT_CTRL", + label: "Left Control", + aliases: ["KC_LCTL"], + }, + { + code: "0x00E1", + group: "modifiers", + key: "KC_LEFT_SHIFT", + label: "Left Shift", + aliases: ["KC_LSFT"], + }, + { + code: "0x00E2", + group: "modifiers", + key: "KC_LEFT_ALT", + label: "Left Alt", + aliases: ["KC_LALT", "KC_LOPT"], + }, + { + code: "0x00E3", + group: "modifiers", + key: "KC_LEFT_GUI", + label: "Left GUI", + aliases: ["KC_LGUI", "KC_LCMD", "KC_LWIN"], + }, + { + code: "0x00E4", + group: "modifiers", + key: "KC_RIGHT_CTRL", + label: "Right Control", + aliases: ["KC_RCTL"], + }, + { + code: "0x00E5", + group: "modifiers", + key: "KC_RIGHT_SHIFT", + label: "Right Shift", + aliases: ["KC_RSFT"], + }, + { + code: "0x00E6", + group: "modifiers", + key: "KC_RIGHT_ALT", + label: "Right Alt", + aliases: ["KC_RALT", "KC_ROPT", "KC_ALGR"], + }, + { + code: "0x00E7", + group: "modifiers", + key: "KC_RIGHT_GUI", + label: "Right GUI", + aliases: ["KC_RGUI", "KC_RCMD", "KC_RWIN"], + }, +]; diff --git a/mock/src/domain/validate.ts b/mock/src/domain/validate.ts new file mode 100644 index 0000000..0d128da --- /dev/null +++ b/mock/src/domain/validate.ts @@ -0,0 +1,354 @@ +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(); + 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 = 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(); + 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(); + 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(); + 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>(); + 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(); + 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(); + 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; +} diff --git a/mock/src/env.d.ts b/mock/src/env.d.ts new file mode 100644 index 0000000..525d5f8 --- /dev/null +++ b/mock/src/env.d.ts @@ -0,0 +1,5 @@ +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent; + export default component; +} diff --git a/mock/src/io/json.ts b/mock/src/io/json.ts new file mode 100644 index 0000000..9b8edd5 --- /dev/null +++ b/mock/src/io/json.ts @@ -0,0 +1,26 @@ +/** RFC 8785 JCS の UTF-8 対象文字列を生成する。T-12。 */ +export function canonicalize(value: unknown): string { + return encode(value); +} +function encode(v: unknown): string { + if (v === null) return "null"; + if (typeof v === "string") return JSON.stringify(v); + if (typeof v === "boolean") return v ? "true" : "false"; + if (typeof v === "number") { + if (!Number.isFinite(v)) throw new TypeError("JCS does not support non-finite numbers"); + return JSON.stringify(Object.is(v, -0) ? 0 : v); + } + if (Array.isArray(v)) return `[${v.map(encode).join(",")}]`; + if (typeof v === "object") { + const o = v as Record; + const keys = Object.keys(o).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${encode(o[k])}`).join(",")}}`; + } + throw new TypeError(`Unsupported JCS value: ${typeof v}`); +} +/** JCS正規化したUTF-8をSHA-256し、指定形式で返す。T-12。 */ +export async function sha256Jcs(value: unknown): Promise { + const bytes = new TextEncoder().encode(canonicalize(value)); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return `sha256-jcs:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`; +} diff --git a/mock/src/io/kleExport.ts b/mock/src/io/kleExport.ts new file mode 100644 index 0000000..96b5075 --- /dev/null +++ b/mock/src/io/kleExport.ts @@ -0,0 +1,88 @@ +import type { FlatKey, KleCompatibility, PhyFile } from "../domain/phy"; +import { flattenKeys } from "../domain/geometry"; +import { labelsToKle } from "./kleImport"; + +type Obj = Record; +const defaults: KleCompatibility = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" }; + +/** PHYをバリアント選択に従ってKLE JSONへ平坦化する。座標は6桁丸め。T-20。 */ +export function exportKle(phy: PhyFile, variantSelection: Record = {}): unknown[] { + const keys = flattenKeys(phy, variantSelection); + const order = [...keys].sort((a, b) => a.rotation - b.rotation || a.y - b.y || a.x - b.x); + const centers = new Map(); + for (const key of order) if (!centers.has(key.rotation)) centers.set(key.rotation, { x: key.x, y: key.y }); + const previous: KleCompatibility = { ...defaults }; + let previousGhost = false; + const rows: unknown[] = []; + + for (const flat of order) { + const key = flat.key; + const center = centers.get(flat.rotation)!; + const rad = (flat.rotation * Math.PI) / 180; + const dx = flat.x - center.x; + const dy = flat.y - center.y; + const localX = center.x + Math.cos(rad) * dx + Math.sin(rad) * dy; + const localY = center.y - Math.sin(rad) * dx + Math.cos(rad) * dy; + const compat = { ...defaults, ...(key.kleCompatibility ?? {}) }; + const cfg: Obj = { + r: flat.rotation, + rx: round(center.x), + ry: round(center.y), + x: round(localX - center.x), + y: round(localY - center.y), + }; + + for (const field of ["c", "t", "p", "a", "sm", "sb", "st"] as const) { + const next = compat[field]; + if (!same(next, previous[field])) cfg[field] = structuredClone(next); + previous[field] = structuredClone(next); + } + const oldOverride = previous.fa !== undefined ? "fa" : previous.f2 !== undefined ? "f2" : "none"; + const nextOverride = compat.fa !== undefined ? "fa" : compat.f2 !== undefined ? "f2" : "none"; + const fontChanged = !same(compat.f, previous.f); + if (fontChanged || (nextOverride === "none" && oldOverride !== "none")) cfg.f = compat.f; + if (nextOverride === "fa" && (oldOverride !== "fa" || !same(compat.fa, previous.fa))) + cfg.fa = structuredClone(compat.fa); + if (nextOverride === "f2" && (oldOverride !== "f2" || !same(compat.f2, previous.f2))) + cfg.f2 = structuredClone(compat.f2); + previous.f = structuredClone(compat.f); + delete previous.f2; + delete previous.fa; + if (nextOverride === "fa") previous.fa = structuredClone(compat.fa); + if (nextOverride === "f2") previous.f2 = structuredClone(compat.f2); + + if (key.ghost !== previousGhost) { + cfg.g = key.ghost === true; + previousGhost = key.ghost === true; + } + if (key.decal) cfg.d = true; + if (compat.l === true) cfg.l = true; + if (compat.n === true) cfg.n = true; + + const w = key.size.w; + const h = key.size.h; + if (w !== 1) cfg.w = round(w); + if (h !== 1) cfg.h = round(h); + if (key.size.x2 !== undefined && key.size.x2 !== 0) cfg.x2 = round(key.size.x2); + if (key.size.y2 !== undefined && key.size.y2 !== 0) cfg.y2 = round(key.size.y2); + const w2 = key.size.w2 ?? w; + const h2 = key.size.h2 ?? h; + if (w2 !== w) cfg.w2 = round(w2); + if (h2 !== h) cfg.h2 = round(h2); + + rows.push([cfg, labelsToKle(key.labels, typeof compat.a === "number" ? compat.a : 4)]); + } + if (phy.kleCompatibility) rows.unshift(structuredClone(phy.kleCompatibility)); + return rows; +} + +/** KLE JSON文字列を生成する。 */ +export function stringifyKle(phy: PhyFile, selection: Record = {}): string { + return JSON.stringify(exportKle(phy, selection)); +} +function same(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} +function round(value: number): number { + return Number(value.toFixed(6)); +} diff --git a/mock/src/io/kleImport.ts b/mock/src/io/kleImport.ts new file mode 100644 index 0000000..894bac8 --- /dev/null +++ b/mock/src/io/kleImport.ts @@ -0,0 +1,192 @@ +import type { KleCompatibility, KeyNode, PhyFile } from "../domain/phy"; +import { createNodeId } from "../domain/ids"; + +type Obj = Record; +type Atom = string | Obj; + +// Output slot -> newline-field index. These fixed tables reproduce KLE's a=0..7 layout. +const labelMap: readonly (readonly (number | null)[])[] = [ + [0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5], + [null, 0, null, null, 6, null, null, 1, null, 4, 11, 5], + [null, null, null, 0, 8, 2, null, null, null, 4, 11, 5], + [null, null, null, null, 0, null, null, null, null, 4, 11, 5], + [0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null], + [null, 0, null, null, 6, null, null, 1, null, null, 4, null], + [null, null, null, 0, 8, 2, null, null, null, null, 4, null], + [null, null, null, null, 0, null, null, null, null, null, 4, null], +]; + +const fileDefaults: Obj = { + backcolor: "#eeeeee", + name: "", + author: "", + notes: "", + background: null, + radii: "", + switchMount: "", + switchBrand: "", + switchType: "", +}; +const keyDefaults: Obj = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" }; + +function isObj(value: unknown): value is Obj { + return !!value && typeof value === "object" && !Array.isArray(value); +} +function num(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} +function labelsFromKle(serialized: string, alignment: number): string[] { + const fields = serialized.split("\n"); + if (fields.length > 12) throw new Error("A KLE label cannot exceed 12 slots"); + const map = labelMap[alignment] ?? labelMap[4]!; + const labels = Array(12).fill(""); + map.forEach((field, slot) => { + if (field !== null && field < fields.length) labels[slot] = fields[field]!; + }); + while (labels.at(-1) === "") labels.pop(); + return labels; +} +function oneShotArray(state: Obj, key: string): void { + delete state[key]; +} + +/** KLEの厳密JSON(またはVIA layouts.keymap)を新規PHYへ取り込む。T-7/T-18/T-20。 */ +export function importKle( + input: unknown, + options: { idGenerator?: () => string; name?: string } = {}, +): { phy: PhyFile; keyOrder: string[] } { + let data: unknown = input; + if (isObj(data)) { + const layouts = data.layouts; + if (isObj(layouts) && Array.isArray(layouts.keymap)) data = layouts.keymap; + else throw new Error("Expected KLE array or VIA layouts.keymap"); + } + if (!Array.isArray(data)) throw new Error("KLE input must be a JSON array"); + const rows = [...data] as unknown[]; + let rawMeta: Obj = {}; + if (rows.length && isObj(rows[0])) rawMeta = rows.shift() as Obj; + + const gen = options.idGenerator ?? (() => globalThis.crypto.randomUUID()); + const state: Obj = { ...keyDefaults }; + let cursorX = 0; + let cursorY = 0; + let rotation = 0; + let rotationX = 0; + let rotationY = 0; + const root: KeyNode[] = []; + const keyOrder: string[] = []; + + for (const row of rows) { + if (!Array.isArray(row)) throw new Error("KLE rows must be arrays"); + cursorX = rotationX; + for (const atom of row as Atom[]) { + if (typeof atom === "string") { + const w = num(state.w, 1); + const h = num(state.h, 1); + cursorX += num(state.x, 0); + cursorY += num(state.y, 0); + const radians = (rotation * Math.PI) / 180; + const dx = cursorX - rotationX; + const dy = cursorY - rotationY; + const x = rotationX + Math.cos(radians) * dx - Math.sin(radians) * dy; + const y = rotationY + Math.sin(radians) * dx + Math.cos(radians) * dy; + const a = num(state.a, 4); + const id = createNodeId("key", gen); + const labels = labelsFromKle(atom, a); + const compatibility: KleCompatibility = { + c: structuredClone(state.c), + t: structuredClone(state.t), + p: structuredClone(state.p), + a, + f: num(state.f, 3), + ...(state.f2 === undefined ? {} : { f2: structuredClone(state.f2) }), + ...(state.fa === undefined ? {} : { fa: structuredClone(state.fa) }), + l: state.l === true, + n: state.n === true, + sm: structuredClone(state.sm), + sb: structuredClone(state.sb), + st: structuredClone(state.st), + }; + const w2 = num(state.w2, 0) || w; + const h2 = num(state.h2, 0) || h; + const key: KeyNode = { + type: "key", + id, + profile: "default", + transform: { x, y, rotation }, + size: { + w, + h, + ...(num(state.x2, 0) === 0 ? {} : { x2: num(state.x2, 0) }), + ...(num(state.y2, 0) === 0 ? {} : { y2: num(state.y2, 0) }), + ...(w2 === w ? {} : { w2 }), + ...(h2 === h ? {} : { h2 }), + }, + labels, + kleCompatibility: compatibility, + }; + if (state.d === true) key.decal = true; + if (state.g === true) key.ghost = true; + root.push(key); + if (!key.decal) keyOrder.push(id); + + cursorX += w; + for (const field of ["w", "h", "x", "y", "x2", "y2", "w2", "h2", "d", "l", "n"]) + oneShotArray(state, field); + } else if (isObj(atom)) { + for (const [key, value] of Object.entries(atom)) { + if (key === "r") rotation = num(value, rotation); + else if (key === "rx") { + rotationX = num(value, rotationX); + cursorX = rotationX; + cursorY = rotationY; + } else if (key === "ry") { + rotationY = num(value, rotationY); + cursorX = rotationX; + cursorY = rotationY; + } else if (key === "f") { + state.f = value; + delete state.f2; + delete state.fa; + } else if (key === "f2") { + state.f2 = value; + delete state.fa; + } else if (key === "fa") { + state.fa = structuredClone(value); + delete state.f2; + } else state[key] = structuredClone(value); + } + } else throw new Error("KLE row entries must be strings or objects"); + } + cursorY += 1; + } + + const metadata = { ...fileDefaults, ...structuredClone(rawMeta) }; + const phy: PhyFile = { + version: "0.1.0", + metadata: { ...(options.name ? { name: options.name } : {}) }, + profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }], + root, + kleCompatibility: metadata, + }; + return { phy, keyOrder }; +} + +/** JSON文字列を厳密にパースして KLE を取り込む。 */ +export function parseKleJson( + json: string, + options: { idGenerator?: () => string; name?: string } = {}, +): ReturnType { + return importKle(JSON.parse(json) as unknown, options); +} + +/** 正準12欄のラベルをKLEのa別固定欄順に戻す。 */ +export function labelsToKle(labels: string[] | undefined, alignment: number): string { + const map = labelMap[alignment] ?? labelMap[4]!; + const fields = Array(12).fill(""); + map.forEach((sourceSlot, slot) => { + if (sourceSlot !== null) fields[sourceSlot] = labels?.[slot] ?? ""; + }); + while (fields.at(-1) === "") fields.pop(); + return fields.join("\n"); +} diff --git a/mock/src/io/projectFile.ts b/mock/src/io/projectFile.ts new file mode 100644 index 0000000..6dfa68b --- /dev/null +++ b/mock/src/io/projectFile.ts @@ -0,0 +1,177 @@ +import type { ProjectFile } from "../domain/project"; +import { validateProject, type ValidationIssue } from "../domain/validate"; +import { makePhyReference, phyReferenceMatches } from "../domain/phyReference"; +import { validateSchema, type LayerName } from "./validateSchema"; + +export type ProjectReadResult = { project?: ProjectFile; errors: ValidationIssue[] }; +export type ProjectSaveResult = { json?: string; errors: ValidationIssue[] }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** Validates project and output container shapes before semantic checks. T-10/T-16. */ +function validateProjectShape(value: unknown): ValidationIssue[] { + const errors: ValidationIssue[] = []; + if (!isRecord(value)) + return [{ severity: "blocksSave", code: "schema", message: "Expected a ProjectFile object", path: "/" }]; + const required = ["format", "version", "projectId", "phy", "idx", "elc", "map", "output"]; + for (const field of required) + if (!(field in value)) + errors.push({ + severity: "blocksSave", + code: "schema", + message: `Missing required ProjectFile field: ${field}`, + path: `/${field}`, + }); + if (value.format !== "beyond-kle-mock-project") + errors.push({ + severity: "blocksSave", + code: "schema", + message: "Invalid ProjectFile format", + path: "/format", + }); + if (typeof value.version !== "string") + errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/version" }); + if (typeof value.projectId !== "string") + errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/projectId" }); + for (const layer of ["phy", "idx", "elc", "map"] as const) { + if (!(layer in value)) continue; + for (const issue of validateSchema(layer, value[layer])) + errors.push({ + severity: "blocksSave", + code: "schema", + message: issue.message, + path: `/${layer}${issue.path === "/" ? "" : issue.path}`, + }); + } + const output = value.output; + if (!isRecord(output)) { + errors.push({ severity: "blocksSave", code: "schema", message: "Expected an object", path: "/output" }); + } else { + if (!isRecord(output.variantSelection)) + errors.push({ + severity: "blocksSave", + code: "schema", + message: "Expected an object of variant IDs", + path: "/output/variantSelection", + }); + else + for (const [key, variant] of Object.entries(output.variantSelection)) + if (typeof variant !== "string") + errors.push({ + severity: "blocksSave", + code: "schema", + message: "Expected a variant ID string", + path: `/output/variantSelection/${key}`, + }); + if (output.matrix !== undefined) { + if (!isRecord(output.matrix)) + errors.push({ + severity: "blocksSave", + code: "schema", + message: "Expected an object", + path: "/output/matrix", + }); + else { + for (const field of ["rowPins", "colPins"] as const) { + const pins = output.matrix[field]; + if (!Array.isArray(pins) || pins.some((pin) => typeof pin !== "string")) + errors.push({ + severity: "blocksSave", + code: "schema", + message: "Expected an array of pin references", + path: `/output/matrix/${field}`, + }); + } + if ( + output.matrix.kind !== undefined && + !["matrix", "charlieplexing", "direct"].includes(String(output.matrix.kind)) + ) + errors.push({ + severity: "blocksSave", + code: "schema", + message: "Unknown output scan mode", + path: "/output/matrix/kind", + }); + } + } + } + return errors; +} + +/** Serializes a project and refreshes PHY references unless T-16 blocks saving. T-12/T-13. */ +export async function saveProject(project: ProjectFile, pretty = false): Promise { + const shapeErrors = validateProjectShape(project); + if (shapeErrors.length) return { errors: shapeErrors }; + const errors = validateProject(project); + if (errors.some((issue) => issue.severity === "blocksSave")) return { errors }; + + const p = structuredClone(project); + const ref = await makePhyReference(p.phy); + for (const layer of ["idx", "elc", "map"] as const) + p[layer].phyReference = { ...p[layer].phyReference, ...ref }; + return { json: JSON.stringify(p, null, pretty ? 2 : undefined), errors }; +} + +/** Parses a project, checking all schemas before semantic validation and retaining unknown fields. T-13/T-14. */ +export async function loadProject(json: string): Promise { + let data: unknown; + try { + data = JSON.parse(json) as unknown; + } catch (e) { + return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] }; + } + const errors = validateProjectShape(data); + if (errors.length) return { errors }; + + const project = data as ProjectFile; + const semantic = validateProject(project); + const unsupportedVersion = semantic.filter((issue) => issue.code === "version"); + if (unsupportedVersion.length) return { errors: semantic }; + for (const layer of ["idx", "elc", "map"] as const) + if (!(await phyReferenceMatches(project.phy, project[layer].phyReference))) + semantic.push({ + severity: "warning", + code: "phy-reference-mismatch", + message: `${layer} PHY checksum does not match the current PHY`, + path: `/${layer}/phyReference`, + }); + return { project, errors: semantic }; +} + +/** Writes a single layer as JSON. */ +export function writeLayer(layer: LayerName, value: unknown, pretty = false): string { + const errors = validateSchema(layer, value); + if (errors.length) throw new Error(errors.map((issue) => `${issue.path} ${issue.message}`).join("; ")); + return JSON.stringify(value, null, pretty ? 2 : undefined); +} + +/** Reads and schema-validates one layer file. */ +export function readLayer(layer: LayerName, json: string): { value?: unknown; errors: ValidationIssue[] } { + let value: unknown; + try { + value = JSON.parse(json) as unknown; + } catch (e) { + return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] }; + } + const errors = validateSchema(layer, value).map((issue) => ({ + severity: "blocksSave" as const, + code: "schema", + message: issue.message, + path: issue.path, + })); + if ( + value && + typeof value === "object" && + "version" in value && + (value as { version?: unknown }).version !== "0.1.0" + ) + errors.push({ + severity: "blocksSave", + code: "version", + message: "Unsupported layer version", + path: "/version", + }); + return errors.length ? { errors } : { value, errors: [] }; +} diff --git a/mock/src/io/schemas/elc.schema.json b/mock/src/io/schemas/elc.schema.json new file mode 100644 index 0000000..27f8a0a --- /dev/null +++ b/mock/src/io/schemas/elc.schema.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://beyond-kle.org/schemas/elc-layer.json", + "title": "ELC Layer - Electrical Wiring", + "description": "Wiring between provider pins and key terminals", + "type": "object", + "required": ["version", "phyReference", "providers", "keys"], + "properties": { + "version": { "type": "string" }, + "phyReference": { + "type": "object", + "required": ["source", "checksum"], + "properties": { + "source": { "type": "string" }, + "checksum": { "type": "string" } + } + }, + "providers": { + "type": "array", + "items": { "$ref": "#/$defs/provider" } + }, + "keys": { + "type": "array", + "items": { "$ref": "#/$defs/keyWiring" } + }, + "ledChains": { + "type": "array", + "items": { "$ref": "#/$defs/ledChain" } + } + }, + "$defs": { + "pinRef": { + "type": "string", + "pattern": "^(VCC|GND|[^.]+\\.[^.]+)$", + "description": "\".\", or the reserved power rails VCC / GND" + }, + "provider": { + "oneOf": [{ "$ref": "#/$defs/mcuProvider" }, { "$ref": "#/$defs/analogMuxProvider" }] + }, + "mcuProvider": { + "type": "object", + "required": ["id", "kind", "part"], + "properties": { + "id": { "type": "string" }, + "kind": { "const": "mcu" }, + "part": { + "type": "string", + "description": "Part number. Used to interpret pin names" + } + } + }, + "analogMuxProvider": { + "type": "object", + "required": ["id", "kind", "channels", "select", "common"], + "properties": { + "id": { "type": "string" }, + "kind": { "const": "analog-mux" }, + "channels": { + "type": "integer", + "minimum": 1, + "description": "Number of logical pins provided. Pin names are \"0\"..\"channels-1\"" + }, + "select": { + "type": "array", + "items": { "$ref": "#/$defs/pinRef" }, + "description": "Select pins, least significant bit first" + }, + "common": { "$ref": "#/$defs/pinRef" }, + "enable": { "$ref": "#/$defs/pinRef" } + } + }, + "keyWiring": { + "oneOf": [{ "$ref": "#/$defs/contactKey" }, { "$ref": "#/$defs/analogKey" }] + }, + "contactKey": { + "type": "object", + "required": ["key", "detect", "terminals"], + "properties": { + "key": { + "type": "string", + "description": "Reference to PHY layer key ID" + }, + "detect": { "const": "contact" }, + "terminals": { + "type": "object", + "description": "Non-polar. a and b carry no ordering meaning", + "required": ["a", "b"], + "properties": { + "a": { "$ref": "#/$defs/pinRef" }, + "b": { "$ref": "#/$defs/pinRef" } + } + }, + "diode": { + "type": "string", + "enum": ["a-to-b", "b-to-a"], + "description": "Anode side comes first. Omitted means no diode" + } + } + }, + "analogKey": { + "type": "object", + "required": ["key", "detect", "terminals"], + "properties": { + "key": { + "type": "string", + "description": "Reference to PHY layer key ID" + }, + "detect": { "const": "analog" }, + "terminals": { + "type": "object", + "required": ["out", "vcc", "gnd"], + "properties": { + "out": { "$ref": "#/$defs/pinRef" }, + "vcc": { "$ref": "#/$defs/pinRef" }, + "gnd": { "$ref": "#/$defs/pinRef" } + } + } + } + }, + "ledChain": { + "type": "object", + "required": ["id", "data", "order"], + "properties": { + "id": { "type": "string" }, + "data": { "$ref": "#/$defs/pinRef" }, + "order": { + "type": "array", + "items": { "type": "string" }, + "description": "PHY node IDs. The position in the array is the LED number" + } + } + } + } +} diff --git a/mock/src/io/schemas/idx.schema.json b/mock/src/io/schemas/idx.schema.json new file mode 100644 index 0000000..9211562 --- /dev/null +++ b/mock/src/io/schemas/idx.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://beyond-kle.org/schemas/idx-layer.json", + "title": "IDX Layer - Key Identification", + "description": "Ordered PHY key IDs across all variants. Output key numbers are contiguous positions after filtering by variant selection", + "type": "object", + "required": ["version", "phyReference", "order"], + "properties": { + "version": { "type": "string" }, + "phyReference": { + "type": "object", + "required": ["source", "checksum"], + "properties": { + "source": { "type": "string" }, + "checksum": { "type": "string" } + } + }, + "order": { + "type": "array", + "uniqueItems": true, + "description": "Each PHY key has its own entry, including mutually exclusive keys wired to the same terminals. Variant selection does not modify this list", + "items": { + "type": "string", + "description": "Reference to PHY layer key ID" + } + } + } +} diff --git a/mock/src/io/schemas/map.schema.json b/mock/src/io/schemas/map.schema.json new file mode 100644 index 0000000..fff614e --- /dev/null +++ b/mock/src/io/schemas/map.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://beyond-kle.org/schemas/map-layer.json", + "title": "MAP Layer - Keymap Configuration", + "description": "Key code assignments and layers", + "type": "object", + "required": ["version", "phyReference", "layers"], + "properties": { + "version": { "type": "string" }, + "phyReference": { + "type": "object", + "required": ["source", "checksum"], + "properties": { + "source": { "type": "string" }, + "checksum": { "type": "string" } + } + }, + "layers": { + "type": "array", + "items": { + "$ref": "#/$defs/layer" + } + } + }, + "$defs": { + "layer": { + "type": "object", + "required": ["name", "keymap"], + "properties": { + "name": { + "type": "string", + "description": "Layer identifier, unique within the MAP. Layer order is the array order" + }, + "keymap": { + "type": "array", + "items": { + "$ref": "#/$defs/keyAssignment" + } + } + } + }, + "keyAssignment": { + "type": "object", + "required": ["keyId", "keycode"], + "properties": { + "keyId": { + "type": "string", + "description": "Reference to PHY layer key ID" + }, + "keycode": { "type": "string" } + } + } + } +} diff --git a/mock/src/io/schemas/phy.schema.json b/mock/src/io/schemas/phy.schema.json new file mode 100644 index 0000000..4910f09 --- /dev/null +++ b/mock/src/io/schemas/phy.schema.json @@ -0,0 +1,253 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://beyond-kle.org/schemas/phy-layer.json", + "title": "PHY Layer - Physical Key Layout", + "description": "Physical positioning and sizing of keys", + "type": "object", + "required": ["version", "metadata", "profiles", "root"], + "properties": { + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Schema version (semver)" + }, + "metadata": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "author": { "type": "string" }, + "description": { "type": "string" }, + "created": { "type": "string", "format": "date-time" }, + "modified": { "type": "string", "format": "date-time" } + } + }, + "kleCompatibility": { + "type": "object", + "description": "File-level opaque KLE fields kept for round-trip output (switchMount, switchBrand, switchType, backcolor, radii, background, css). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6", + "additionalProperties": true + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/profile" + } + }, + "root": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/key" }, + { "$ref": "#/$defs/cluster" }, + { "$ref": "#/$defs/component" }, + { "$ref": "#/$defs/variantGroup" } + ] + } + } + }, + "$defs": { + "transform": { + "type": "object", + "description": "Placement relative to the parent: rotate by rotation around the local origin, then translate by (x, y)", + "required": ["x", "y", "rotation"], + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "rotation": { "type": "number", "description": "Degrees" } + } + }, + "profile": { + "type": "object", + "required": ["id", "name", "unitSize"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "unitSize": { + "type": "object", + "description": "Millimeters per 1U", + "required": ["x", "y"], + "properties": { + "x": { "type": "number", "minimum": 0 }, + "y": { "type": "number", "minimum": 0 } + } + }, + "visualOffset": { + "type": "object", + "description": "Key top rectangle, in millimeters: inward distance from each edge of the outline, plus corner radius", + "required": ["top", "right", "bottom", "left", "radius"], + "properties": { + "top": { "type": "number" }, + "right": { "type": "number" }, + "bottom": { "type": "number" }, + "left": { "type": "number" }, + "radius": { "type": "number", "minimum": 0 } + } + } + } + }, + "key": { + "type": "object", + "required": ["type", "id", "profile", "transform", "size"], + "properties": { + "type": { + "type": "string", + "const": "key" + }, + "id": { + "type": "string", + "description": "Unique key identifier" + }, + "profile": { + "type": "string", + "description": "Reference to profile ID" + }, + "transform": { "$ref": "#/$defs/transform" }, + "size": { + "type": "object", + "description": "Local size. Not affected by the parent transform", + "required": ["w", "h"], + "properties": { + "w": { "type": "number", "minimum": 0 }, + "h": { "type": "number", "minimum": 0 }, + "x2": { + "type": "number", + "description": "Second rectangle for non-rectangular keys, relative to the local origin" + }, + "y2": { "type": "number" }, + "w2": { "type": "number", "minimum": 0 }, + "h2": { "type": "number", "minimum": 0 } + } + }, + "switchOffset": { + "type": "object", + "description": "Switch position, offset from the center of the (w, h) rectangle. Key local coordinates, unit U. Defaults to 0", + "required": ["x", "y"], + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" } + } + }, + "partGroup": { + "type": "string", + "description": "Label grouping keys that use the same part. Not interpreted by Beyond KLE" + }, + "orientation": { + "type": "integer", + "enum": [0, 90, 180, 270], + "default": 0, + "description": "Switch orientation in the key local coordinates" + }, + "perKeyLED": { + "type": "boolean", + "default": false, + "description": "Whether a per-key LED is mounted" + }, + "decal": { "type": "boolean", "default": false }, + "ghost": { "type": "boolean", "default": false }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Reference information. Used for matching on KLE JSON import" + }, + "kleCompatibility": { + "type": "object", + "description": "Opaque KLE fields kept for round-trip output (c, t, p, a, f, f2, fa, l, n, sm, sb, st). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6", + "additionalProperties": true + } + } + }, + "component": { + "type": "object", + "description": "Non-key part placed by the designer (LED, OLED, encoder, screw hole, ...)", + "required": ["type", "id", "transform"], + "properties": { + "type": { + "type": "string", + "const": "component" + }, + "id": { "type": "string" }, + "transform": { "$ref": "#/$defs/transform" }, + "partGroup": { + "type": "string", + "description": "Names starting with @ are reserved for editor rendering" + } + } + }, + "variantGroup": { + "type": "object", + "description": "Mutually exclusive layout alternatives. Children are variants only", + "required": ["type", "id", "name", "transform", "default", "children"], + "properties": { + "type": { + "type": "string", + "const": "variantGroup" + }, + "id": { "type": "string" }, + "name": { + "type": "string", + "description": "Heading shown for the choice in the editor" + }, + "transform": { "$ref": "#/$defs/transform" }, + "default": { + "type": "string", + "description": "id of the variant used when a single layout must be chosen" + }, + "children": { + "type": "array", + "minItems": 2, + "description": "Display order", + "items": { "$ref": "#/$defs/variant" } + } + } + }, + "variant": { + "type": "object", + "description": "One alternative. Appears only directly under a variantGroup. Carries no transform", + "required": ["type", "id", "name", "children"], + "properties": { + "type": { + "type": "string", + "const": "variant" + }, + "id": { "type": "string" }, + "name": { "type": "string" }, + "children": { + "type": "array", + "description": "May be empty", + "items": { + "oneOf": [ + { "$ref": "#/$defs/key" }, + { "$ref": "#/$defs/cluster" }, + { "$ref": "#/$defs/component" }, + { "$ref": "#/$defs/variantGroup" } + ] + } + } + } + }, + "cluster": { + "type": "object", + "required": ["type", "id", "transform", "children"], + "properties": { + "type": { + "type": "string", + "const": "cluster" + }, + "id": { "type": "string" }, + "name": { "type": "string" }, + "transform": { "$ref": "#/$defs/transform" }, + "children": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/key" }, + { "$ref": "#/$defs/cluster" }, + { "$ref": "#/$defs/component" }, + { "$ref": "#/$defs/variantGroup" } + ] + } + } + } + } + } +} diff --git a/mock/src/io/validateSchema.ts b/mock/src/io/validateSchema.ts new file mode 100644 index 0000000..7a0ca02 --- /dev/null +++ b/mock/src/io/validateSchema.ts @@ -0,0 +1,27 @@ +import Ajv2020 from "ajv/dist/2020"; +import addFormats from "ajv-formats"; +import phySchema from "./schemas/phy.schema.json"; +import idxSchema from "./schemas/idx.schema.json"; +import elcSchema from "./schemas/elc.schema.json"; +import mapSchema from "./schemas/map.schema.json"; + +export type LayerName = "phy" | "idx" | "elc" | "map"; +export type SchemaIssue = { path: string; message: string }; +const ajv = new Ajv2020({ allErrors: true, strict: false }); +addFormats(ajv); +const validators = { + phy: ajv.compile(phySchema), + idx: ajv.compile(idxSchema), + elc: ajv.compile(elcSchema), + map: ajv.compile(mapSchema), +}; +/** Validates one layer against its draft 2020-12 schema. */ +export function validateSchema(layer: LayerName, value: unknown): SchemaIssue[] { + const valid = validators[layer](value); + return valid + ? [] + : (validators[layer].errors ?? []).map((e) => ({ + path: `${e.instancePath || "/"}${e.params && "missingProperty" in e.params ? `/${e.params.missingProperty}` : ""}`, + message: e.message ?? "Schema validation failed", + })); +} diff --git a/mock/src/main.ts b/mock/src/main.ts new file mode 100644 index 0000000..c8efb7e --- /dev/null +++ b/mock/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from "vue"; +import App from "./ui/App.vue"; + +createApp(App).mount("#app"); diff --git a/mock/src/ui/App.vue b/mock/src/ui/App.vue new file mode 100644 index 0000000..fada593 --- /dev/null +++ b/mock/src/ui/App.vue @@ -0,0 +1,904 @@ + + + + + diff --git a/mock/src/ui/common/KeyboardView.vue b/mock/src/ui/common/KeyboardView.vue new file mode 100644 index 0000000..b120287 --- /dev/null +++ b/mock/src/ui/common/KeyboardView.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/mock/src/ui/defaultLayout.ts b/mock/src/ui/defaultLayout.ts new file mode 100644 index 0000000..7455b9a --- /dev/null +++ b/mock/src/ui/defaultLayout.ts @@ -0,0 +1,2 @@ +/** Small 4x4 starter layout, kept in src/ui rather than the test fixtures. */ +export const defaultLayout: unknown[] = Array.from({ length: 4 }, () => ["", "", "", ""]); diff --git a/mock/src/ui/panels/ElcPanel.vue b/mock/src/ui/panels/ElcPanel.vue new file mode 100644 index 0000000..3ae2b10 --- /dev/null +++ b/mock/src/ui/panels/ElcPanel.vue @@ -0,0 +1,564 @@ + + + + + diff --git a/mock/src/ui/panels/GeneratorPanel.vue b/mock/src/ui/panels/GeneratorPanel.vue new file mode 100644 index 0000000..79ba481 --- /dev/null +++ b/mock/src/ui/panels/GeneratorPanel.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/mock/src/ui/panels/IdxPanel.vue b/mock/src/ui/panels/IdxPanel.vue new file mode 100644 index 0000000..57da517 --- /dev/null +++ b/mock/src/ui/panels/IdxPanel.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/mock/src/ui/panels/MapPanel.vue b/mock/src/ui/panels/MapPanel.vue new file mode 100644 index 0000000..e784ae6 --- /dev/null +++ b/mock/src/ui/panels/MapPanel.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/mock/src/ui/phy/PhyCanvas.vue b/mock/src/ui/phy/PhyCanvas.vue new file mode 100644 index 0000000..5be05c8 --- /dev/null +++ b/mock/src/ui/phy/PhyCanvas.vue @@ -0,0 +1,35 @@ + + diff --git a/mock/src/ui/phy/PhyNodeView.vue b/mock/src/ui/phy/PhyNodeView.vue new file mode 100644 index 0000000..b4f4fec --- /dev/null +++ b/mock/src/ui/phy/PhyNodeView.vue @@ -0,0 +1,126 @@ + + diff --git a/mock/src/ui/phy/PhyTree.vue b/mock/src/ui/phy/PhyTree.vue new file mode 100644 index 0000000..3d76fe6 --- /dev/null +++ b/mock/src/ui/phy/PhyTree.vue @@ -0,0 +1,54 @@ + + diff --git a/mock/src/ui/phy/dragMath.ts b/mock/src/ui/phy/dragMath.ts new file mode 100644 index 0000000..23478ac --- /dev/null +++ b/mock/src/ui/phy/dragMath.ts @@ -0,0 +1,13 @@ +export type Point = { x: number; y: number }; +export function snapFromStart(_start: number, delta: number, step = 0.25): number { + return Math.round(delta / step) * step; +} +export function parentPoint(parent: SVGGraphicsElement, clientX: number, clientY: number): Point { + const matrix = parent.getScreenCTM(); + if (!matrix) throw new Error("SVG parent has no screen transform"); + const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse()); + return { x: point.x, y: point.y }; +} +export function snappedDelta(start: Point, current: Point): Point { + return { x: snapFromStart(0, current.x - start.x), y: snapFromStart(0, current.y - start.y) }; +} diff --git a/mock/src/ui/types.ts b/mock/src/ui/types.ts new file mode 100644 index 0000000..cbb829a --- /dev/null +++ b/mock/src/ui/types.ts @@ -0,0 +1,2 @@ +import type { ProjectFile } from "../domain/project"; +export type Dispatch = (label: string, update: (project: ProjectFile) => ProjectFile) => void; diff --git a/mock/tests/domain.test.ts b/mock/tests/domain.test.ts new file mode 100644 index 0000000..56e9033 --- /dev/null +++ b/mock/tests/domain.test.ts @@ -0,0 +1,265 @@ +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("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); + }); +}); diff --git a/mock/tests/dragMath.test.ts b/mock/tests/dragMath.test.ts new file mode 100644 index 0000000..23c16b7 --- /dev/null +++ b/mock/tests/dragMath.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { snapFromStart, snappedDelta } from "../src/ui/phy/dragMath"; + +describe("PHY drag math", () => { + it("snaps from the operation start in quarter-unit increments", () => { + expect(snapFromStart(0.1, 0.28)).toBeCloseTo(0.25); + expect(snapFromStart(0.1, 0.38)).toBeCloseTo(0.5); + }); + it("returns snapped local deltas", () => { + expect(snappedDelta({ x: 1, y: 1 }, { x: 1.37, y: 0.61 })).toEqual({ x: 0.25, y: -0.5 }); + }); +}); diff --git a/mock/tests/elcMatrix.test.ts b/mock/tests/elcMatrix.test.ts new file mode 100644 index 0000000..21e4215 --- /dev/null +++ b/mock/tests/elcMatrix.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { createElc } from "../src/domain/elc"; +import { wireMatrixKeys } from "../src/domain/elcMatrix"; + +describe("wireMatrixKeys", () => { + it("assigns row-major pins and diode without mutating source", () => { + const source = createElc({ source: "test", checksum: "" }); + const next = wireMatrixKeys( + source, + ["k1", "k2", "k3"], + ["MCU.GP0", "MCU.GP1"], + ["MCU.GP2", "MCU.GP3"], + "a-to-b", + ); + expect(next.keys.map((w) => w.detect === "contact" && [w.terminals.a, w.terminals.b, w.diode])).toEqual([ + ["MCU.GP0", "MCU.GP2", "a-to-b"], + ["MCU.GP0", "MCU.GP3", "a-to-b"], + ["MCU.GP1", "MCU.GP2", "a-to-b"], + ]); + expect(source.keys).toEqual([]); + }); + it("rejects an undersized matrix", () => { + expect(() => + wireMatrixKeys(createElc({ source: "test", checksum: "" }), ["a", "b"], ["R"], ["C"]), + ).toThrow(); + }); + it("preserves extension fields when updating existing wiring", () => { + const source = { + ...createElc({ source: "test", checksum: "" }), + keys: [ + { + key: "k1", + detect: "contact" as const, + terminals: { a: "OLD.R", b: "OLD.C" }, + extension: "keep", + }, + ], + }; + const next = wireMatrixKeys(source, ["k1"], ["R"], ["C"]); + expect(next.keys[0]).toMatchObject({ extension: "keep", terminals: { a: "R", b: "C" } }); + }); +}); diff --git a/mock/tests/fixtures/kle-oracle.json b/mock/tests/fixtures/kle-oracle.json new file mode 100644 index 0000000..71b7bbc --- /dev/null +++ b/mock/tests/fixtures/kle-oracle.json @@ -0,0 +1,5967 @@ +{ + "macropad-4x4": { + "input": [ + ["7", "8", "9", "/"], + ["4", "5", "6", "*"], + ["1", "2", "3", "-"], + ["0", ".", "Enter", "+"] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["9"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["/"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["*"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["2"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["-"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["."], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Enter"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["+"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "cr0wn60": { + "input": [ + [ + { + "p": "DCS R1" + }, + "Esc", + { + "x": 0.25 + }, + "!\n1", + "@\n2", + "#\n3", + "$\n4", + "%\n5", + { + "x": 1 + }, + "^\n6", + "&\n7", + "*\n8", + "(\n9", + ")\n0", + "_\n-", + { + "x": 0.75 + }, + "+\n=" + ], + [ + { + "p": "DCS R2", + "a": 0, + "w": 1.5 + }, + "Tab\n\n\n\n\n1.5", + { + "a": 4 + }, + "Q", + "W", + "E", + "R", + "T", + { + "x": 0.5 + }, + "Y", + "U", + "I", + "O", + "P", + "{\n[", + { + "x": 0.5, + "a": 0, + "w": 1.5 + }, + "Backspace\n\n\n\n\n1.5" + ], + [ + { + "p": "DCS R3", + "w": 1.75 + }, + "Ctrl\n\n\n\n\n1.75", + { + "a": 4 + }, + "A", + "S", + "D", + "F", + "G", + "H", + "J", + "K", + "L", + ":\n;", + "\"\n'", + { + "a": 0, + "w": 2.25 + }, + "Enter\n\n\n\n\n2.25" + ], + [ + { + "p": "DCS R4", + "w": 1.25 + }, + "Shift\n\n\n\n\n1.25", + { + "x": 0.25, + "a": 4 + }, + "Z", + "X", + "C", + "V", + "B", + { + "x": 0.5 + }, + "N", + "M", + "<\n,", + ">\n.", + "?\n/", + "|\n\\", + { + "x": 0.25, + "a": 0, + "w": 1.75 + }, + "Shift\n\n\n\n\n1.75" + ], + [ + { + "x": 1.25, + "w": 1.25 + }, + "Win\n\n\n\n\n1.25", + { + "w": 1.25 + }, + "Alt\n\n\n\n\n1.25", + { + "x": 6.5, + "w": 1.25 + }, + "Alt\n\n\n\n\n1.25", + { + "w": 1.25 + }, + "Ctrl\n\n\n\n\n1.25" + ], + [ + { + "r": 180, + "rx": 4.5, + "ry": 4.5, + "y": -0.5, + "x": -0.25, + "a": 4 + }, + "\n\n\nFn" + ], + [ + { + "rx": 6.75, + "y": -0.5, + "x": -2.5, + "p": "DCS SPACE", + "a": 3, + "w": 2.25 + }, + "\n\n\n\n\n2.25", + { + "w": 2.25 + }, + "\n\n\n\n\n2.25" + ], + [ + { + "rx": 9.5, + "y": -0.5, + "x": -0.75, + "p": "DCS R4", + "a": 4 + }, + "\n\n\nCode" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Esc"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["!", null, null, null, null, null, "1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["@", null, null, null, null, null, "2"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["#", null, null, null, null, null, "3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["$", null, null, null, null, null, "4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["%", null, null, null, null, null, "5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 7.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["^", null, null, null, null, null, "6"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["&", null, null, null, null, null, "7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 9.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["*", null, null, null, null, null, "8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["(", null, null, null, null, null, "9"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [")", null, null, null, null, null, "0"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 12.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["_", null, null, null, null, null, "-"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 14, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["+", null, null, null, null, null, "="], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1.5, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.5, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Tab", null, "", null, null, null, "", null, "", "", null, "1.5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Q"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["W"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["E"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["R"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["T"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 7, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Y"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["U"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 9, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["I"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["O"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["P"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 12, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["{", null, null, null, null, null, "["], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 13.5, + "y": 1, + "width": 1.5, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.5, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Backspace", null, "", null, null, null, "", null, "", "", null, "1.5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 2, + "width": 1.75, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.75, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Ctrl", null, "", null, null, null, "", null, "", "", null, "1.75"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["A"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["S"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["D"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["F"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["G"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 6.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["H"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 7.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["J"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["K"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 9.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["L"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [":", null, null, null, null, null, ";"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["\"", null, null, null, null, null, "'"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 12.75, + "y": 2, + "width": 2.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 2.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Enter", null, "", null, null, null, "", null, "", "", null, "2.25"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 3, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Shift", null, "", null, null, null, "", null, "", "", null, "1.25"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Z"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["X"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["C"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["V"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["B"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 7, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["N"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["M"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 9, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["<", null, null, null, null, null, ","], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [">", null, null, null, null, null, "."], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["?", null, null, null, null, null, "/"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 12, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["|", null, null, null, null, null, "\\"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 13.25, + "y": 3, + "width": 1.75, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.75, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Shift", null, "", null, null, null, "", null, "", "", null, "1.75"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.25, + "y": 4, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Win", null, "", null, null, null, "", null, "", "", null, "1.25"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.5, + "y": 4, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Alt", null, "", null, null, null, "", null, "", "", null, "1.25"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10.25, + "y": 4, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Alt", null, "", null, null, null, "", null, "", "", null, "1.25"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11.5, + "y": 4, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Ctrl", null, "", null, null, null, "", null, "", "", null, "1.25"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.25, + "y": 4, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 180, + "rotation_x": 4.5, + "rotation_y": 4.5, + "labels": ["", null, "", null, null, null, "", null, "Fn"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.25, + "y": 4, + "width": 2.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 2.25, + "height2": 1, + "rotation_angle": 180, + "rotation_x": 6.75, + "rotation_y": 4.5, + "labels": [null, null, null, null, "", null, null, null, null, "", null, "2.25"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS SPACE", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 6.5, + "y": 4, + "width": 2.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 2.25, + "height2": 1, + "rotation_angle": 180, + "rotation_x": 6.75, + "rotation_y": 4.5, + "labels": [null, null, null, null, "", null, null, null, null, "", null, "2.25"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS SPACE", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8.75, + "y": 4, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 180, + "rotation_x": 9.5, + "rotation_y": 4.5, + "labels": ["", null, "", null, null, null, "", null, "Code"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "cr0wn60-via": { + "input": [ + [ + { + "p": "DCS R1" + }, + "1,0", + { + "x": 0.25 + }, + "2,0", + "3,0", + "4,0", + "5,0", + "6,0", + { + "x": 1 + }, + "0,1", + "2,1", + "3,1", + "4,1", + "5,1", + "6,1", + { + "x": 0.75 + }, + "7,1" + ], + [ + { + "p": "DCS R2", + "a": 0, + "w": 1.5 + }, + "0,2", + { + "a": 4 + }, + "1,2", + "3,2", + "4,2", + "5,2", + "6,2", + { + "x": 0.5 + }, + "0,3", + "1,3", + "2,3", + "4,3", + "5,3", + "6,3", + { + "x": 0.5, + "a": 0, + "w": 1.5 + }, + "7,3" + ], + [ + { + "p": "DCS R3", + "w": 1.75 + }, + "0,4", + { + "a": 4 + }, + "1,4", + "2,4", + "3,4", + "5,4", + "6,4", + "0,5", + "1,5", + "2,5", + "3,5", + "4,5", + "6,5", + { + "a": 0, + "w": 2.25 + }, + "7,5" + ], + [ + { + "p": "DCS R4", + "w": 1.25 + }, + "0,6", + { + "x": 0.25, + "a": 4 + }, + "1,6", + "2,6", + "3,6", + "4,6", + "5,6", + { + "x": 0.5 + }, + "0,7", + "1,7", + "2,7", + "3,7", + "4,7", + "5,7", + { + "x": 0.25, + "a": 0, + "w": 1.75 + }, + "6,7" + ], + [ + { + "x": 1.25, + "w": 1.25 + }, + "0,8", + { + "w": 1.25 + }, + "1,8", + { + "x": 6.5, + "w": 1.25 + }, + "6,8", + { + "w": 1.25 + }, + "7,8" + ], + [ + { + "r": 180, + "rx": 4.5, + "ry": 4.5, + "y": -0.5, + "x": -0.25, + "a": 4 + }, + "2,8" + ], + [ + { + "rx": 6.75, + "y": -0.5, + "x": -2.5, + "p": "DCS SPACE", + "a": 3, + "w": 2.25 + }, + "4,8", + { + "w": 2.25 + }, + "3,8" + ], + [ + { + "rx": 9.5, + "y": -0.5, + "x": -0.75, + "p": "DCS R4", + "a": 4 + }, + "5,8" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1,0"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["2,0"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["3,0"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["4,0"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["5,0"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6,0"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 7.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0,1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["2,1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 9.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["3,1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["4,1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["5,1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 12.25, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6,1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 14, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["7,1"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R1", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1.5, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.5, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0,2"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1,2"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["3,2"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["4,2"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["5,2"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5.5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6,2"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 7, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0,3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1,3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 9, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["2,3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["4,3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["5,3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 12, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6,3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 13.5, + "y": 1, + "width": 1.5, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.5, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["7,3"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R2", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 2, + "width": 1.75, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.75, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0,4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1,4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["2,4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["3,4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["5,4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6,4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 6.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0,5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 7.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1,5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["2,5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 9.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["3,5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["4,5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11.75, + "y": 2, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6,5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 12.75, + "y": 2, + "width": 2.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 2.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["7,5"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R3", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 3, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0,6"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1,6"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["2,6"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["3,6"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["4,6"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5.5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["5,6"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 7, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0,7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1,7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 9, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["2,7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["3,7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["4,7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 12, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["5,7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 13.25, + "y": 3, + "width": 1.75, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.75, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6,7"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1.25, + "y": 4, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0,8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.5, + "y": 4, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["1,8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 10.25, + "y": 4, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["6,8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 11.5, + "y": 4, + "width": 1.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.25, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["7,8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.25, + "y": 4, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 180, + "rotation_x": 4.5, + "rotation_y": 4.5, + "labels": ["2,8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4.25, + "y": 4, + "width": 2.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 2.25, + "height2": 1, + "rotation_angle": 180, + "rotation_x": 6.75, + "rotation_y": 4.5, + "labels": [null, null, null, null, "4,8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS SPACE", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 6.5, + "y": 4, + "width": 2.25, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 2.25, + "height2": 1, + "rotation_angle": 180, + "rotation_x": 6.75, + "rotation_y": 4.5, + "labels": [null, null, null, null, "3,8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS SPACE", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 8.75, + "y": 4, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 180, + "rotation_x": 9.5, + "rotation_y": 4.5, + "labels": ["5,8"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "DCS R4", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "align-0": { + "input": [ + [ + { + "a": 0 + }, + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11" + ], + [ + { + "a": 0, + "t": "#111111\n#222222\n#333333" + }, + "A\nB\nC" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0", "8", "2", "6", "9", "7", "1", "10", "3", "4", "11", "5"], + "textColor": [], + "textSize": [], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["A", null, "C", null, null, null, "B"], + "textColor": [null, null, "#333333", null, null, null, "#222222", null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#111111", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "align-1": { + "input": [ + [ + { + "a": 1 + }, + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11" + ], + [ + { + "a": 1, + "t": "#111111\n#222222\n#333333" + }, + "A\nB\nC" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, "0", null, null, "6", null, null, "1", null, "4", "11", "5"], + "textColor": [null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, "A", null, null, null, null, null, "B"], + "textColor": [null, null, null, null, null, null, null, "#222222", null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#111111", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "align-2": { + "input": [ + [ + { + "a": 2 + }, + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11" + ], + [ + { + "a": 2, + "t": "#111111\n#222222\n#333333" + }, + "A\nB\nC" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, null, null, "0", "8", "2", null, null, null, "4", "11", "5"], + "textColor": [null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, null, null, "A", null, "C"], + "textColor": [null, null, null, null, null, "#333333", null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#111111", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "align-3": { + "input": [ + [ + { + "a": 3 + }, + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11" + ], + [ + { + "a": 3, + "t": "#111111\n#222222\n#333333" + }, + "A\nB\nC" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, null, null, null, "0", null, null, null, null, "4", "11", "5"], + "textColor": [null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, null, null, null, "A"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#111111", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "align-4": { + "input": [ + [ + { + "a": 4 + }, + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11" + ], + [ + { + "a": 4, + "t": "#111111\n#222222\n#333333" + }, + "A\nB\nC" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["0", "8", "2", "6", "9", "7", "1", "10", "3", null, "4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["A", null, "C", null, null, null, "B"], + "textColor": [null, null, "#333333", null, null, null, "#222222", null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#111111", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "align-5": { + "input": [ + [ + { + "a": 5 + }, + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11" + ], + [ + { + "a": 5, + "t": "#111111\n#222222\n#333333" + }, + "A\nB\nC" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, "0", null, null, "6", null, null, "1", null, null, "4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, "A", null, null, null, null, null, "B"], + "textColor": [null, null, null, null, null, null, null, "#222222", null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#111111", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "align-6": { + "input": [ + [ + { + "a": 6 + }, + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11" + ], + [ + { + "a": 6, + "t": "#111111\n#222222\n#333333" + }, + "A\nB\nC" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, null, null, "0", "8", "2", null, null, null, null, "4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, null, null, "A", null, "C"], + "textColor": [null, null, null, null, null, "#333333", null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#111111", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "align-7": { + "input": [ + [ + { + "a": 7 + }, + "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11" + ], + [ + { + "a": 7, + "t": "#111111\n#222222\n#333333" + }, + "A\nB\nC" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, null, null, null, "0", null, null, null, null, null, "4"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": [null, null, null, null, "A"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#111111", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "rotation-multirow": { + "input": [ + [ + { + "r": 90, + "rx": 2, + "ry": 3 + }, + "A" + ], + ["B", "C"], + [ + { + "r": -30, + "rx": 5 + }, + "D" + ], + [ + { + "y": 0.5 + }, + "E" + ], + [ + { + "ry": 1, + "r": 15 + }, + "F" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 2, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 90, + "rotation_x": 2, + "rotation_y": 3, + "labels": ["A"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2, + "y": 4, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 90, + "rotation_x": 2, + "rotation_y": 3, + "labels": ["B"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3, + "y": 4, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 90, + "rotation_x": 2, + "rotation_y": 3, + "labels": ["C"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5, + "y": 3, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": -30, + "rotation_x": 5, + "rotation_y": 3, + "labels": ["D"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5, + "y": 4.5, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": -30, + "rotation_x": 5, + "rotation_y": 3, + "labels": ["E"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 5, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 15, + "rotation_x": 5, + "rotation_y": 1, + "labels": ["F"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "rotation-midrow": { + "input": [ + [ + "A", + { + "r": 30, + "rx": 1, + "ry": 1 + }, + "B", + "C" + ], + [ + { + "rx": 4 + }, + "D" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["A"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 30, + "rotation_x": 1, + "rotation_y": 1, + "labels": ["B"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 30, + "rotation_x": 1, + "rotation_y": 1, + "labels": ["C"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 4, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 30, + "rotation_x": 4, + "rotation_y": 1, + "labels": ["D"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "flags": { + "input": [ + [ + { + "g": true + }, + "A", + "B", + { + "g": false + }, + "C" + ], + [ + { + "l": true, + "n": true, + "d": true + }, + "D", + "E" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["A"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": true, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["B"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": true, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["C"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["D"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": true, + "stepped": true, + "decal": true, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["E"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "text-attrs": { + "input": [ + [ + { + "f": 5, + "f2": 2 + }, + "A\nB", + { + "fa": [1, 2, 3] + }, + "C\nD", + { + "f": 4 + }, + "E\nF" + ], + [ + { + "c": "#ff0000", + "p": "SA", + "sm": "cherry", + "sb": "gateron", + "st": "Gateron Red" + }, + "G", + "H" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["A", null, null, null, null, null, "B"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, 2, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 5 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 1, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["C", null, null, null, null, null, "D"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [1, null, null, null, null, null, 2, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 5 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["E", null, null, null, null, null, "F"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 4 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 0, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["G"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 4 + }, + "color": "#ff0000", + "profile": "SA", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "cherry", + "sb": "gateron", + "st": "Gateron Red" + }, + { + "x": 1, + "y": 1, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["H"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 4 + }, + "color": "#ff0000", + "profile": "SA", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "cherry", + "sb": "gateron", + "st": "Gateron Red" + } + ] + }, + "second-rect": { + "input": [ + [ + { + "w": 2, + "h": 1.5, + "w2": 0, + "h2": 0 + }, + "A", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "Enter", + { + "w": 1.5, + "h2": 2 + }, + "B" + ] + ], + "meta": { + "backcolor": "#eeeeee", + "name": "", + "author": "", + "notes": "", + "background": null, + "radii": "", + "switchMount": "", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 2, + "height": 1.5, + "x2": 0, + "y2": 0, + "width2": 2, + "height2": 1.5, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["A"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 2.25, + "y": 0, + "width": 1.25, + "height": 2, + "x2": -0.25, + "y2": 0, + "width2": 1.5, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["Enter"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + }, + { + "x": 3.5, + "y": 0, + "width": 1.5, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1.5, + "height2": 2, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["B"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + }, + "meta": { + "input": [ + { + "name": "M", + "author": "x", + "backcolor": "#222", + "radii": "6px", + "switchMount": "alps" + }, + ["A"] + ], + "meta": { + "backcolor": "#222", + "name": "M", + "author": "x", + "notes": "", + "background": null, + "radii": "6px", + "switchMount": "alps", + "switchBrand": "", + "switchType": "" + }, + "keys": [ + { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "x2": 0, + "y2": 0, + "width2": 1, + "height2": 1, + "rotation_angle": 0, + "rotation_x": 0, + "rotation_y": 0, + "labels": ["A"], + "textColor": [null, null, null, null, null, null, null, null, null, null, null, null], + "textSize": [null, null, null, null, null, null, null, null, null, null, null, null], + "default": { + "textColor": "#000000", + "textSize": 3 + }, + "color": "#cccccc", + "profile": "", + "nub": false, + "stepped": false, + "decal": false, + "ghost": false, + "sm": "", + "sb": "", + "st": "" + } + ] + } +} diff --git a/mock/tests/kle-oracle.test.ts b/mock/tests/kle-oracle.test.ts new file mode 100644 index 0000000..d4905dc --- /dev/null +++ b/mock/tests/kle-oracle.test.ts @@ -0,0 +1,374 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { KeyNode, PhyFile } from "../src/domain/phy"; +import { flattenKeys } from "../src/domain/geometry"; +import { clusterNodes, reframe, reparent } from "../src/domain/phyCommands"; +import { importKle } from "../src/io/kleImport"; +import { exportKle } from "../src/io/kleExport"; +import { createIdx } from "../src/domain/idx"; + +type OracleKey = { + x: number; + y: number; + width: number; + height: number; + x2: number; + y2: number; + width2: number; + height2: number; + rotation_angle: number; + rotation_x: number; + rotation_y: number; + labels: (string | null)[]; + textColor: (string | null)[]; + textSize: (number | null)[]; + default: { textColor: string; textSize: number }; + color: string; + profile: string; + nub: boolean; + stepped: boolean; + decal: boolean; + ghost: boolean; + sm: string; + sb: string; + st: string; +}; +type Case = { input: unknown[]; meta: Record; keys: OracleKey[] }; +const oracle = JSON.parse( + readFileSync(resolve(process.cwd(), "tests/fixtures/kle-oracle.json"), "utf8"), +) as Record; +// Independent expected lookup derived from the frozen serial.js output fixture. +const labelMap: readonly (readonly (number | null)[])[] = [ + [0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5], + [null, 0, null, null, 6, null, null, 1, null, 4, 11, 5], + [null, null, null, 0, 8, 2, null, null, null, 4, 11, 5], + [null, null, null, null, 0, null, null, null, null, 4, 11, 5], + [0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null], + [null, 0, null, null, 6, null, null, 1, null, null, 4, null], + [null, null, null, 0, 8, 2, null, null, null, null, 4, null], + [null, null, null, null, 0, null, null, null, null, null, 4, null], +]; +const uuid = (() => { + let i = 0; + return () => `12345678-90ab-4cde-8f01-${String(++i).padStart(12, "0")}`; +})(); +const angleRad = (degrees: number) => (degrees * Math.PI) / 180; +const near = (actual: number, expected: number, tolerance = 1e-9) => + expect(Math.abs(actual - expected), `${actual} differs from ${expected}`).toBeLessThanOrEqual(tolerance); +function canonicalLabels(labels: (string | null)[]): string[] { + const result = labels.map((label) => label ?? ""); + while (result.at(-1) === "") result.pop(); + return result; +} +function oracleOrigin(k: OracleKey): { x: number; y: number } { + const r = angleRad(k.rotation_angle), + dx = k.x - k.rotation_x, + dy = k.y - k.rotation_y; + return { + x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy, + y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy, + }; +} +function expectedCorners(k: OracleKey, second = false): { x: number; y: number }[] { + const r = angleRad(k.rotation_angle), + ox = k.x + (second ? k.x2 : 0), + oy = k.y + (second ? k.y2 : 0); + const w = second ? k.width2 : k.width, + h = second ? k.height2 : k.height; + return [ + [0, 0], + [w, 0], + [w, h], + [0, h], + ].map(([x, y]) => { + const px = ox + x!, + py = oy + y!, + dx = px - k.rotation_x, + dy = py - k.rotation_y; + return { + x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy, + y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy, + }; + }); +} +function keyCorners(k: KeyNode, second = false): { x: number; y: number }[] { + const r = angleRad(k.transform.rotation), + ox = second ? (k.size.x2 ?? 0) : 0, + oy = second ? (k.size.y2 ?? 0) : 0; + const w = second ? (k.size.w2 ?? k.size.w) : k.size.w, + h = second ? (k.size.h2 ?? k.size.h) : k.size.h; + return [ + [0, 0], + [w, 0], + [w, h], + [0, h], + ].map(([x, y]) => ({ + x: k.transform.x + Math.cos(r) * (ox + x!) - Math.sin(r) * (oy + y!), + y: k.transform.y + Math.sin(r) * (ox + x!) + Math.cos(r) * (oy + y!), + })); +} +function expectedStyleArrays(k: OracleKey, key: KeyNode): void { + const compat = key.kleCompatibility ?? {}, + a = typeof compat.a === "number" ? compat.a : 4; + const slots = labelMap[a] ?? labelMap[4]!; + const t = typeof compat.t === "string" ? compat.t.split("\n") : []; + expect(compat.c).toBe(k.color); + expect(compat.p).toBe(k.profile); + expect(compat.sm).toBe(k.sm); + expect(compat.sb).toBe(k.sb); + expect(compat.st).toBe(k.st); + expect(compat.l === true).toBe(k.stepped); + expect(compat.n === true).toBe(k.nub); + expect(key.decal === true).toBe(k.decal); + expect(key.ghost === true).toBe(k.ghost); + expect(compat.f).toBe(k.default.textSize); + const defaultTextColor = t[0] ?? "#000000"; + expect(k.default.textColor).toBe(defaultTextColor); + const expectedColors = Array(12).fill(null), + expectedSizes = Array(12).fill(null); + slots.forEach((source, slot) => { + const hasLabel = (key.labels?.[slot] ?? "") !== ""; + if (hasLabel && source !== null && source > 0 && source < t.length) expectedColors[slot] = t[source]!; + if (hasLabel && source !== null && source >= 0) { + if (Array.isArray(compat.fa) && source < compat.fa.length && typeof compat.fa[source] === "number") + expectedSizes[slot] = compat.fa[source] as number; + else if (source > 0 && typeof compat.f2 === "number") expectedSizes[slot] = compat.f2; + } + }); + const oracleColors = Array.from({ length: 12 }, (_, i) => k.textColor[i] ?? null); + const oracleSizes = Array.from({ length: 12 }, (_, i) => k.textSize[i] ?? null); + expect(expectedColors).toEqual(oracleColors); + expect(expectedSizes).toEqual(oracleSizes); + for (let i = 0; i < 12; i++) { + expect(expectedColors[i]).toBe(oracleColors[i]); + expect(expectedSizes[i]).toBe(oracleSizes[i]); + } +} +function verify(phy: PhyFile, expected: Case, tolerance = 1e-9): void { + const keys = flattenKeys(phy).map((x) => x.key); + expect(keys).toHaveLength(expected.keys.length); + expect(phy.kleCompatibility).toEqual(expected.meta); + const unmatched = [...keys]; + for (const want of expected.keys) { + const origin = oracleOrigin(want), + labels = canonicalLabels(want.labels); + const index = unmatched.findIndex( + (candidate) => + JSON.stringify(candidate.labels ?? []) === JSON.stringify(labels) && + Math.abs(candidate.transform.x - origin.x) < 1e-6 && + Math.abs(candidate.transform.y - origin.y) < 1e-6, + ); + expect( + index, + `missing oracle key labels=${JSON.stringify(labels)} at ${origin.x},${origin.y}`, + ).toBeGreaterThanOrEqual(0); + const actual = unmatched.splice(index, 1)[0]!; + near(actual.transform.x, origin.x, tolerance); + near(actual.transform.y, origin.y, tolerance); + near(actual.transform.rotation, want.rotation_angle, tolerance); + expect(actual.labels ?? []).toEqual(labels); + expect(actual.size.w).toBe(want.width); + expect(actual.size.h).toBe(want.height); + expect(actual.size.x2 ?? 0).toBe(want.x2); + expect(actual.size.y2 ?? 0).toBe(want.y2); + expect(actual.size.w2 ?? actual.size.w).toBe(want.width2); + expect(actual.size.h2 ?? actual.size.h).toBe(want.height2); + const actualMain = keyCorners(actual), + expectedMain = expectedCorners(want); + const actualSecond = keyCorners(actual, true), + expectedSecond = expectedCorners(want, true); + [...actualMain, ...actualSecond].forEach((point, i) => { + const target = [...expectedMain, ...expectedSecond][i]!; + near(point.x, target.x, tolerance); + near(point.y, target.y, tolerance); + }); + expectedStyleArrays(want, actual); + } +} + +describe("KLE importer against frozen serial.js oracle", () => { + for (const [name, testCase] of Object.entries(oracle)) { + it(`${name}: import and export/import match oracle`, () => { + const first = importKle(testCase.input, { idGenerator: uuid }); + verify(first.phy, testCase); + const eligible = first.phy.root.filter((node): node is KeyNode => node.type === "key" && !node.decal); + expect(first.keyOrder).toEqual(eligible.map((key) => key.id)); + const second = importKle(exportKle(first.phy), { idGenerator: uuid }); + verify(second.phy, testCase, 1e-6); + }); + } +}); + +describe("PHY regression cases from Sol review", () => { + const base = (root: PhyFile["root"]): PhyFile => ({ + version: "0.1.0", + metadata: {}, + profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }], + root, + }); + it("reparent rejects self and descendants regardless of moved node kind", () => { + const phy = base([ + { + type: "cluster", + id: "parent", + transform: { x: 0, y: 0, rotation: 0 }, + children: [ + { + type: "cluster", + id: "child", + transform: { x: 0, y: 0, rotation: 0 }, + children: [ + { type: "cluster", id: "grandchild", transform: { x: 0, y: 0, rotation: 0 }, children: [] }, + ], + }, + ], + }, + { + type: "variantGroup", + id: "group", + name: "G", + transform: { x: 0, y: 0, rotation: 0 }, + default: "v", + children: [ + { + type: "variant", + id: "v", + name: "V", + children: [ + { type: "cluster", id: "inside", transform: { x: 0, y: 0, rotation: 0 }, children: [] }, + ], + }, + { type: "variant", id: "v2", name: "V2", children: [] }, + ], + }, + ]); + expect(() => reparent(phy, "parent", "parent")).toThrow(); + expect(() => reparent(phy, "parent", "child")).toThrow(); + expect(() => reparent(phy, "child", "child")).toThrow(); + expect(() => reparent(phy, "child", "grandchild")).toThrow(); + expect(() => reparent(phy, "group", "inside")).toThrow(); + }); + it("cluster frame starts at the rotated shape bounds, including second rectangles", () => { + const phy = base([ + { + type: "key", + id: "wide", + profile: "default", + transform: { x: 2, y: 2, rotation: 90 }, + size: { w: 2, h: 1, x2: -1, y2: -0.5, w2: 1, h2: 1 }, + }, + { + type: "key", + id: "other", + profile: "default", + transform: { x: 5, y: 2, rotation: 90 }, + size: { w: 1, h: 1 }, + }, + ]); + const grouped = clusterNodes( + phy, + ["wide", "other"], + "block", + () => "12345678-90ab-4cde-8f01-000000000001", + ); + const cluster = grouped.root[0]!; + expect(cluster.type).toBe("cluster"); + if (cluster.type !== "cluster") throw new Error("Expected cluster"); + // In the cluster's 90-degree coordinate frame the second rectangle extends left/up. + near(cluster.transform.rotation, 90); + near(cluster.transform.x, 5); + near(cluster.transform.y, 1); + }); + it("flattening propagates raw ancestor rotations without normalizing", () => { + const phy = base([ + { + type: "cluster", + id: "a", + transform: { x: 0, y: 0, rotation: 450 }, + children: [ + { + type: "cluster", + id: "b", + transform: { x: 0, y: 0, rotation: 400 }, + children: [ + { + type: "key", + id: "k", + profile: "default", + transform: { x: 0, y: 0, rotation: -20 }, + size: { w: 1, h: 1 }, + }, + ], + }, + ], + }, + ]); + expect(flattenKeys(phy)[0]!.rotation).toBe(830); + }); + it("cluster, uncluster, reparent, and reframe retain raw world angles", () => { + let phy = base([ + { + type: "cluster", + id: "outer", + transform: { x: 1, y: 2, rotation: 400 }, + children: [ + { + type: "key", + id: "a", + profile: "default", + transform: { x: 0, y: 0, rotation: 50 }, + size: { w: 1, h: 1 }, + }, + { + type: "key", + id: "b", + profile: "default", + transform: { x: 2, y: 0, rotation: 50 }, + size: { w: 1, h: 1 }, + }, + ], + }, + { type: "cluster", id: "target", transform: { x: -1, y: 4, rotation: 720 }, children: [] }, + ]); + const rotations = () => + Object.fromEntries(flattenKeys(phy).map(({ key, rotation }) => [key.id, rotation])); + expect(rotations()).toEqual({ a: 450, b: 450 }); + phy = clusterNodes(phy, ["a", "b"], "nested", uuid); + expect(rotations()).toEqual({ a: 450, b: 450 }); + const outer = phy.root[0]!; + if (outer.type !== "cluster") throw new Error("Expected outer cluster"); + const nested = outer.children[0]!; + if (nested.type !== "cluster") throw new Error("Expected nested cluster"); + phy = reframe(phy, nested.id, { x: 3, y: -2, rotation: 810 }); + expect(rotations()).toEqual({ a: 450, b: 450 }); + phy = reparent(phy, "a", "target"); + expect(rotations()).toEqual({ a: 450, b: 450 }); + phy = clusterNodes(phy, ["b"], "single", uuid); + expect(rotations()).toEqual({ a: 450, b: 450 }); + }); + it("T-18 initial order filters decal and retains ghost", () => { + const phy = base([ + { + type: "key", + id: "decal", + profile: "default", + transform: { x: 0, y: 0, rotation: 0 }, + size: { w: 1, h: 1 }, + decal: true, + }, + { + type: "key", + id: "ghost", + profile: "default", + transform: { x: 1, y: 0, rotation: 0 }, + size: { w: 1, h: 1 }, + ghost: true, + }, + ]); + const imported = importKle([[{ d: true }, "D", { g: true }, "G"]], { idGenerator: uuid }); + expect(imported.keyOrder).toHaveLength(1); + const idx = createIdx(phy, ["decal", "ghost"], { source: "x", checksum: "sha256-jcs:x" }); + expect(idx.order).toEqual(["ghost"]); + }); +}); diff --git a/mock/tests/phase1.test.ts b/mock/tests/phase1.test.ts new file mode 100644 index 0000000..61e90cc --- /dev/null +++ b/mock/tests/phase1.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { PhyFile, KeyNode } from "../src/domain/phy"; +import { flattenKeys } from "../src/domain/geometry"; +import { importKle } from "../src/io/kleImport"; +import { exportKle } from "../src/io/kleExport"; +import { canonicalize, sha256Jcs } from "../src/io/json"; +import { + addVariant, + clusterNodes, + createVariantGroup, + deleteVariant, + reframe, + reparent, + uncluster, +} from "../src/domain/phyCommands"; + +const gen = (() => { + let i = 0; + return () => { + const s = String(++i).padStart(12, "0"); + return `${s.slice(0, 8)}-${s.slice(8)}-4000-8000-000000000000`; + }; +})(); +function sample(path: string): unknown { + return JSON.parse(readFileSync(resolve(process.cwd(), "../samples/kle", path), "utf8")) as unknown; +} +function byShape(p: PhyFile) { + return flattenKeys(p) + .map((k) => ({ + x: k.x, + y: k.y, + r: k.rotation, + w: k.key.size.w, + h: k.key.size.h, + labels: k.key.labels, + compat: k.key.kleCompatibility, + })) + .sort( + (a, b) => + Math.round(a.r * 1e6) - Math.round(b.r * 1e6) || + Math.round(a.y * 1e6) - Math.round(b.y * 1e6) || + Math.round(a.x * 1e6) - Math.round(b.x * 1e6), + ); +} +describe("KLE round trip", () => { + for (const file of ["macropad-4x4.json", "cr0wn60.json"]) + it(file, () => { + const a = importKle(sample(file), { idGenerator: gen }).phy; + const b = importKle(exportKle(a), { idGenerator: gen }).phy; + const aa = byShape(a), + bb = byShape(b); + expect(bb).toHaveLength(aa.length); + for (let i = 0; i < aa.length; i++) { + const x = aa[i]!, + y = bb[i]!; + expect( + Math.abs(x.x - y.x), + `${file} item ${i}: x ${x.x} vs ${y.x}; y=${x.y}/${y.y}; rot=${x.r}/${y.r}; labels=${JSON.stringify(x.labels)}/${JSON.stringify(y.labels)}`, + ).toBeLessThanOrEqual(1e-6); + expect(Math.abs(x.y - y.y), `${file} item ${i}: y ${x.y} vs ${y.y}`).toBeLessThanOrEqual(1e-6); + expect(Math.abs(x.r - y.r)).toBeLessThanOrEqual(1e-6); + expect(x.w).toBe(y.w); + expect(x.h).toBe(y.h); + expect(x.labels).toEqual(y.labels); + expect(x.compat).toEqual(y.compat); + } + }); + it("rotated KLE cursor, mid-row rotation and pivot changes", () => { + const { phy } = importKle([[{ r: 90, rx: 1, ry: 1 }, "A", "B", { r: 180, rx: 2, ry: 1 }, "C"]], { + idGenerator: gen, + }); + const k = phy.root as KeyNode[]; + expect(k.map((x) => [x.transform.x, x.transform.y, x.transform.rotation])).toEqual([ + [1, 1, 90], + [1, 2, 90], + [2, 1, 180], + ]); + const corners = (n: KeyNode) => + [ + [0, 0], + [n.size.w, 0], + [n.size.w, n.size.h], + [0, n.size.h], + ].map(([x, y]) => { + const a = (n.transform.rotation * Math.PI) / 180; + return [ + n.transform.x + Math.cos(a) * x! - Math.sin(a) * y!, + n.transform.y + Math.sin(a) * x! + Math.cos(a) * y!, + ]; + }); + const check = (actual: number[][], expected: number[][]) => + actual.forEach((p, i) => + p.forEach((v, j) => expect(Math.abs(v - expected[i]![j]!)).toBeLessThanOrEqual(1e-12)), + ); + check(corners(k[0]!), [ + [1, 1], + [1, 2], + [0, 2], + [0, 1], + ]); + check(corners(k[2]!), [ + [2, 1], + [1, 1], + [1, 0], + [2, 0], + ]); + }); +}); +function fixture(): PhyFile { + return { + version: "0.1.0", + metadata: {}, + profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }], + root: [ + { + type: "cluster", + id: "outer", + name: "Outer", + transform: { x: 4, y: 2, rotation: 30 }, + children: [ + { + type: "cluster", + id: "inner", + name: "Inner", + transform: { x: 1, y: 2, rotation: 20 }, + children: [ + { + type: "key", + id: "a", + profile: "default", + transform: { x: 0.2, y: 0.4, rotation: 15 }, + size: { w: 1, h: 1 }, + }, + { + type: "key", + id: "b", + profile: "default", + transform: { x: 1.4, y: -0.2, rotation: -5 }, + size: { w: 1, h: 1 }, + }, + ], + }, + ], + }, + { + type: "cluster", + id: "other", + name: "Other", + transform: { x: -3, y: 6, rotation: -12 }, + children: [], + }, + ], + }; +} +const pose = (p: PhyFile) => + Object.fromEntries(flattenKeys(p).map((k) => [k.key.id, [k.x, k.y, k.rotation]])); +const closePose = (a: Record, b: Record) => { + for (const id of Object.keys(a)) { + a[id]!.forEach((v, i) => + expect(Math.abs(v - b[id]![i]!), `pose ${id} component ${i}: ${v} vs ${b[id]![i]}`).toBeLessThanOrEqual( + 1e-9, + ), + ); + } +}; +describe("PHY commands and variants", () => { + it("preserves nested rotated key world placement through cluster, uncluster, reparent, reframe", () => { + let p = fixture(); + const before = pose(p); + p = clusterNodes(p, ["a", "b"], "picked", gen); + closePose(before, pose(p)); + const outer = p.root[0]!; + if (outer.type !== "cluster") throw Error(); + const inner = outer.children[0]!; + if (inner.type !== "cluster") throw Error(); + const created = inner.children[0]!; + if (created.type !== "cluster") throw Error(); + p = uncluster(p, created.id); + closePose(before, pose(p)); + p = reparent(p, "a", "other"); + closePose(before, pose(p)); + p = reframe(p, "outer", { x: 8, y: -4, rotation: -20 }); + closePose(before, pose(p)); + }); + it("flattens variant selection, exports only the selected branch, and applies deletion rules", () => { + let p: PhyFile = { + version: "0.1.0", + metadata: {}, + profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }], + root: [], + }; + p = createVariantGroup( + p, + "Layout", + { x: 2, y: 0, rotation: 0 }, + [ + { + name: "ANSI", + children: [ + { + type: "key", + id: "ansi", + profile: "default", + transform: { x: 0, y: 0, rotation: 0 }, + size: { w: 1, h: 1 }, + labels: ["Enter"], + }, + ], + }, + { + name: "ISO", + children: [ + { + type: "key", + id: "iso", + profile: "default", + transform: { x: 0, y: 0, rotation: 0 }, + size: { w: 1, h: 1 }, + labels: ["Enter"], + }, + ], + }, + ], + gen, + ); + const group = p.root[0]!; + if (group.type !== "variantGroup") throw Error(); + expect(flattenKeys(p).map((x) => x.key.id)).toEqual(["ansi"]); + const choice = group.children[1]!.id; + expect(flattenKeys(p, { [group.id]: choice }).map((x) => x.key.id)).toEqual(["iso"]); + const out = JSON.stringify(exportKle(p, { [group.id]: choice })); + expect(out).not.toContain('"ansi"'); + expect(() => deleteVariant(p, group.id, group.children[0]!.id)).toThrow(); + p = addVariant(p, group.id, "JIS", gen); + const grown = p.root[0]!; + if (grown.type !== "variantGroup") throw Error(); + p = deleteVariant(p, group.id, grown.default); + const reduced = p.root[0]!; + if (reduced.type !== "variantGroup") throw Error(); + expect(reduced.default).toBe(reduced.children[0]!.id); + }); +}); +describe("JCS", () => { + it("sorts keys and uses ECMAScript number formatting", () => { + expect(canonicalize({ z: 1, a: 1e-6, b: 1e21, c: -0 })).toBe('{"a":0.000001,"b":1e+21,"c":0,"z":1}'); + expect(canonicalize([333333333.33333329, 1e30, 4.5, 2e-3, 1e-27])).toBe( + "[333333333.3333333,1e+30,4.5,0.002,1e-27]", + ); + }); + it("hashes canonical UTF-8 with SHA-256", async () => { + expect(await sha256Jcs({ a: 1 })).toBe( + "sha256-jcs:015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862", + ); + }); +}); diff --git a/mock/tests/review.test.ts b/mock/tests/review.test.ts new file mode 100644 index 0000000..49ee37c --- /dev/null +++ b/mock/tests/review.test.ts @@ -0,0 +1,270 @@ +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"]); + }); +}); diff --git a/mock/tsconfig.json b/mock/tsconfig.json new file mode 100644 index 0000000..501f3b2 --- /dev/null +++ b/mock/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "e2e/**/*.ts", "*.config.ts"] +} diff --git a/mock/vite.config.ts b/mock/vite.config.ts new file mode 100644 index 0000000..ae8f5d1 --- /dev/null +++ b/mock/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + test: { include: ["tests/**/*.test.ts"] }, +} as never);