Files
Beyond-KLE/mock/tests/phyLogic.test.ts
T
ewdernaandClaude Opus 5.5 0697e7a3b4 mock: バリアントのバッジをノードより後に描き、キーの下に隠れないようにする
バッジは NodeSvg の再帰の中で各バリアントグループの <g> に描いていたため、
後から描かれる兄弟のキーに隠れていた。選択の描画層の後に PhyBadgeLayer を置き、
位置は純粋関数 variantBadges でワールド座標に直して正立で描く。
フォーカス中、フォーカス外のバッジは薄く描き、クリックを受けない。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NznQtmVPoQAnietqwr1auL
2026-10-22 14:24:49 +09:00

233 lines
8.1 KiB
TypeScript

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 });
});
});