Files
Beyond-KLE/mock/e2e/phy.spec.ts
T
ewdernaandClaude Opus 5.5 afd9af1914 mock: PHY Editorで異形キーを描き・編集し・追加できるようにする(T-32)
- domain/keyShape.ts に、キーの矩形・外接矩形・和の外形の計算と、異形キーのプリセット表(今はISO Enterだけ)を置いた
- キャンバスと選択枠は、2つ目の矩形を持つキーを和の外形のpathで描く。範囲選択と囲み矩形も両方の矩形で取る
- プロパティ欄に「異形キー」のチェックと、2つ目の矩形の欄(X2・Y2・Width2・Height2)を置いた
- ツールバーに「異形キーを足す」selectを置いた。1Uと同じ規則の位置に、外接矩形の左上を合わせて足す
- 検証: vue-tsc 0件、Vitest 106件(うち新規5件)、Playwright PHY 31件(うち新規1件)、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xmqw9x7EoryY6zf116i3yM
2026-10-26 08:55:20 +09:00

772 lines
39 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);
});
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(".hold-row.active")).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.locator(`[data-testid="idx-canvas"] [data-key-id="${id}"]`)).toHaveCount(0);
await expect(page.getByTestId("idx-register-all")).toBeDisabled();
});
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(".hold-row.active")).toContainText("Cluster", { 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("リスト方式では、リストに入ると巡回が止まり、指した行で離すと確定する", async ({ page }) => {
const cluster = await makeCluster(page);
await page.getByRole("button", { name: "長押し: ループ" }).click();
await expect(page.getByRole("button", { name: "長押し: リスト" })).toBeVisible();
const key = cluster.locator('[data-node-type="key"]').first();
const keyId = await key.getAttribute("data-node-id");
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(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
const row = await page.locator(".hold-row", { hasText: "キー" }).boundingBox();
await page.mouse.move(row!.x + row!.width / 2, row!.y + row!.height / 2, { steps: 5 });
await expect(page.locator(".hold-row.active")).toHaveText("キー");
await expect(page.locator(".hold-ring")).toHaveCount(0);
await page.waitForTimeout(1200);
await expect(page.locator(".hold-row.active")).toHaveText("キー");
await page.mouse.up();
await expect(page.locator(".hold-list")).toHaveCount(0);
await expect(page.locator(`[data-sel-id="${keyId}"]`)).toBeVisible();
// リストへ動かしてもキーはドラッグされない
expect(await key.boundingBox()).toEqual(b);
});
test("ループ方式では、長押しのあと動かすと選んだ先をドラッグする", async ({ page }) => {
const cluster = await makeCluster(page);
const clusterId = await cluster.getAttribute("data-node-id");
const keys = cluster.locator('[data-node-type="key"]');
const before = await keys.nth(1).boundingBox();
const b = (await keys.first().boundingBox())!;
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
await page.mouse.down();
await expect(page.locator(".hold-ring")).toBeVisible({ timeout: 2000 });
await page.mouse.move(b.x + b.width / 2 + 80, b.y + b.height / 2, { steps: 5 });
await expect(page.locator(".hold-list")).toHaveCount(0);
await page.mouse.up();
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
// クラスターごと動くので、押していないキーも動く
expect((await keys.nth(1).boundingBox())!.x).toBeGreaterThan(before!.x + 40);
});
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);
});
test("バリアントグループを選んでキーを足すと、表示中のバリアントに新しい行を作る", async ({ page }) => {
await keysSelectVariantGroup(page);
const group = page.locator('[data-node-type="variantGroup"]').first();
const groupId = await group.getAttribute("data-node-id");
await page.locator(`[data-tree-id="${groupId}"]`).first().click();
const groupKeys = group.locator('[data-node-type="key"]');
const before = await groupKeys.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(groupKeys).toHaveCount(before.length + 1);
const after = await groupKeys.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 variant 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);
});
test("キャンバスのviewBoxはツールバーを除いたSVGの大きさに合う", async ({ page }) => {
const canvas = page.locator("svg.canvas");
const box = (await canvas.boundingBox())!;
const [, , w, h] = (await canvas.getAttribute("viewBox"))!.split(" ").map(Number);
expect(Math.abs(w! - box.width)).toBeLessThan(1);
expect(Math.abs(h! - box.height)).toBeLessThan(1);
});
test("スイッチ・スタビの十字はトグルで出し、スタビ候補を複数選べる", async ({ page }) => {
const key = page.locator('[data-node-type="key"]').first();
const id = await key.getAttribute("data-node-id");
await expect(page.locator(".mount-layer")).toHaveCount(0);
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
const mark = page.locator(`[data-mount-id="${id}"]`);
await expect(mark.locator(".switch")).toHaveCount(1);
await expect(mark.locator(".stab")).toHaveCount(0);
await key.click();
await expect(page.locator(".stabilizers")).toContainText("候補なし");
await page.getByLabel("Width").fill("6.25");
await page.getByLabel("Width").blur();
for (const label of ["±50mm", "±40mm", "±42.5mm"]) await expect(page.getByLabel(label)).not.toBeChecked();
await page.getByLabel("±50mm").check();
await page.getByLabel("±40mm").check();
await expect(mark.locator(".stab")).toHaveCount(4);
const switchX = async () => (await mark.locator(".switch").boundingBox())!.x;
const before = await switchX();
await page.getByLabel("switchOffset X").fill("0.625");
await page.getByLabel("switchOffset X").dispatchEvent("change");
await expect.poll(switchX).toBeGreaterThan(before + 10);
await expect(mark.locator(".stab")).toHaveCount(4);
await page.getByRole("button", { name: "Undo" }).click();
await page.getByRole("button", { name: "Undo" }).click();
await expect(mark.locator(".stab")).toHaveCount(2);
await expect(page.getByLabel("±40mm")).not.toBeChecked();
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
await expect(page.locator(".mount-layer")).toHaveCount(0);
});
test("ISO Enterを足すと外形で描き、プロパティの異形キーで2つ目の矩形を出し入れできる", async ({ page }) => {
const keys = page.locator('[data-node-type="key"]');
await keys.first().click();
const select = page.getByRole("combobox", { name: "異形キーを足す" });
await select.selectOption("ISO Enter");
await expect(select).toHaveValue("");
await expect(keys).toHaveCount(17);
const added = keys.last();
const addedId = await added.getAttribute("data-node-id");
await expect(added.locator("path.key-face")).toHaveCount(1);
await expect(page.locator(`path[data-sel-id="${addedId}"]`)).toHaveCount(1);
const irregular = page.getByLabel("異形キー", { exact: true });
await expect(irregular).toBeChecked();
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("-0.25");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.5");
await page.getByLabel("Width2", { exact: true }).fill("2");
await page.getByLabel("Width2", { exact: true }).press("Enter");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("2");
await irregular.uncheck();
await expect(added.locator("rect.key-face")).toHaveAttribute("width", "1.25");
await expect(page.getByLabel("X2", { exact: true })).toHaveCount(0);
await irregular.check();
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("0");
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.25");
});