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
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
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);
|
|
});
|
|
});
|