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); }); async function makeCluster(page: import("@playwright/test").Page) { const keys = page.locator('[data-node-type="key"]'); await keys.nth(0).click(); await keys.nth(1).click({ modifiers: ["Shift"] }); await page.getByRole("button", { name: "クラスターにする" }).click(); return page.locator('[data-node-type="cluster"]').first(); } test("階層パネルを閉じると、キャンバスが空いた幅を埋める", async ({ page }) => { const canvas = page.locator(".canvas-pane"); const properties = page.locator(".properties"); const before = (await canvas.boundingBox())!; await page.getByRole("button", { name: "階層" }).click(); await expect(page.locator(".tree")).toHaveCount(0); await expect.poll(async () => (await canvas.boundingBox())!.width).toBeGreaterThan(before.width + 200); const after = (await canvas.boundingBox())!; const side = (await properties.boundingBox())!; const workspace = (await page.locator(".workspace").boundingBox())!; expect(Math.abs(after.x - workspace.x)).toBeLessThan(1); expect(Math.abs(side.x + side.width - (workspace.x + workspace.width))).toBeLessThan(1); }); test("選択レイヤーはキーより前面にあり、回転とフォーカスに追従する", async ({ page }) => { test.setTimeout(60_000); const keys = page.locator('[data-node-type="key"]'); const leftId = await keys.first().getAttribute("data-node-id"); await keys.first().click(); const order = await page.locator("svg.canvas").evaluate((svg, id) => { const face = svg.querySelector(`[data-node-id="${id}"]`)!; const layer = svg.querySelector(`[data-sel-id="${id}"]`)!; return [ face.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING, layer.parentElement?.classList.contains("selection-layer"), ]; }, leftId); expect(order).toEqual([4, true]); const cluster = await makeCluster(page); const clusterId = await cluster.getAttribute("data-node-id"); await page.locator(`[data-tree-id="${clusterId}"]`).first().click(); await page.getByLabel("Rotation").fill("35"); await page.getByLabel("Rotation").dispatchEvent("change"); const key = cluster.locator('[data-node-type="key"]').first(); const id = await key.getAttribute("data-node-id"); await key.click(); const aligned = async () => page.evaluate((keyId) => { const face = document.querySelector(`[data-node-id="${keyId}"] .key-face`)!.getBoundingClientRect(); const outline = document.querySelector(`[data-sel-id="${keyId}"]`)!.getBoundingClientRect(); return [ Math.abs(face.x - outline.x), Math.abs(face.y - outline.y), Math.abs(face.width - outline.width), Math.abs(face.height - outline.height), ]; }, id); expect((await aligned()).every((delta) => delta < 1)).toBe(true); await page.locator(`[data-tree-id="${clusterId}"]`).first().click(); await page.locator(".canvas-pane").focus(); await page.keyboard.press("Enter"); const focused = page.locator(`[data-node-id="${id}"]`).last(); await focused.click(); expect((await aligned()).every((delta) => delta < 1)).toBe(true); }); test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーがない", async ({ page }) => { const errors: string[] = []; page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); await page.reload(); await expect(page.locator('[data-node-type="key"]')).toHaveCount(16); const key = page.locator('[data-node-type="key"]').first(); const before = await key.getAttribute("transform"); const box = await key.boundingBox(); 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(); await expect.poll(() => key.getAttribute("transform")).not.toBe(before); const after = await key.getAttribute("transform"); 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", after!); expect(errors).toEqual([]); }); test("ドラッグ中に右端で自動スクロールし、Undo 1回で戻る", async ({ page }) => { const key = page.locator('[data-node-type="key"]').first(); const before = await key.getAttribute("transform"); const keyBox = (await key.boundingBox())!; const canvasBox = (await page.locator("svg.canvas").boundingBox())!; const y = keyBox.y + keyBox.height / 2; await page.mouse.move(keyBox.x + keyBox.width / 2, y); await page.mouse.down(); await page.mouse.move(canvasBox.x + canvasBox.width - 8, y, { steps: 8 }); const atEdge = await key.getAttribute("transform"); await page.waitForTimeout(700); expect(await key.getAttribute("transform")).not.toBe(atEdge); await page.mouse.up(); const released = await key.getAttribute("transform"); await page.waitForTimeout(250); expect(await key.getAttribute("transform")).toBe(released); await page.getByRole("button", { name: "Undo" }).click(); await expect(key).toHaveAttribute("transform", before!); }); test("選択中のクラスターにだけ原点の丸を出す", async ({ page }) => { const keys = page.locator('[data-node-type="key"]'); const corner = (await keys.nth(0).locator(".key-face").boundingBox())!; const cluster = await makeCluster(page); const clusterId = await cluster.getAttribute("data-node-id"); await keys.nth(5).click(); await expect(page.locator(".origin")).toHaveCount(0); await page.locator(`[data-tree-id="${clusterId}"]`).first().click(); const origin = page.locator(`[data-origin-id="${clusterId}"]`); await expect(origin).toHaveCount(1); const box = (await origin.boundingBox())!; expect(Math.abs(box.x + box.width / 2 - corner.x)).toBeLessThan(2); expect(Math.abs(box.y + box.height / 2 - corner.y)).toBeLessThan(2); await keys.nth(5).click(); await expect(page.locator(".origin")).toHaveCount(0); }); test("クラスター化で画面位置を保つ", async ({ page }) => { const keys = page.locator('[data-node-type="key"]'); const centers = async () => keys.evaluateAll((es) => es.slice(0, 2).map((e) => { const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }), ); const before = await centers(); await keys.nth(0).click(); await keys.nth(1).click({ modifiers: ["Shift"] }); await page.getByRole("button", { name: "クラスターにする" }).click(); const after = await centers(); for (let i = 0; i < 2; i++) { expect(after[i]![0]).toBeCloseTo(before[i]![0]!, 0); expect(after[i]![1]).toBeCloseTo(before[i]![1]!, 0); } }); test("複数選択のドラッグが全件に適用される", async ({ page }) => { const keys = page.locator('[data-node-type="key"]'); const before = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform"))); await keys.nth(0).click(); await keys.nth(1).click({ modifiers: ["Shift"] }); const box = await keys.nth(0).boundingBox(); if (!box) throw new Error("key 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 + 27, box.y + box.height / 2 + 10, { steps: 3 }); await page.mouse.up(); const after = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform"))); expect(after[0]).not.toBe(before[0]); expect(after[1]).not.toBe(before[1]); }); test("回転クラスターの矢印移動は親軸で動き、ドラッグもポインタに追従する", async ({ page }) => { const cluster = await makeCluster(page); const clusterId = await cluster.getAttribute("data-node-id"); await page.locator(`[data-tree-id="${clusterId}"]`).click(); await page.getByLabel("Rotation").fill("90"); await page.getByLabel("Rotation").dispatchEvent("change"); const key = page.locator('[data-node-type="key"]').first(); const beforeBox = await key.boundingBox(); const beforeTransform = await key.getAttribute("transform"); await key.click(); const pane = page.locator(".canvas-pane"); await pane.focus(); await page.keyboard.press("ArrowRight"); const afterBox = await key.boundingBox(); expect(afterBox!.y).toBeGreaterThan(beforeBox!.y + 4); const dragBox = await key.boundingBox(); await page.mouse.move(dragBox!.x + dragBox!.width / 2, dragBox!.y + dragBox!.height / 2); await page.mouse.down(); await page.mouse.move(dragBox!.x + dragBox!.width / 2 + 28, dragBox!.y + dragBox!.height / 2, { steps: 3 }); await page.mouse.up(); const dragAfterBox = await key.boundingBox(); expect(await key.getAttribute("transform")).not.toBe(beforeTransform); expect(dragAfterBox!.x).toBeGreaterThan(dragBox!.x + 10); expect(dragAfterBox!.y).toBeCloseTo(dragBox!.y, 0); }); test("長押しでクラスターを選び、ダブルクリックでフォーカスしてEscで戻る", async ({ page }) => { const cluster = await makeCluster(page); const clusterId = await cluster.getAttribute("data-node-id"); const key = page.locator('[data-node-type="key"]').first(); const b = await key.boundingBox(); await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2); await page.mouse.down(); await expect(page.locator(".long-label")).toContainText("Cluster", { timeout: 2000 }); await page.mouse.up(); await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/); await page.locator(`[data-tree-id="${clusterId}"]`).first().click(); const keyBox = await key.boundingBox(); await page.mouse.dblclick(keyBox!.x + keyBox!.width / 2, keyBox!.y + keyBox!.height / 2, { delay: 80 }); await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible(); const pane = page.locator(".canvas-pane"); await pane.focus(); await page.keyboard.press("Escape"); await page.keyboard.press("Escape"); await expect(page.getByRole("button", { name: "root", exact: true })).toHaveCount(0); }); test("入れ子の回転クラスターをフォーカスできる", async ({ page }) => { const outer = await makeCluster(page); const outerId = await outer.getAttribute("data-node-id"); const childIds = await outer .locator('[data-node-type="key"]') .evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id"))); await page.getByText("キー・部品 2件", { exact: true }).click(); await page.locator(`[data-tree-id="${childIds[0]}"]`).first().click(); await page .locator(`[data-tree-id="${childIds[1]}"]`) .first() .click({ modifiers: ["Shift"] }); await page.getByRole("button", { name: "クラスターにする" }).click(); const inner = outer.locator('[data-node-type="cluster"]').first(); const innerId = await inner.getAttribute("data-node-id"); await page.locator(`[data-tree-id="${outerId}"]`).first().click(); await page.getByLabel("Rotation").fill("35"); await page.getByLabel("Rotation").dispatchEvent("change"); const key = inner.locator('[data-node-type="key"]').first(); await expect(key).toBeVisible(); await page.locator(`[data-tree-id="${outerId}"]`).first().click(); await page.locator(".canvas-pane").focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible(); await expect(page.locator(`[data-node-id="${childIds[0]}"]`).last()).toBeVisible(); const focusedKey = page.locator(`[data-node-id="${childIds[0]}"]`).last(); const focusedBox = await focusedKey.boundingBox(); await page.mouse.move(focusedBox!.x + focusedBox!.width / 2, focusedBox!.y + focusedBox!.height / 2); await page.mouse.down(); await page.waitForTimeout(700); await expect(page.locator(`[data-sel-id="${innerId}"].candidate`)).toBeVisible({ timeout: 1800, }); await page.mouse.up(); await page.screenshot({ path: path.join(test.info().outputDir, "rotated-long-press-in-focus.png") }); const clickBox = await focusedKey.boundingBox(); await page.mouse.dblclick(clickBox!.x + clickBox!.width / 2, clickBox!.y + clickBox!.height / 2, { delay: 80, }); await expect(page.locator(`[data-node-id="${innerId}"]`)).toHaveCount(1); }); test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => { test.setTimeout(60_000); const keys = page.locator('[data-node-type="key"]'); await keys.first().click(); const outline = page.locator(".selection-layer .sel").first(); await expect(outline).toBeVisible(); expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("3.5px"); await keys.nth(1).click({ modifiers: ["Shift"] }); await page.getByRole("button", { name: "選択からバリアントグループ" }).click(); const group = page.locator('[data-node-type="variantGroup"]').first(); await page.getByLabel("Rotation").fill("35"); await page.getByLabel("Rotation").dispatchEvent("change"); const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`); await expect(badge).toBeVisible(); await badge.locator("rect").click(); await page .locator(".popover") .getByRole("button", { name: /Option B/ }) .click(); await expect(keys).toHaveCount(14); await page.locator(".canvas-pane").focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible(); await badge.locator("rect").click(); const focusedBadgeBox = await badge.boundingBox(); const focusedPopoverBox = await page.locator(".popover").boundingBox(); expect( Math.hypot(focusedPopoverBox!.x - focusedBadgeBox!.x, focusedPopoverBox!.y - focusedBadgeBox!.y), ).toBeLessThanOrEqual(80); await page.screenshot({ path: path.join(test.info().outputDir, "rotated-badge-in-focus.png") }); await page .locator(".popover") .getByRole("button", { name: /Option A/ }) .click(); await expect(keys).toHaveCount(16); }); test("クラスター内のキーから作ったバリアントグループはクラスターの中にでき、バッジは名前を書かない", async ({ page, }) => { const cluster = await makeCluster(page); const key = cluster.locator('[data-node-type="key"]').first(); await key.click(); await page.getByRole("button", { name: "選択からバリアントグループ" }).click(); const group = cluster.locator('[data-node-type="variantGroup"]'); await expect(group).toHaveCount(1); const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`); await expect(badge.locator("text")).toHaveText("⇄"); await expect(badge.locator("title")).toHaveText("Variant Group: Option A(既定)"); // バッジはキーより後に描き、隣のキーの下に隠れない const onTop = await badge.evaluate((element) => { const keys = [...document.querySelectorAll('[data-node-type="key"]')]; const box = element.getBoundingClientRect(); const hit = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); return [ keys.every((key) => key.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING), element.contains(hit), ]; }); expect(onTop).toEqual([true, true]); await badge.locator("rect").click(); const popover = page.locator(".popover"); await expect(popover.getByRole("button", { name: /Option A/ }).locator(".default-tag")).toHaveText("既定"); await expect(popover.getByRole("button", { name: /Option B/ }).locator(".default-tag")).toHaveCount(0); await popover.getByRole("button", { name: /Option B/ }).click(); await expect(badge.locator("title")).toHaveText("Variant Group: Option B"); }); test("削除確認は参照を列挙し、実行後はIDXからも消える", async ({ page }) => { const key = page.locator('[data-node-type="key"]').first(); const id = await key.getAttribute("data-node-id"); await key.click(); await page.getByRole("button", { name: "削除", exact: true }).click(); const confirm = page.locator(".confirm-float"); await expect(confirm).toContainText("参照"); await expect(confirm).toContainText(id!); await confirm.getByRole("button", { name: "削除", exact: true }).click(); await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0); await page.getByRole("button", { name: "IDX", exact: true }).click(); await expect(page.getByTestId(`idx-row-${id}`)).toHaveCount(0); }); test("コピー貼り付けはIDを変えて1刻みずらし、KLE貼り付けはキーを追加する", async ({ page }) => { const key = page.locator('[data-node-type="key"]').first(); const id = await key.getAttribute("data-node-id"); const original = await key.getAttribute("transform"); await key.click(); const marker = await page.evaluate(() => { const data = new DataTransfer(); window.dispatchEvent( new ClipboardEvent("copy", { bubbles: true, cancelable: true, clipboardData: data }), ); return data.getData("text/plain"); }); await page.evaluate((marker) => { const data = new DataTransfer(); data.setData("text/plain", marker); window.dispatchEvent( new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }), ); }, marker); await expect(page.locator('[data-node-type="key"]')).toHaveCount(17); const pasted = page.locator('[data-node-type="key"]').last(); expect(await pasted.getAttribute("data-node-id")).not.toBe(id); expect(await pasted.getAttribute("transform")).not.toBe(original); await page.evaluate(() => { const data = new DataTransfer(); data.setData("text/plain", JSON.stringify([["Pasted"]])); window.dispatchEvent( new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }), ); }); await expect(page.locator('[data-node-type="key"]')).toHaveCount(18); }); test("カット貼り付けはIDとワールド位置を保つ", async ({ page }) => { const key = page.locator('[data-node-type="key"]').first(); const id = await key.getAttribute("data-node-id"); const transform = await key.getAttribute("transform"); await key.click(); const marker = await page.evaluate(() => { const data = new DataTransfer(); window.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true, clipboardData: data })); return data.getData("text/plain"); }); await expect(key).toHaveClass(/cut/); await page.evaluate((value) => { const data = new DataTransfer(); data.setData("text/plain", value); window.dispatchEvent( new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }), ); }, marker); const cutKey = page.locator(`[data-node-id="${id}"]`); await expect(cutKey).toHaveAttribute("transform", transform!); await expect(page.locator('[data-node-type="key"]')).toHaveCount(16); }); test("プロジェクト保存読み込みとKLEから60キー新規を往復する", async ({ page }) => { await page.locator('[data-node-type="key"]').first().click(); await page.getByLabel("Width").fill("1.5"); await page.getByLabel("Width").dispatchEvent("change"); const [download] = await Promise.all([ page.waitForEvent("download"), page.getByRole("button", { name: "プロジェクトを保存" }).click(), ]); const savePath = path.join(test.info().outputDir, "project.json"); await download.saveAs(savePath); const saved = await readFile(savePath); const chooser = page.waitForEvent("filechooser"); await page.getByRole("button", { name: "プロジェクトを開く" }).click(); await ( await chooser ).setFiles({ name: "roundtrip.bkle.json", mimeType: "application/json", buffer: saved }); await expect(page.locator('[data-node-type="key"]')).toHaveCount(16); await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", "1.5"); const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json")); const kleChooser = page.waitForEvent("filechooser"); await page.getByRole("button", { name: "KLE JSONから新規" }).click(); await (await kleChooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle }); await expect(page.locator('[data-node-type="key"]')).toHaveCount(60); }); test("unitSize不一致では追加と数値編集が無効", async ({ page }) => { const [download] = await Promise.all([ page.waitForEvent("download"), page.getByRole("button", { name: "プロジェクトを保存" }).click(), ]); const savePath = path.join(test.info().outputDir, "project.json"); await download.saveAs(savePath); const source = JSON.parse(await readFile(savePath, "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 expect(page.getByText("unitSize が一致しないため編集できません。")).toBeVisible(); await expect(page.getByRole("button", { name: "キーを足す" })).toBeDisabled(); await page.locator('[data-node-type="key"]').first().click(); await expect(page.getByLabel("Width")).toBeDisabled(); const before = await page.locator('[data-node-type="key"] .key-face').first().getAttribute("width"); await page.getByLabel("Width").evaluate((e: HTMLInputElement) => { e.value = "2"; e.dispatchEvent(new Event("change", { bubbles: true })); }); await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", before!); }); test("KLE読込直後に全体表示し、cr0wn60の全キーがキャンバス内に収まる", async ({ page }) => { const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json")); const chooser = page.waitForEvent("filechooser"); await page.getByRole("button", { name: "KLE JSONから新規" }).click(); await (await chooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle }); await expect(page.locator('[data-node-type="key"]')).toHaveCount(60); const canvas = await page.locator("svg.canvas").boundingBox(); const boxes = await page.locator('[data-node-type="key"]').evaluateAll((es) => es.map((e) => { const r = e.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; }), ); expect(canvas).not.toBeNull(); for (const b of boxes) { expect(b.left).toBeGreaterThanOrEqual(canvas!.x); expect(b.top).toBeGreaterThanOrEqual(canvas!.y); expect(b.right).toBeLessThanOrEqual(canvas!.x + canvas!.width); expect(b.bottom).toBeLessThanOrEqual(canvas!.y + canvas!.height); } await page.screenshot({ path: path.join(test.info().outputDir, "cr0wn60-fit.png") }); }); test("キャンバス上のShift選択で2件をクラスター化し、選択解除と親ドラッグを行う", async ({ page }) => { const keys = page.locator('[data-node-type="key"]'); await keys.nth(0).click(); await keys.nth(1).click({ modifiers: ["Shift"] }); await expect(page.locator(".sel")).toHaveCount(2); await page.locator(".canvas-pane").focus(); await page.keyboard.press("Control+g"); const cluster = page.locator('[data-node-type="cluster"]').first(); await expect(cluster.locator('[data-node-type="key"]')).toHaveCount(2); const clusterBefore = await cluster.getAttribute("transform"); const childrenBefore = await cluster .locator('[data-node-type="key"]') .evaluateAll((es) => es.map((e) => e.getAttribute("transform"))); const key = cluster.locator('[data-node-type="key"]').first(); const b = await key.boundingBox(); await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2); await page.mouse.down(); await page.mouse.move(b!.x + b!.width / 2 + 28, b!.y + b!.height / 2 + 5, { steps: 3 }); await page.mouse.up(); await expect(cluster).not.toHaveAttribute("transform", clusterBefore!); expect( await cluster .locator('[data-node-type="key"]') .evaluateAll((es) => es.map((e) => e.getAttribute("transform"))), ).toEqual(childrenBefore); const third = keys.nth(3); await third.click(); await third.click({ modifiers: ["Shift"] }); await expect(page.locator(".sel")).toHaveCount(0); }); test("削除のキーボード確認とバリアントポップオーバーを対象の近くに置く", async ({ page }) => { const key = page.locator('[data-node-type="key"]').first(); await key.click(); const keyBox = await key.boundingBox(); await page.locator(".canvas-pane").focus(); await page.keyboard.press("Delete"); const confirm = page.locator(".confirm-float"); const confirmBox = await confirm.boundingBox(); const pane = await page.locator(".canvas-pane").boundingBox(); expect(confirmBox!.width).toBeLessThanOrEqual(400); expect(confirmBox!.x).toBeGreaterThanOrEqual(pane!.x); expect(confirmBox!.y).toBeGreaterThanOrEqual(pane!.y); expect(confirmBox!.x + confirmBox!.width).toBeLessThanOrEqual(pane!.x + pane!.width); expect(confirmBox!.y + confirmBox!.height).toBeLessThanOrEqual(pane!.y + pane!.height); const gapX = Math.max( 0, confirmBox!.x - (keyBox!.x + keyBox!.width), keyBox!.x - (confirmBox!.x + confirmBox!.width), ); const gapY = Math.max( 0, confirmBox!.y - (keyBox!.y + keyBox!.height), keyBox!.y - (confirmBox!.y + confirmBox!.height), ); expect(Math.hypot(gapX, gapY)).toBeLessThanOrEqual(150); await page.screenshot({ path: path.join(test.info().outputDir, "delete-confirm.png") }); await page.keyboard.press("Escape"); await keysSelectVariantGroup(page); const badge = page.locator(".badge-layer:not(.dim) .badge").first(); await badge.locator("rect").click(); const badgeBox = await badge.boundingBox(); const popoverBox = await page.locator(".popover").boundingBox(); expect(Math.hypot(popoverBox!.x - badgeBox!.x, popoverBox!.y - badgeBox!.y)).toBeLessThanOrEqual(80); await page.screenshot({ path: path.join(test.info().outputDir, "variant-popover.png") }); }); async function keysSelectVariantGroup(page: import("@playwright/test").Page) { const keys = page.locator('[data-node-type="key"]'); await keys.nth(0).click(); await keys.nth(1).click({ modifiers: ["Shift"] }); await page.getByRole("button", { name: "選択からバリアントグループ" }).click(); } test("Alt+上矢印は選択を親へ上げ、フォーカス描画にID重複を作らない", async ({ page }) => { const cluster = await makeCluster(page); const clusterId = await cluster.getAttribute("data-node-id"); await page.locator(`[data-tree-id="${clusterId}"]`).click(); await page.getByLabel("Rotation").fill("35"); await page.getByLabel("Rotation").dispatchEvent("change"); const key = cluster.locator('[data-node-type="key"]').first(); const transform = await key.getAttribute("transform"); await key.click(); await page.locator(".canvas-pane").focus(); await page.keyboard.press("Alt+ArrowUp"); await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/); await expect(key).toHaveAttribute("transform", transform!); await page.keyboard.press("Enter"); const ids = await page .locator("svg.canvas [data-node-id]") .evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id"))); expect(new Set(ids).size).toBe(ids.length); await page.screenshot({ path: path.join(test.info().outputDir, "focus-mode.png") }); const pane = await page.locator("svg.canvas").boundingBox(); await page.mouse.click(pane!.x + 8, pane!.y + 8); await expect(page.locator(".sel")).toHaveCount(0); }); test("長押し候補は離すまで選択を変えず、離すとクラスターを選ぶ", async ({ page }) => { const cluster = await makeCluster(page); const clusterId = await cluster.getAttribute("data-node-id"); const keys = page.locator('[data-node-type="key"]'); await keys.nth(4).click(); await expect( page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`), ).toBeVisible(); const b = await cluster.locator('[data-node-type="key"]').first().boundingBox(); await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2); await page.mouse.down(); await expect(page.locator(".long-label")).toContainText("2/", { timeout: 1800 }); await expect( page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`), ).toBeVisible(); await page.screenshot({ path: path.join(test.info().outputDir, "long-press-candidate.png") }); await page.mouse.up(); await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/); }); test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => { const select = page.getByRole("combobox", { name: "部品を足す" }); await select.selectOption("その他"); await expect(select).toHaveValue(""); await expect(page.locator('[data-node-type="component"] .part-label').last()).toHaveText("part"); }); test("クラスター内でキーを足すと、足したキーを選ぶ", async ({ page }) => { const cluster = await makeCluster(page); const clusterKeys = cluster.locator('[data-node-type="key"]'); const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id"))); await page.getByText("キー・部品 2件", { exact: true }).click(); await page.locator(`[data-tree-id="${before[0]}"]`).first().click(); await page.getByRole("button", { name: "キーを足す" }).click(); await expect(clusterKeys).toHaveCount(3); const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id"))); const added = after.find((id) => !before.includes(id)); await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1); await expect(page.locator("[data-sel-id]")).toHaveCount(1); }); test("クラスターを選んでキーを足すと、そのクラスターに新しい行を作る", async ({ page }) => { const cluster = await makeCluster(page); const clusterId = await cluster.getAttribute("data-node-id"); await page.locator(`[data-tree-id="${clusterId}"]`).first().click(); const clusterKeys = cluster.locator('[data-node-type="key"]'); const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id"))); const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox())); await page.getByRole("button", { name: "キーを足す" }).click(); await expect(clusterKeys).toHaveCount(3); const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id"))); const added = after.find((id) => !before.includes(id)); await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1); const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!; // The cluster holds two keys of the first row, 1U apart; the new row starts 1U below its left end. const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x); const unit = right!.x - left!.x; expect(Math.abs(box.x - left!.x)).toBeLessThan(1); expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1); });