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
232 lines
11 KiB
TypeScript
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);
|
|
});
|