Files
Beyond-KLE/mock/e2e/phy.spec.ts
T
ewdernaandClaude Opus 5.5 fccbc28bee mock: 仮決定に沿ってモックを実装する(PHY Editor・IDX・ELC・MAP・Generator)
provisional/decisions.md の仮決定(T-1〜T-28)に従い、mock/ に Vue 3 + Vite + TypeScript のモックを置いた。
T-3 の1〜3(回転したクラスター内のドラッグと保存の往復、IDX・MAPとの連携、ELCと出力の対応表)まで。疑似デバイスとの往復(T-24)は未着手。

- KLE JSONの入出力は、KLE本家の serial.js の deserialize の結果を期待値とする fixture(tests/fixtures/kle-oracle.json)で照合した。本家のコードは取り込んでいない(T-7)
- 基本キーコードの表は QMK の data/constants/keycodes/keycodes_0.0.1_basic.hjson(1e340cd)から抽出した
- 実装は Codex(Luna)、レビューは Codex(Sol)。検証: Vitest 53件、Playwright 20件、vue-tsc、vite build
- TypeScript は vue-tsc が 7 系に未対応のため 6 系に固定した
- 操作性の改善(ELCの提供者の表示、一括配線とGeneratorのピン設定の引き継ぎ、キー内の文字のはみ出し)は別途行う

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
2026-09-25 22:42:30 +09:00

232 lines
11 KiB
TypeScript

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);
});