これまではクラスターを1つのノードとして扱い、その右隣(親の中)に足していた。 クラスターの中に新しい行を作り、その左端に置く。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsamNKrWY3obJwR746oQRq
640 lines
31 KiB
TypeScript
640 lines
31 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(".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);
|
|
});
|