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