test(tabletop): cover store actions, interactions, and path sampling

Add tests for the tabletop and interaction stores (seed, surface toggles,
setPart, movePart reindexing/clamping), the interaction store's hold/drop
and dialog stack, and the SVG path sampler (Q/T/S/A commands, unsupported
commands, pointAt edge cases, and zero-length curves). stacking.ts goes
from 61% to 99.5% line coverage.
This commit is contained in:
hyper
2026-08-18 10:30:37 +08:00
parent d0e4269ad6
commit ddf6785f9e
3 changed files with 391 additions and 98 deletions
+130 -50
View File
@@ -1,28 +1,29 @@
import { describe, expect, it } from 'vitest';
import type { Package, Setup } from '@tts/bgm';
import { beforeEach, describe, expect, it } from "vitest";
import type { Package, Setup } from "@tts/bgm";
import {
interactionsFor,
dropPaths,
pickPath,
partFacings,
nextFacing,
} from './interactions.js';
useInteractionStore,
} from "./interactions.js";
const pkg: Package = {
meta: { id: 'harbor' },
meta: { id: "harbor" },
parts: new Map([
['card#a', { type: 'card', id: 'a' }],
['card#b', { type: 'card', id: 'b', facing: ['face', 'back'] }],
["card#a", { type: "card", id: "a" }],
["card#b", { type: "card", id: "b", facing: ["face", "back"] }],
]),
surfaces: new Map([
[
'board#harbor',
"board#harbor",
{
type: 'board',
id: 'harbor',
type: "board",
id: "harbor",
layout: [
{ route: '/deck', x: 0, y: 0, rotation: 0 },
{ route: '/discard', x: 40, y: 0, rotation: 0 },
{ route: "/deck", x: 0, y: 0, rotation: 0 },
{ route: "/discard", x: 40, y: 0, rotation: 0 },
],
},
],
@@ -32,77 +33,156 @@ const pkg: Package = {
};
const setup: Setup = {
type: 'game',
id: 'main',
type: "game",
id: "main",
setup: [],
interactions: [
{ dialog: 'prompt#insert', on: ['/deck'] },
{ dialog: 'prompt#shuffle' },
{ dialog: "prompt#insert", on: ["/deck"] },
{ dialog: "prompt#shuffle" },
],
};
describe('interactionsFor', () => {
it('returns the interactions whose `on` matches the path, plus any without `on`', () => {
expect(interactionsFor(setup, '/deck').map((i) => i.dialog)).toEqual([
'prompt#insert',
'prompt#shuffle',
describe("interactionsFor", () => {
it("returns the interactions whose `on` matches the path, plus any without `on`", () => {
expect(interactionsFor(setup, "/deck").map((i) => i.dialog)).toEqual([
"prompt#insert",
"prompt#shuffle",
]);
});
it('includes interactions without `on` for any path', () => {
expect(interactionsFor(setup, '/discard').map((i) => i.dialog)).toEqual([
'prompt#shuffle',
it("includes interactions without `on` for any path", () => {
expect(interactionsFor(setup, "/discard").map((i) => i.dialog)).toEqual([
"prompt#shuffle",
]);
});
it('returns [] when the setup declares no interactions', () => {
it("returns [] when the setup declares no interactions", () => {
expect(
interactionsFor({ type: 'game', id: 'main', setup: [] }, '/deck'),
interactionsFor({ type: "game", id: "main", setup: [] }, "/deck"),
).toEqual([]);
});
});
describe('dropPaths', () => {
it('uses the declared `on` paths when interactions exist', () => {
expect([...dropPaths(setup, pkg)]).toEqual(['/deck']);
describe("dropPaths", () => {
it("uses the declared `on` paths when interactions exist", () => {
expect([...dropPaths(setup, pkg)]).toEqual(["/deck"]);
});
it('falls back to every literal routed path when no interactions are declared', () => {
const bare: Setup = { type: 'game', id: 'main', setup: [] };
expect([...dropPaths(bare, pkg)].sort()).toEqual(['/deck', '/discard']);
it("falls back to every literal routed path when no interactions are declared", () => {
const bare: Setup = { type: "game", id: "main", setup: [] };
expect([...dropPaths(bare, pkg)].sort()).toEqual(["/deck", "/discard"]);
});
it("skips :param routes in the fallback, since they need a candidate", () => {
const paramPkg: Package = {
...pkg,
surfaces: new Map([
[
"board#harbor",
{
type: "board",
id: "harbor",
layout: [
{ route: "/dock/:seat", x: 0, y: 0, rotation: 0 },
{ route: "/deck", x: 40, y: 0, rotation: 0 },
],
},
],
]),
};
const bare: Setup = { type: "game", id: "main", setup: [] };
expect([...dropPaths(bare, paramPkg)]).toEqual(["/deck"]);
});
});
describe('pickPath', () => {
it('returns the nearest literal route within the threshold', () => {
expect(pickPath(pkg, 'board#harbor', 41, 1, 40)).toBe('/discard');
describe("pickPath", () => {
it("returns the nearest literal route within the threshold", () => {
expect(pickPath(pkg, "board#harbor", 41, 1, 40)).toBe("/discard");
});
it('returns null when nothing is within the threshold', () => {
expect(pickPath(pkg, 'board#harbor', 100, 100, 40)).toBeNull();
it("returns null when nothing is within the threshold", () => {
expect(pickPath(pkg, "board#harbor", 100, 100, 40)).toBeNull();
});
it('returns null for an unknown surface', () => {
expect(pickPath(pkg, 'board#nope', 0, 0, 40)).toBeNull();
it("returns null for an unknown surface", () => {
expect(pickPath(pkg, "board#nope", 0, 0, 40)).toBeNull();
});
it("skips :param routes, which have no fixed anchor", () => {
const paramPkg: Package = {
...pkg,
surfaces: new Map([
[
"board#harbor",
{
type: "board",
id: "harbor",
layout: [
{ route: "/dock/:seat", x: 0, y: 0, rotation: 0 },
{ route: "/deck", x: 40, y: 0, rotation: 0 },
],
},
],
]),
};
expect(pickPath(paramPkg, "board#harbor", 100, 100, 40)).toBeNull();
expect(pickPath(paramPkg, "board#harbor", 41, 1, 40)).toBe("/deck");
});
});
describe('partFacings / nextFacing', () => {
it('defaults to the full set when the part declares none', () => {
expect(partFacings(pkg.parts.get('card#a')!)).toEqual([
'face',
'back',
'standing',
describe("useInteractionStore", () => {
beforeEach(() => {
useInteractionStore.setState({ held: null, dialogs: [] });
});
it("holds and drops a part", () => {
useInteractionStore
.getState()
.hold({ id: "harbor:card#a", origin: "/deck" });
expect(useInteractionStore.getState().held).toEqual({
id: "harbor:card#a",
origin: "/deck",
});
useInteractionStore.getState().drop();
expect(useInteractionStore.getState().held).toBeNull();
});
it("pushes and pops the dialog stack", () => {
const { pushDialog, popDialog } = useInteractionStore.getState();
pushDialog({ id: "prompt#insert", path: "/deck" });
pushDialog({ id: "prompt#shuffle", path: "/deck" });
expect(useInteractionStore.getState().dialogs).toEqual([
{ id: "prompt#insert", path: "/deck" },
{ id: "prompt#shuffle", path: "/deck" },
]);
popDialog();
expect(useInteractionStore.getState().dialogs).toEqual([
{ id: "prompt#insert", path: "/deck" },
]);
});
});
describe("partFacings / nextFacing", () => {
it("defaults to the full set when the part declares none", () => {
expect(partFacings(pkg.parts.get("card#a")!)).toEqual([
"face",
"back",
"standing",
]);
});
it('uses the declared affordance', () => {
expect(partFacings(pkg.parts.get('card#b')!)).toEqual(['face', 'back']);
it("uses the declared affordance", () => {
expect(partFacings(pkg.parts.get("card#b")!)).toEqual(["face", "back"]);
});
it('cycles forward through the affordance', () => {
const part = pkg.parts.get('card#b')!;
expect(nextFacing(part, 'face')).toBe('back');
expect(nextFacing(part, 'back')).toBe('face');
it("cycles forward through the affordance", () => {
const part = pkg.parts.get("card#b")!;
expect(nextFacing(part, "face")).toBe("back");
expect(nextFacing(part, "back")).toBe("face");
});
it("wraps to the first facing after the last", () => {
const part = pkg.parts.get("card#b")!;
expect(nextFacing(part, "back")).toBe("face");
});
});
+153 -46
View File
@@ -1,131 +1,238 @@
import { describe, expect, it } from 'vitest';
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
import { describe, expect, it } from "vitest";
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from "./stacking.js";
describe('parsePath', () => {
it('measures a straight line', () => {
const path = parsePath('M 0 0 L 10 0');
describe("parsePath", () => {
it("measures a straight line", () => {
const path = parsePath("M 0 0 L 10 0");
expect(path.length).toBeCloseTo(10);
});
it('measures a cubic curve', () => {
const path = parsePath('M 0 0 C 20 -20 40 -20 60 0');
it("measures a cubic curve", () => {
const path = parsePath("M 0 0 C 20 -20 40 -20 60 0");
// Longer than the chord (60) but finite.
expect(path.length).toBeGreaterThan(60);
expect(path.length).toBeLessThan(80);
});
it('handles relative commands', () => {
const path = parsePath('m 0 0 l 10 0 l 0 10');
it("handles relative commands", () => {
const path = parsePath("m 0 0 l 10 0 l 0 10");
expect(path.length).toBeCloseTo(20);
});
it('supports h/v/z', () => {
const path = parsePath('M 0 0 H 10 V 10 Z');
it("supports h/v/z", () => {
const path = parsePath("M 0 0 H 10 V 10 Z");
// 10 right + 10 down + the diagonal back to the start (closes the triangle).
expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200));
});
it("supports quadratic curves (Q)", () => {
const path = parsePath("M 0 0 Q 50 50 100 0");
// Longer than the chord (100) but finite.
expect(path.length).toBeGreaterThan(100);
expect(path.length).toBeLessThan(120);
// The curve passes through the midpoint of the control point.
const mid = pointAt(path, path.length / 2);
expect(mid.y).toBeGreaterThan(0);
});
it("supports smooth quadratic continuation (T)", () => {
const path = parsePath("M 0 0 Q 50 50 100 0 T 200 0");
// Two quadratic segments; the second reflects the first's control point.
expect(path.length).toBeGreaterThan(200);
});
it("supports smooth cubic continuation (S)", () => {
const path = parsePath("M 0 0 C 25 50 75 50 100 0 S 175 -50 200 0");
expect(path.length).toBeGreaterThan(200);
});
it("supports elliptical arcs (A)", () => {
// A semicircle of radius 50: length ≈ π * 50.
const path = parsePath("M 0 0 A 50 50 0 0 1 100 0");
expect(path.length).toBeCloseTo(Math.PI * 50, 0);
});
it("supports relative variants of each command", () => {
const path = parsePath("m 0 0 q 50 50 100 0 t 100 0");
expect(path.length).toBeGreaterThan(200);
});
it("throws on an unsupported command", () => {
expect(() => parsePath("M 0 0 R 10 10")).toThrow(
/Unsupported SVG path command/,
);
});
});
describe('pointAt', () => {
it('returns the start at distance 0 and end at full length', () => {
const path = parsePath('M 0 0 L 10 0');
describe("pointAt", () => {
it("returns the start at distance 0 and end at full length", () => {
const path = parsePath("M 0 0 L 10 0");
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
const end = pointAt(path, path.length);
expect(end.x).toBeCloseTo(10);
expect(end.y).toBeCloseTo(0);
});
it('interpolates along the path', () => {
const path = parsePath('M 0 0 L 10 0');
it("interpolates along the path", () => {
const path = parsePath("M 0 0 L 10 0");
const mid = pointAt(path, 5);
expect(mid.x).toBeCloseTo(5);
expect(mid.angle).toBeCloseTo(0);
});
it('returns the tangent angle in degrees', () => {
const path = parsePath('M 0 0 L 10 10');
it("returns the tangent angle in degrees", () => {
const path = parsePath("M 0 0 L 10 10");
expect(pointAt(path, 5).angle).toBeCloseTo(45);
const down = parsePath('M 0 0 L 0 10');
const down = parsePath("M 0 0 L 0 10");
expect(pointAt(down, 5).angle).toBeCloseTo(90);
});
it('returns the tangent angle at the start of the path', () => {
const path = parsePath('M 0 0 L 10 10');
it("returns the tangent angle at the start of the path", () => {
const path = parsePath("M 0 0 L 10 10");
expect(pointAt(path, 0).angle).toBeCloseTo(45);
});
it("clamps distance to the path length", () => {
const path = parsePath("M 0 0 L 10 0");
expect(pointAt(path, 999).x).toBeCloseTo(10);
expect(pointAt(path, -5).x).toBeCloseTo(0);
});
it("returns the origin for an empty path", () => {
expect(pointAt({ points: [], length: 0 }, 5)).toEqual({
x: 0,
y: 0,
angle: 0,
});
});
it("returns the single point for a degenerate path", () => {
const path = parsePath("M 5 5");
expect(pointAt(path, 0)).toEqual({ x: 5, y: 5, angle: 0 });
});
});
describe('stackingOffset', () => {
it('defaults to a 1° tilt without a curve', () => {
expect(stackingOffset(undefined, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
describe("stackingOffset", () => {
it("defaults to a 1° tilt without a curve", () => {
expect(stackingOffset(undefined, 0, 3)).toEqual({
x: 0,
y: 0,
rotation: 0,
z: 0,
tilt: 1,
});
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({
x: 0,
y: 0,
rotation: 0,
z: 0,
tilt: 1,
});
});
it('spreads parts evenly along a straight curve', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3);
it("spreads parts evenly along a straight curve", () => {
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 1, 3);
// step = length / max(steps=1, 2) = 50; part 1 at 50.
expect(offset.x).toBeCloseTo(50);
expect(offset.y).toBeCloseTo(0);
});
it('aligns to center', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3);
it("aligns to center", () => {
const offset = stackingOffset(
{ curve: "M 0 0 L 100 0", align: "center" },
0,
3,
);
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
expect(offset.x).toBeCloseTo(0);
});
it('aligns to end', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3);
it("aligns to end", () => {
const offset = stackingOffset(
{ curve: "M 0 0 L 100 0", align: "end" },
2,
3,
);
// start = 100 - 100 = 0; part 2 at 100.
expect(offset.x).toBeCloseTo(100);
});
it('respects a positive limit (first n)', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: 2 }, 2, 4);
it("respects a positive limit (first n)", () => {
const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: 2 }, 2, 4);
// Part 2 is beyond the first 2 shown -> not placed.
expect(offset).toBe(NO_OFFSET);
});
it('respects a negative limit (last n)', () => {
it("respects a negative limit (last n)", () => {
// Last 2 of 4 are indices 2,3. Part 2 is the first shown.
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', limit: -2 }, 2, 4);
const offset = stackingOffset({ curve: "M 0 0 L 100 0", limit: -2 }, 2, 4);
expect(offset.x).toBeCloseTo(0);
});
it('uses steps to densify the curve', () => {
it("uses steps to densify the curve", () => {
// steps=4, 3 parts -> step = 100 / max(4, 2) = 25.
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', steps: 4 }, 1, 3);
const offset = stackingOffset({ curve: "M 0 0 L 100 0", steps: 4 }, 1, 3);
expect(offset.x).toBeCloseTo(25);
});
it('tilts every part the same amount without a curve', () => {
it("tilts every part the same amount without a curve", () => {
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
});
it('tilts every part the same amount along the curve', () => {
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', tilt: 0.1 }, 1, 3);
it("tilts every part the same amount along the curve", () => {
const offset = stackingOffset({ curve: "M 0 0 L 100 0", tilt: 0.1 }, 1, 3);
expect(offset.x).toBeCloseTo(50);
expect(offset.tilt).toBeCloseTo(0.1);
});
it('tilts only the shown parts', () => {
it("tilts only the shown parts", () => {
// limit 2 shows indices 0,1; index 2 is dropped.
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 2, 4)).toBe(NO_OFFSET);
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1);
});
it('ramps z from zStart to zEnd across the curve', () => {
it("ramps z from zStart to zEnd across the curve", () => {
// 3 parts on a 100-long curve: u = 0, 0.5, 1. z ramps 0 -> 40.
const first = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 0, 3);
const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3);
const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3);
const first = stackingOffset(
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
0,
3,
);
const mid = stackingOffset(
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
1,
3,
);
const last = stackingOffset(
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
2,
3,
);
expect(first.z).toBeCloseTo(0);
expect(mid.z).toBeCloseTo(20);
expect(last.z).toBeCloseTo(40);
});
it('returns no offset for an empty stack', () => {
it("returns no offset for an empty stack", () => {
expect(stackingOffset(undefined, 0, 0)).toBe(NO_OFFSET);
});
it("applies only the default tilt when a curve has zero length", () => {
// A degenerate curve (a single point) has length 0, so no horizontal
// offset applies, but the default 1° tilt still does.
expect(stackingOffset({ curve: "M 5 5" }, 0, 3)).toEqual({
x: 0,
y: 0,
rotation: 0,
z: 0,
tilt: 1,
});
});
it("places a single part at the start of the curve", () => {
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 0, 1);
// span = max(steps=1, 0) = 1; step = 100; part 0 at 0.
expect(offset.x).toBeCloseTo(0);
});
});
+107 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import type { Facing, Package, Surface } from "@tts/bgm";
import {
matchRoute,
@@ -6,6 +6,7 @@ import {
computeSurfacePlacements,
computeRenderState,
placementKey,
useTabletopStore,
} from "./state.js";
function makeSurface(overrides: Partial<Surface> = {}): Surface {
@@ -212,6 +213,111 @@ describe("computeRenderState", () => {
});
});
describe("useTabletopStore", () => {
beforeEach(() => {
useTabletopStore.setState({ surfaces: {}, parts: {} });
});
it("seeds surfaces and parts", () => {
useTabletopStore
.getState()
.seed({ surfaces: { "board#harbor": true }, parts: {} });
expect(useTabletopStore.getState().surfaces).toEqual({
"board#harbor": true,
});
});
it("enables and disables a surface", () => {
useTabletopStore.getState().enableSurface("board#harbor");
expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(true);
useTabletopStore.getState().disableSurface("board#harbor");
expect(useTabletopStore.getState().surfaces["board#harbor"]).toBe(false);
});
it("setPart patches an existing part and ignores an unknown id", () => {
useTabletopStore.getState().setParts({
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
});
useTabletopStore.getState().setPart("harbor:card#a", { facing: "back" });
expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({
path: "/deck",
index: 0,
facing: "back",
});
useTabletopStore.getState().setPart("harbor:card#nope", { facing: "back" });
expect(
useTabletopStore.getState().parts["harbor:card#nope"],
).toBeUndefined();
});
it("movePart reindexes source and destination, inserting at index", () => {
useTabletopStore.getState().setParts({
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
"harbor:card#b": { path: "/deck", index: 1, facing: "face" },
"harbor:card#c": { path: "/deck", index: 2, facing: "face" },
});
// Move the top card to the bottom (index 0).
useTabletopStore.getState().movePart("harbor:card#c", "/deck", 0);
const parts = useTabletopStore.getState().parts;
expect(parts["harbor:card#c"]).toEqual({
path: "/deck",
index: 0,
facing: "face",
});
expect(parts["harbor:card#a"]).toEqual({
path: "/deck",
index: 1,
facing: "face",
});
expect(parts["harbor:card#b"]).toEqual({
path: "/deck",
index: 2,
facing: "face",
});
});
it("movePart moves between paths, closing the source gap", () => {
useTabletopStore.getState().setParts({
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
"harbor:card#b": { path: "/deck", index: 1, facing: "face" },
"harbor:card#c": { path: "/discard", index: 0, facing: "face" },
});
useTabletopStore.getState().movePart("harbor:card#a", "/discard", 0);
const parts = useTabletopStore.getState().parts;
expect(parts["harbor:card#a"]).toEqual({
path: "/discard",
index: 0,
facing: "face",
});
expect(parts["harbor:card#c"]).toEqual({
path: "/discard",
index: 1,
facing: "face",
});
expect(parts["harbor:card#b"]).toEqual({
path: "/deck",
index: 0,
facing: "face",
});
});
it("movePart clamps index and ignores an unknown id", () => {
useTabletopStore.getState().setParts({
"harbor:card#a": { path: "/deck", index: 0, facing: "face" },
});
useTabletopStore.getState().movePart("harbor:card#a", "/deck", 99);
expect(useTabletopStore.getState().parts["harbor:card#a"]).toEqual({
path: "/deck",
index: 0,
facing: "face",
});
useTabletopStore.getState().movePart("harbor:card#nope", "/deck", 0);
expect(
useTabletopStore.getState().parts["harbor:card#nope"],
).toBeUndefined();
});
});
describe("placementKey", () => {
it("is unique per surface and piece", () => {
const a = { surface: "board#harbor", piece: "harbor:card#a" } as never;