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:
2026-10-07 20:54:37 +09:00
co-authored by Claude Opus 5.5
parent beac52c20b
commit 4d1eb23cf8
24 changed files with 3807 additions and 1131 deletions
-12
View File
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { snapFromStart, snappedDelta } from "../src/ui/phy/dragMath";
describe("PHY drag math", () => {
it("snaps from the operation start in quarter-unit increments", () => {
expect(snapFromStart(0.1, 0.28)).toBeCloseTo(0.25);
expect(snapFromStart(0.1, 0.38)).toBeCloseTo(0.5);
});
it("returns snapped local deltas", () => {
expect(snappedDelta({ x: 1, y: 1 }, { x: 1.37, y: 0.61 })).toEqual({ x: 0.25, y: -0.5 });
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import type { PhyFile } from "../src/domain/phy";
import {
addComponentToParent,
addKeyToParent,
cloneNodesWithNewIds,
duplicateNode,
insertNodes,
} from "../src/domain/phyCommands";
const ids = (() => {
let n = 0;
return () => `0000000${++n}-0000-4000-8000-000000000000`;
})();
const file: PhyFile = {
version: "0.1.0",
metadata: {},
profiles: [{ id: "p", name: "P", unitSize: { x: 1, y: 1 } }],
root: [
{ type: "cluster", id: "cl-parent", name: "P", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
],
};
describe("PHY parent insertion and duplication", () => {
it("adds keys and components to root, clusters, or variants", () => {
let p = addKeyToParent(file, "cl-parent", { x: 2, y: 3, rotation: 0 }, { w: 1, h: 1 }, "p", ids);
expect(
p.root[0]!.type === "cluster" &&
(p.root[0]! as import("../src/domain/phy").ClusterNode).children[0]?.type,
).toBe("key");
p = addComponentToParent(p, "cl-parent", { x: 0, y: 0, rotation: 0 }, "@led", ids);
expect(
p.root[0]!.type === "cluster" &&
(p.root[0]! as import("../src/domain/phy").ClusterNode).children[1]?.type,
).toBe("component");
});
it("inserts a copied node batch into a selected container", () => {
const node = {
type: "key" as const,
id: "key-source",
profile: "p",
transform: { x: 1, y: 1, rotation: 0 },
size: { w: 1, h: 1 },
};
const p = insertNodes(file, "cl-parent", [node]);
expect(
p.root[0]!.type === "cluster" && (p.root[0]! as import("../src/domain/phy").ClusterNode).children,
).toHaveLength(1);
});
it("renews IDs and remaps a duplicated group's internal default", () => {
const base: PhyFile = {
...file,
root: [
{
type: "variantGroup",
id: "vg-source",
name: "VG",
transform: { x: 0, y: 0, rotation: 0 },
default: "v-one",
children: [
{ type: "variant", id: "v-one", name: "One", children: [] },
{ type: "variant", id: "v-two", name: "Two", children: [] },
],
},
],
};
const q = duplicateNode(base, "vg-source", ids);
const copy = q.root[1];
expect(copy?.type).toBe("variantGroup");
if (copy?.type === "variantGroup") {
expect(copy.id).not.toBe("vg-source");
expect(copy.default).not.toBe("v-one");
expect(copy.children.map((v) => v.id)).not.toContain("v-one");
expect(copy.default).toBe(copy.children[0]!.id);
}
});
it("remaps defaults for variant groups nested inside a copied cluster", () => {
const nested: PhyFile["root"][number] = {
type: "cluster",
id: "outer",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{
type: "variantGroup",
id: "vg-inner",
name: "Inner",
transform: { x: 1, y: 2, rotation: 0 },
default: "v-inner-a",
children: [
{ type: "variant", id: "v-inner-a", name: "A", children: [] },
{ type: "variant", id: "v-inner-b", name: "B", children: [] },
],
},
],
};
const copy = cloneNodesWithNewIds([nested], ids)[0];
expect(copy?.type).toBe("cluster");
if (copy?.type === "cluster") {
const group = copy.children[0];
expect(group?.type).toBe("variantGroup");
if (group?.type === "variantGroup") {
expect(group.default).toBe(group.children[0]!.id);
expect(group.default).not.toBe("v-inner-a");
}
}
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import {
GRID_STEPS,
localBounds,
insertionContainer,
longPressColumn,
nextGrid,
normalizeSelection,
obbAabb,
removeFocusedSubtree,
pasteLocalTransform,
pasteParent,
resolveKey,
snap,
tabOrder,
treeIndex,
visibleNodeIds,
} from "../src/ui/phy/logic";
import { fitViewport, zoomAround } from "../src/ui/phy/viewport";
import type { PhyFile, Node as PhyNode } from "../src/domain/phy";
const phy: PhyFile = {
version: "0.1.0",
metadata: {},
profiles: [],
root: [
{
type: "cluster",
id: "c",
transform: { x: 0, y: 0, rotation: 0 },
children: [
{ type: "key", id: "k", profile: "p", transform: { x: 0, y: 0, rotation: 0 }, size: { w: 1, h: 1 } },
],
},
],
};
describe("PHY pure interaction rules", () => {
it("removes ancestors and descendants from mixed selections", () => {
expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]);
expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);
const outside = removeFocusedSubtree(phy.root, "k");
expect(treeIndex(outside).nodes.has("c")).toBe(true);
expect(treeIndex(outside).nodes.has("k")).toBe(false);
expect(outside[0]?.type === "cluster" ? outside[0].children : []).toEqual([]);
});
it("cycles long press candidates and stops before the focus parent", () => {
expect(longPressColumn("k", ["c", "root"], "c")).toEqual(["k"]);
expect(longPressColumn("k", ["c", "root"])).toEqual(["k", "c", "root"]);
});
it("orders rows with a half unit tolerance", () => {
expect(
tabOrder([
{ id: "b", x: 2, y: 0.4 },
{ id: "a", x: 1, y: 0 },
{ id: "c", x: 0, y: 1 },
]).map((x) => x.id),
).toEqual(["a", "b", "c"]);
});
it("computes bounds and paste target", () => {
expect(
obbAabb([
{ x: 2, y: 3 },
{ x: -1, y: 5 },
]),
).toEqual({ x: -1, y: 3, width: 3, height: 2 });
expect(pasteParent([], "c")).toBe("c");
expect(pasteParent(["p"], null)).toBe("p");
expect(pasteParent([null], "c")).toBeNull();
});
it("computes child bounds in parent axes and converts paste world position", () => {
const group: PhyNode = {
type: "cluster",
id: "g",
transform: { x: 10, y: 20, rotation: 90 },
children: [
{
type: "key",
id: "key2",
profile: "p",
transform: { x: 2, y: 3, rotation: 0 },
size: { w: 1, h: 2 },
},
],
};
expect(localBounds(group.type === "cluster" ? group.children : [])).toEqual({
x: 2,
y: 3,
width: 1,
height: 2,
});
expect(
pasteLocalTransform(
{ a: 0, b: 1, c: -1, d: 0, e: 10, f: 20 },
{ a: 0, b: 1, c: -1, d: 0, e: 8, f: 18 },
0.25,
),
).toEqual({ x: 2.25, y: -1.75, rotation: 0 });
});
it("distinguishes selectable parents from variant insertion containers without ID prefixes", () => {
const roots: PhyNode[] = [
{
type: "variantGroup",
id: "group-any-id",
name: "Layout",
transform: { x: 0, y: 0, rotation: 0 },
default: "option-any-id",
children: [
{
type: "variant",
id: "option-any-id",
name: "Main",
children: [
{
type: "key",
id: "key-any-id",
profile: "p",
transform: { x: 0, y: 0, rotation: 0 },
size: { w: 1, h: 1 },
},
],
},
],
},
];
const index = treeIndex(roots);
expect(index.selectableParent("key-any-id")).toBe("group-any-id");
expect(index.insertionContainer("key-any-id")).toBe("option-any-id");
expect(index.groupForVariant("option-any-id")).toBe("group-any-id");
expect(insertionContainer(roots, "group-any-id", { "group-any-id": "option-any-id" })).toBe(
"option-any-id",
);
expect(visibleNodeIds(roots[0], {}).has("key-any-id")).toBe(true);
const outside = removeFocusedSubtree(roots, "key-any-id");
expect(treeIndex(outside).nodes.has("key-any-id")).toBe(false);
expect(treeIndex(outside).nodes.has("group-any-id")).toBe(true);
});
it("snaps without changing off-grid direct numeric values and cycles steps", () => {
expect(snap(0.37, 0.25)).toBe(0.25);
expect(nextGrid(0.25, 1)).toBe(0.125);
expect(GRID_STEPS).toHaveLength(4);
});
it("resolves keyboard layout independent digit shortcuts and modifiers", () => {
expect(
resolveKey({ key: "@", code: "Digit1", shiftKey: true, ctrlKey: false, metaKey: false, altKey: false }),
).toBe("fit");
expect(
resolveKey({ key: "z", code: "KeyZ", shiftKey: true, ctrlKey: true, metaKey: false, altKey: false }),
).toBe("redo");
});
it("fits bounds centrally with padding and zooms around the cursor", () => {
const fitted = fitViewport({ x: 10, y: 20, width: 4, height: 2 }, 400, 300, 20);
expect(fitted.zoom).toBe(90);
expect(fitted.pan.x + (10 + 2) * fitted.zoom).toBeCloseTo(200);
expect(fitted.pan.y + (20 + 1) * fitted.zoom).toBeCloseTo(150);
const cursor = { x: 140, y: 160 },
world = { x: (cursor.x - fitted.pan.x) / fitted.zoom, y: (cursor.y - fitted.pan.y) / fitted.zoom },
zoomed = zoomAround(cursor, fitted, 1.5);
expect((cursor.x - zoomed.pan.x) / zoomed.zoom).toBeCloseTo(world.x);
expect((cursor.y - zoomed.pan.y) / zoomed.zoom).toBeCloseTo(world.y);
});
});