import { describe, expect, it } from "vitest"; import { GRID_STEPS, localBounds, insertionContainer, longPressColumn, nextGrid, normalizeSelection, obbAabb, removeFocusedSubtree, pasteLocalTransform, pasteParent, resolveKey, snap, selectionFrames, variantBadges, tabOrder, treeIndex, visibleNodeIds, } from "../src/ui/phy/logic"; import { autoPanVelocity, 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("computes selected frame world transforms through nested rotations", () => { const roots: PhyNode[] = [ { type: "cluster", id: "outer", transform: { x: 10, y: 20, rotation: 90 }, children: [ { type: "cluster", id: "inner", transform: { x: 2, y: 3, rotation: -90 }, children: [ { type: "key", id: "target", profile: "p", transform: { x: 4, y: 5, rotation: 30 }, size: { w: 2, h: 1 }, }, ], }, ], }, ]; const frame = selectionFrames(roots, ["target"], {}, null)[0]!; expect(frame.id).toBe("target"); expect(frame.transform.e).toBeCloseTo(11); expect(frame.transform.f).toBeCloseTo(27); expect(frame.transform.a).toBeCloseTo(Math.cos(Math.PI / 6)); expect(frame.transform.b).toBeCloseTo(Math.sin(Math.PI / 6)); expect(frame.selected).toBe(true); const candidateOnly = selectionFrames(roots, [], {}, "target")[0]!; expect([candidateOnly.selected, candidateOnly.candidate]).toEqual([false, true]); }); it("places variant badges at the rotated group's top-right corner, upright", () => { const key = (id: string): PhyNode => ({ type: "key", id, profile: "p", transform: { x: 0, y: 0, rotation: 0 }, size: { w: 2, h: 1 }, }); const roots: PhyNode[] = [ { type: "variantGroup", id: "g", name: "G", transform: { x: 1, y: 1, rotation: 90 }, default: "a", children: [ { type: "variant", id: "a", name: "A", children: [key("ka")] }, { type: "variant", id: "b", name: "B", children: [] }, ], }, ]; const [badge] = variantBadges(roots, {}, 10); // ローカル (2 + 0.4, -2 - 0.4) を90度回して (1, 1) だけ動かす expect(badge!.group.id).toBe("g"); expect(badge!.x).toBeCloseTo(1 + 2.4); expect(badge!.y).toBeCloseTo(1 + 2.4); expect(badge!.size).toBeCloseTo(2); }); 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); }); it("scales auto-pan speed near edges, outside the canvas, and at corners", () => { const bounds = { x: 10, y: 20, width: 200, height: 100 }; expect(autoPanVelocity({ x: 110, y: 70 }, bounds)).toEqual({ x: 0, y: 0 }); expect(autoPanVelocity({ x: 194, y: 70 }, bounds)).toEqual({ x: 240, y: 0 }); expect(autoPanVelocity({ x: 210, y: 70 }, bounds)).toEqual({ x: 480, y: 0 }); expect(autoPanVelocity({ x: 220, y: 70 }, bounds)).toEqual({ x: 480, y: 0 }); expect(autoPanVelocity({ x: 210, y: 10 }, bounds)).toEqual({ x: 480, y: -480 }); }); });