mock: 仮決定に沿ってモックを実装する(PHY Editor・IDX・ELC・MAP・Generator)
provisional/decisions.md の仮決定(T-1〜T-28)に従い、mock/ に Vue 3 + Vite + TypeScript のモックを置いた。 T-3 の1〜3(回転したクラスター内のドラッグと保存の往復、IDX・MAPとの連携、ELCと出力の対応表)まで。疑似デバイスとの往復(T-24)は未着手。 - KLE JSONの入出力は、KLE本家の serial.js の deserialize の結果を期待値とする fixture(tests/fixtures/kle-oracle.json)で照合した。本家のコードは取り込んでいない(T-7) - 基本キーコードの表は QMK の data/constants/keycodes/keycodes_0.0.1_basic.hjson(1e340cd)から抽出した - 実装は Codex(Luna)、レビューは Codex(Sol)。検証: Vitest 53件、Playwright 20件、vue-tsc、vite build - TypeScript は vue-tsc が 7 系に未対応のため 6 系に固定した - 操作性の改善(ELCの提供者の表示、一括配線とGeneratorのピン設定の引き継ぎ、キー内の文字のはみ出し)は別途行う Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<Record<string, [number, number]>> =>
|
||||
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<Record<string, [number, number]>>;
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user