Files
Beyond-KLE/mock/tests/phyLogic.test.ts
T
ewdernaandClaude Opus 5.5 e92708dc12 mock: 選択枠を太くし、最前面の描画層に移す(T-30 単位1)
選択枠と長押し候補の枠を NodeSvg の再帰から外し、ノードの描画より後に描く
PhySelectionLayer に移した。枠はワールド変換とローカル矩形から純粋関数
selectionFrames で求め、画面上3.5pxで描く。部品の選択表示は形状上に残す。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDkA8qMQ3Uf1eY3Ar19vjy
2026-10-21 08:42:14 +09:00

193 lines
6.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
GRID_STEPS,
localBounds,
insertionContainer,
longPressColumn,
nextGrid,
normalizeSelection,
obbAabb,
removeFocusedSubtree,
pasteLocalTransform,
pasteParent,
resolveKey,
snap,
selectionFrames,
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("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));
});
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);
});
});