mock: PHY Editorを作り直す(T-29)
provisional/phy-editor-feedback.md の合意事項と phy-editor-keybindings.md の割り当て案に沿って、PHY 画面を一から書き直した。 App.vue は共通のシェル(入出力・履歴・タブ)だけにし、PHY は src/ui/phy/ に、コンポーネント・composable・純粋モジュール(logic.ts, viewport.ts)を分けて置いた。 - 画面確認で見つかった不具合(読み込み時の全体表示、Shift+クリック、削除の確認の大きさ、Alt+↑、フォーカス中の二重描画、長押し中の選択など)を直し、キャンバス上の実際の操作で確かめる e2e を足した - 階層パネルでクラスターの子を余分な折りたたみで包まず、件数を配下のキー・部品の数にそろえた - 実装は Codex(Luna)。検証: vue-tsc 0件、Vitest 64件、Playwright 30件、prettier --check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbfgHT848s3JbuFw8MKFF8
This commit is contained in:
+10
-13
@@ -5,14 +5,11 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
});
|
||||
|
||||
test("register unindexed key, reorder its output number, and undo", async ({ page }) => {
|
||||
test("new PHY key is registered at the end, reorder its output number, and undo", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||
await page.getByRole("button", { name: "キー追加" }).click();
|
||||
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||
const keyId = await page.locator('[data-node-type="key"]').last().getAttribute("data-node-id");
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
|
||||
const unregisteredRow = page.locator(".idx-all-keys li.unregistered").first();
|
||||
const keyId = (await unregisteredRow.locator("b").innerText()).trim();
|
||||
await unregisteredRow.getByRole("button", { name: "登録" }).click();
|
||||
const row = page.getByTestId(`idx-row-${keyId}`);
|
||||
await expect(row).toBeVisible();
|
||||
const previewKey = page.locator(`[data-testid="keyboard-view"] [data-key-id="${keyId}"] text`);
|
||||
@@ -28,15 +25,15 @@ test("output numbers recalculate after switching to a variant that excludes orde
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||
const treeKeys = page.locator(".tree-node").filter({ hasText: "キー ·" });
|
||||
const firstId = await treeKeys.nth(0).getAttribute("data-tree-id");
|
||||
const secondId = await treeKeys.nth(1).getAttribute("data-tree-id");
|
||||
await treeKeys.nth(0).click();
|
||||
await treeKeys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
const firstId = await keys.nth(0).getAttribute("data-node-id");
|
||||
const secondId = await keys.nth(1).getAttribute("data-node-id");
|
||||
await keys.nth(0).click();
|
||||
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力 0");
|
||||
await page.getByLabel("Layout 出力バリアント").selectOption({ label: "Option B" });
|
||||
await page.getByLabel("Variant Group 出力バリアント").selectOption({ label: "Option B" });
|
||||
await expect(page.getByTestId(`idx-row-${firstId}`)).toContainText("出力対象外");
|
||||
await expect(page.getByTestId(`idx-row-${secondId}`)).toContainText("出力対象外");
|
||||
const remaining = page.locator(".idx-all-keys .idx-preview").filter({ hasText: "出力 0" });
|
||||
|
||||
+436
-192
@@ -7,225 +7,469 @@ test.beforeEach(async ({ page }) => {
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
});
|
||||
|
||||
test("drag snaps to quarter units and undo/redo", async ({ page }) => {
|
||||
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("ドラッグの刻みを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();
|
||||
if (!box) throw new Error("Key has no bounds");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2 + 2, { steps: 3 });
|
||||
await page.mouse.move(box!.x + box!.width / 2 + 24, box!.y + box!.height / 2, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
const moved = await key.getAttribute("transform");
|
||||
expect(moved).not.toBe(before);
|
||||
const x = Number(moved?.match(/translate\(([-\d.]+)/)?.[1]);
|
||||
expect(x / 0.25).toBeCloseTo(Math.round(x / 0.25), 6);
|
||||
await 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", moved!);
|
||||
await expect(key).toHaveAttribute("transform", after!);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("dragging inside a rotated cluster tracks pointer movement", async ({ page }) => {
|
||||
const treeNodes = page.locator("[data-tree-id]");
|
||||
await treeNodes.nth(0).click();
|
||||
await treeNodes.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "クラスター化" }).click();
|
||||
const cluster = page.locator('[data-node-type="cluster"]').first();
|
||||
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();
|
||||
const rotation = page.getByLabel("Rotation");
|
||||
await rotation.fill("30");
|
||||
await rotation.dispatchEvent("change");
|
||||
await page.getByLabel("Rotation").fill("90");
|
||||
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const box = await key.boundingBox();
|
||||
if (!box) throw new Error("Key bounds missing");
|
||||
const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
const before = { ...start };
|
||||
const localBefore = await key.getAttribute("transform");
|
||||
const matrix = await key.evaluate((el) => {
|
||||
const m = (el.parentElement as unknown as SVGGElement).getScreenCTM()!;
|
||||
return [m.a, m.b, m.c, m.d] as const;
|
||||
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-node-id="${innerId}"] .group-sel.candidate`)).toBeVisible({
|
||||
timeout: 1800,
|
||||
});
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 32, start.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
const after = await key.boundingBox();
|
||||
if (!after) throw new Error("Key bounds missing");
|
||||
const screenDx = after.x + after.width / 2 - before.x;
|
||||
const localAfter = await key.getAttribute("transform");
|
||||
const parse = (value: string | null) => {
|
||||
const match = value?.match(/translate\(([-\d.]+),([-\d.]+)\)/);
|
||||
return { x: Number(match?.[1]), y: Number(match?.[2]) };
|
||||
};
|
||||
const delta = {
|
||||
x: parse(localAfter).x - parse(localBefore).x,
|
||||
y: parse(localAfter).y - parse(localBefore).y,
|
||||
};
|
||||
expect(Math.abs(screenDx - (matrix[0] * delta.x + matrix[2] * delta.y))).toBeLessThan(4);
|
||||
expect(
|
||||
Math.abs(after.y + after.height / 2 - before.y - (matrix[1] * delta.x + matrix[3] * delta.y)),
|
||||
).toBeLessThan(4);
|
||||
expect(Math.abs(screenDx - 32)).toBeLessThan(15);
|
||||
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("cluster and drag leave every other key at the same screen coordinates", async ({ page }) => {
|
||||
const positions = async (): Promise<Record<string, [number, number]>> =>
|
||||
page.locator('[data-node-type="key"]').evaluateAll((els) =>
|
||||
Object.fromEntries(
|
||||
els.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return [el.getAttribute("data-node-id"), [r.x + r.width / 2, r.y + r.height / 2]];
|
||||
}),
|
||||
),
|
||||
) as Promise<Record<string, [number, number]>>;
|
||||
const before = await positions();
|
||||
const treeNodes = page.locator("[data-tree-id]");
|
||||
const targetId = await page.locator('[data-node-type="key"]').first().getAttribute("data-node-id");
|
||||
await treeNodes.nth(0).click();
|
||||
await treeNodes.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "クラスター化" }).click();
|
||||
const clustered = await positions();
|
||||
for (const [id, point] of Object.entries(before)) {
|
||||
expect(clustered[id]?.[0]).toBeCloseTo(point[0], 0);
|
||||
expect(clustered[id]?.[1]).toBeCloseTo(point[1], 0);
|
||||
}
|
||||
|
||||
const target = page.locator(`[data-node-id="${targetId}"]`).first();
|
||||
const box = await target.boundingBox();
|
||||
if (!box) throw new Error("Target key bounds missing");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
const after = await positions();
|
||||
for (const id of Object.keys(before)) {
|
||||
if (id === targetId) continue;
|
||||
expect(after[id]?.[0]).toBeCloseTo(before[id]![0]!, 0);
|
||||
expect(after[id]?.[1]).toBeCloseTo(before[id]![1]!, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("variant deletion uses the reference review and removes IDX references", async ({ page }) => {
|
||||
const initialKey = page.locator('[data-node-type="key"]').first();
|
||||
const keyId = await initialKey.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${keyId}"]`).click();
|
||||
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
|
||||
const controls = page.locator(".canvas-head label").last();
|
||||
await controls.locator("button").first().click(); // Add a third Variant so deletion can proceed.
|
||||
await controls.locator("button").last().click(); // Request deletion of the selected/default Variant.
|
||||
await expect(page.locator(".confirm-box")).toContainText("削除される参照");
|
||||
await expect(page.locator(".confirm-box")).toContainText("idx");
|
||||
await page.getByRole("button", { name: "参照と一緒に削除" }).click();
|
||||
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||
await expect(page.getByTestId(`idx-row-${keyId}`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("multi-key rotation is one undo step", async ({ page }) => {
|
||||
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
|
||||
const keys = page.locator('[data-node-type="key"]');
|
||||
const ids = [
|
||||
await keys.nth(0).getAttribute("data-node-id"),
|
||||
await keys.nth(1).getAttribute("data-node-id"),
|
||||
];
|
||||
const before = await Promise.all(
|
||||
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
|
||||
);
|
||||
const tree = page.locator("[data-tree-id]");
|
||||
await tree.nth(0).click();
|
||||
await tree.nth(1).click({ modifiers: ["Shift"] });
|
||||
await page.getByRole("button", { name: "選択を+15°回転" }).click();
|
||||
const after = await Promise.all(
|
||||
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
|
||||
);
|
||||
expect(after).not.toEqual(before);
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
const undone = await Promise.all(
|
||||
ids.map((id) => page.locator(`[data-node-id="${id}"]`).getAttribute("transform")),
|
||||
);
|
||||
expect(undone).toEqual(before);
|
||||
await keys.first().click();
|
||||
const outline = page.locator('[data-node-type="key"] .sel').first();
|
||||
await expect(outline).toBeVisible();
|
||||
expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("2px");
|
||||
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 = group.locator(".badge");
|
||||
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("unsupported versions are rejected; save-blocked projects stay open and mismatches block panel edits", async ({
|
||||
page,
|
||||
}) => {
|
||||
const first = page.locator('[data-node-type="key"]').first();
|
||||
const firstId = await first.getAttribute("data-node-id");
|
||||
await page.locator(`[data-tree-id="${firstId}"]`).click();
|
||||
await page.getByRole("button", { name: "選択からVariantGroup" }).click();
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||
]);
|
||||
const filepath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(filepath);
|
||||
const fs = await import("node:fs/promises");
|
||||
const original = JSON.parse(await fs.readFile(filepath, "utf8"));
|
||||
const load = async (value: unknown, name: string) => {
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||
await (
|
||||
await chooserPromise
|
||||
).setFiles({
|
||||
name,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(value)),
|
||||
});
|
||||
};
|
||||
await load({ ...original, version: "0.2.0" }, "unsupported.bkle.json");
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||
await expect(page.locator(".error")).toContainText("Unsupported ProjectFile version");
|
||||
|
||||
const duplicate = structuredClone(original);
|
||||
duplicate.idx.order[0] = "missing-key-reference";
|
||||
await load(duplicate, "missing-reference.bkle.json");
|
||||
await expect(page.getByTestId("save-blocked")).toBeVisible();
|
||||
|
||||
const mismatch = structuredClone(original);
|
||||
mismatch.phy.profiles.push({ ...mismatch.phy.profiles[0], id: "other", unitSize: { x: 2, y: 2 } });
|
||||
await load(mismatch, "unit-mismatch.bkle.json");
|
||||
await expect(page.getByText("unitSize が揃っていないPHYは編集できません。", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("Layout").selectOption({ label: "Option B" });
|
||||
await expect(page.locator('[data-node-type="key"]')).toHaveCount(15);
|
||||
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||
await expect(page.getByLabel("追加するレイヤー名")).toBeDisabled();
|
||||
await expect(page.getByTestId("map-add-layer")).toBeDisabled();
|
||||
await expect(page.getByRole("tab", { name: "Blocked" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("project download can be loaded back", async ({ page }) => {
|
||||
test("削除確認は参照を列挙し、実行後はIDXからも消える", async ({ page }) => {
|
||||
const key = page.locator('[data-node-type="key"]').first();
|
||||
const box = await key.boundingBox();
|
||||
if (!box) throw new Error("Key bounds missing");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 16, box.y + box.height / 2, { steps: 2 });
|
||||
await page.mouse.up();
|
||||
const savedTransform = await key.getAttribute("transform");
|
||||
const 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 filepath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||
await download.saveAs(filepath);
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
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();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles(filepath);
|
||||
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"]').first()).toHaveAttribute("transform", savedTransform!);
|
||||
await expect(page.locator('[data-testid="phy-canvas"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("KLE import creates sixty visible keys", async ({ page }) => {
|
||||
const kle = JSON.parse(await readFile(path.resolve(process.cwd(), "../samples/kle/cr0wn60.json"), "utf8"));
|
||||
const chooserPromise = page.waitForEvent("filechooser");
|
||||
await 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();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles({
|
||||
name: "cr0wn60.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(kle)),
|
||||
});
|
||||
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('[data-node-type="variantGroup"] .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(keys.nth(4).locator(".sel")).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(keys.nth(4).locator(".sel")).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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user