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:
@@ -1,28 +1,29 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { Package, Setup } from '@tts/bgm';
|
import type { Package, Setup } from "@tts/bgm";
|
||||||
import {
|
import {
|
||||||
interactionsFor,
|
interactionsFor,
|
||||||
dropPaths,
|
dropPaths,
|
||||||
pickPath,
|
pickPath,
|
||||||
partFacings,
|
partFacings,
|
||||||
nextFacing,
|
nextFacing,
|
||||||
} from './interactions.js';
|
useInteractionStore,
|
||||||
|
} from "./interactions.js";
|
||||||
|
|
||||||
const pkg: Package = {
|
const pkg: Package = {
|
||||||
meta: { id: 'harbor' },
|
meta: { id: "harbor" },
|
||||||
parts: new Map([
|
parts: new Map([
|
||||||
['card#a', { type: 'card', id: 'a' }],
|
["card#a", { type: "card", id: "a" }],
|
||||||
['card#b', { type: 'card', id: 'b', facing: ['face', 'back'] }],
|
["card#b", { type: "card", id: "b", facing: ["face", "back"] }],
|
||||||
]),
|
]),
|
||||||
surfaces: new Map([
|
surfaces: new Map([
|
||||||
[
|
[
|
||||||
'board#harbor',
|
"board#harbor",
|
||||||
{
|
{
|
||||||
type: 'board',
|
type: "board",
|
||||||
id: 'harbor',
|
id: "harbor",
|
||||||
layout: [
|
layout: [
|
||||||
{ route: '/deck', x: 0, y: 0, rotation: 0 },
|
{ route: "/deck", x: 0, y: 0, rotation: 0 },
|
||||||
{ route: '/discard', x: 40, y: 0, rotation: 0 },
|
{ route: "/discard", x: 40, y: 0, rotation: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -32,77 +33,156 @@ const pkg: Package = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setup: Setup = {
|
const setup: Setup = {
|
||||||
type: 'game',
|
type: "game",
|
||||||
id: 'main',
|
id: "main",
|
||||||
setup: [],
|
setup: [],
|
||||||
interactions: [
|
interactions: [
|
||||||
{ dialog: 'prompt#insert', on: ['/deck'] },
|
{ dialog: "prompt#insert", on: ["/deck"] },
|
||||||
{ dialog: 'prompt#shuffle' },
|
{ dialog: "prompt#shuffle" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('interactionsFor', () => {
|
describe("interactionsFor", () => {
|
||||||
it('returns the interactions whose `on` matches the path, plus any without `on`', () => {
|
it("returns the interactions whose `on` matches the path, plus any without `on`", () => {
|
||||||
expect(interactionsFor(setup, '/deck').map((i) => i.dialog)).toEqual([
|
expect(interactionsFor(setup, "/deck").map((i) => i.dialog)).toEqual([
|
||||||
'prompt#insert',
|
"prompt#insert",
|
||||||
'prompt#shuffle',
|
"prompt#shuffle",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes interactions without `on` for any path', () => {
|
it("includes interactions without `on` for any path", () => {
|
||||||
expect(interactionsFor(setup, '/discard').map((i) => i.dialog)).toEqual([
|
expect(interactionsFor(setup, "/discard").map((i) => i.dialog)).toEqual([
|
||||||
'prompt#shuffle',
|
"prompt#shuffle",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns [] when the setup declares no interactions', () => {
|
it("returns [] when the setup declares no interactions", () => {
|
||||||
expect(
|
expect(
|
||||||
interactionsFor({ type: 'game', id: 'main', setup: [] }, '/deck'),
|
interactionsFor({ type: "game", id: "main", setup: [] }, "/deck"),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dropPaths', () => {
|
describe("dropPaths", () => {
|
||||||
it('uses the declared `on` paths when interactions exist', () => {
|
it("uses the declared `on` paths when interactions exist", () => {
|
||||||
expect([...dropPaths(setup, pkg)]).toEqual(['/deck']);
|
expect([...dropPaths(setup, pkg)]).toEqual(["/deck"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to every literal routed path when no interactions are declared', () => {
|
it("falls back to every literal routed path when no interactions are declared", () => {
|
||||||
const bare: Setup = { type: 'game', id: 'main', setup: [] };
|
const bare: Setup = { type: "game", id: "main", setup: [] };
|
||||||
expect([...dropPaths(bare, pkg)].sort()).toEqual(['/deck', '/discard']);
|
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', () => {
|
describe("pickPath", () => {
|
||||||
it('returns the nearest literal route within the threshold', () => {
|
it("returns the nearest literal route within the threshold", () => {
|
||||||
expect(pickPath(pkg, 'board#harbor', 41, 1, 40)).toBe('/discard');
|
expect(pickPath(pkg, "board#harbor", 41, 1, 40)).toBe("/discard");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when nothing is within the threshold', () => {
|
it("returns null when nothing is within the threshold", () => {
|
||||||
expect(pickPath(pkg, 'board#harbor', 100, 100, 40)).toBeNull();
|
expect(pickPath(pkg, "board#harbor", 100, 100, 40)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for an unknown surface', () => {
|
it("returns null for an unknown surface", () => {
|
||||||
expect(pickPath(pkg, 'board#nope', 0, 0, 40)).toBeNull();
|
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', () => {
|
describe("useInteractionStore", () => {
|
||||||
it('defaults to the full set when the part declares none', () => {
|
beforeEach(() => {
|
||||||
expect(partFacings(pkg.parts.get('card#a')!)).toEqual([
|
useInteractionStore.setState({ held: null, dialogs: [] });
|
||||||
'face',
|
});
|
||||||
'back',
|
|
||||||
'standing',
|
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', () => {
|
it("uses the declared affordance", () => {
|
||||||
expect(partFacings(pkg.parts.get('card#b')!)).toEqual(['face', 'back']);
|
expect(partFacings(pkg.parts.get("card#b")!)).toEqual(["face", "back"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cycles forward through the affordance', () => {
|
it("cycles forward through the affordance", () => {
|
||||||
const part = pkg.parts.get('card#b')!;
|
const part = pkg.parts.get("card#b")!;
|
||||||
expect(nextFacing(part, 'face')).toBe('back');
|
expect(nextFacing(part, "face")).toBe("back");
|
||||||
expect(nextFacing(part, 'back')).toBe('face');
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,131 +1,238 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from './stacking.js';
|
import { stackingOffset, parsePath, pointAt, NO_OFFSET } from "./stacking.js";
|
||||||
|
|
||||||
describe('parsePath', () => {
|
describe("parsePath", () => {
|
||||||
it('measures a straight line', () => {
|
it("measures a straight line", () => {
|
||||||
const path = parsePath('M 0 0 L 10 0');
|
const path = parsePath("M 0 0 L 10 0");
|
||||||
expect(path.length).toBeCloseTo(10);
|
expect(path.length).toBeCloseTo(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('measures a cubic curve', () => {
|
it("measures a cubic curve", () => {
|
||||||
const path = parsePath('M 0 0 C 20 -20 40 -20 60 0');
|
const path = parsePath("M 0 0 C 20 -20 40 -20 60 0");
|
||||||
// Longer than the chord (60) but finite.
|
// Longer than the chord (60) but finite.
|
||||||
expect(path.length).toBeGreaterThan(60);
|
expect(path.length).toBeGreaterThan(60);
|
||||||
expect(path.length).toBeLessThan(80);
|
expect(path.length).toBeLessThan(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles relative commands', () => {
|
it("handles relative commands", () => {
|
||||||
const path = parsePath('m 0 0 l 10 0 l 0 10');
|
const path = parsePath("m 0 0 l 10 0 l 0 10");
|
||||||
expect(path.length).toBeCloseTo(20);
|
expect(path.length).toBeCloseTo(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports h/v/z', () => {
|
it("supports h/v/z", () => {
|
||||||
const path = parsePath('M 0 0 H 10 V 10 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).
|
// 10 right + 10 down + the diagonal back to the start (closes the triangle).
|
||||||
expect(path.length).toBeCloseTo(10 + 10 + Math.sqrt(200));
|
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', () => {
|
describe("pointAt", () => {
|
||||||
it('returns the start at distance 0 and end at full length', () => {
|
it("returns the start at distance 0 and end at full length", () => {
|
||||||
const path = parsePath('M 0 0 L 10 0');
|
const path = parsePath("M 0 0 L 10 0");
|
||||||
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
|
expect(pointAt(path, 0)).toMatchObject({ x: 0, y: 0 });
|
||||||
const end = pointAt(path, path.length);
|
const end = pointAt(path, path.length);
|
||||||
expect(end.x).toBeCloseTo(10);
|
expect(end.x).toBeCloseTo(10);
|
||||||
expect(end.y).toBeCloseTo(0);
|
expect(end.y).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('interpolates along the path', () => {
|
it("interpolates along the path", () => {
|
||||||
const path = parsePath('M 0 0 L 10 0');
|
const path = parsePath("M 0 0 L 10 0");
|
||||||
const mid = pointAt(path, 5);
|
const mid = pointAt(path, 5);
|
||||||
expect(mid.x).toBeCloseTo(5);
|
expect(mid.x).toBeCloseTo(5);
|
||||||
expect(mid.angle).toBeCloseTo(0);
|
expect(mid.angle).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the tangent angle in degrees', () => {
|
it("returns the tangent angle in degrees", () => {
|
||||||
const path = parsePath('M 0 0 L 10 10');
|
const path = parsePath("M 0 0 L 10 10");
|
||||||
expect(pointAt(path, 5).angle).toBeCloseTo(45);
|
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);
|
expect(pointAt(down, 5).angle).toBeCloseTo(90);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the tangent angle at the start of the path', () => {
|
it("returns the tangent angle at the start of the path", () => {
|
||||||
const path = parsePath('M 0 0 L 10 10');
|
const path = parsePath("M 0 0 L 10 10");
|
||||||
expect(pointAt(path, 0).angle).toBeCloseTo(45);
|
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', () => {
|
describe("stackingOffset", () => {
|
||||||
it('defaults to a 1° tilt without a curve', () => {
|
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(undefined, 0, 3)).toEqual({
|
||||||
expect(stackingOffset({ limit: 5 }, 0, 3)).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 1 });
|
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', () => {
|
it("spreads parts evenly along a straight curve", () => {
|
||||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0' }, 1, 3);
|
const offset = stackingOffset({ curve: "M 0 0 L 100 0" }, 1, 3);
|
||||||
// step = length / max(steps=1, 2) = 50; part 1 at 50.
|
// step = length / max(steps=1, 2) = 50; part 1 at 50.
|
||||||
expect(offset.x).toBeCloseTo(50);
|
expect(offset.x).toBeCloseTo(50);
|
||||||
expect(offset.y).toBeCloseTo(0);
|
expect(offset.y).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aligns to center', () => {
|
it("aligns to center", () => {
|
||||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'center' }, 0, 3);
|
const offset = stackingOffset(
|
||||||
|
{ curve: "M 0 0 L 100 0", align: "center" },
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
);
|
||||||
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
|
// span = 50 * 2 = 100; centered start = (100 - 100)/2 = 0.
|
||||||
expect(offset.x).toBeCloseTo(0);
|
expect(offset.x).toBeCloseTo(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aligns to end', () => {
|
it("aligns to end", () => {
|
||||||
const offset = stackingOffset({ curve: 'M 0 0 L 100 0', align: 'end' }, 2, 3);
|
const offset = stackingOffset(
|
||||||
|
{ curve: "M 0 0 L 100 0", align: "end" },
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
);
|
||||||
// start = 100 - 100 = 0; part 2 at 100.
|
// start = 100 - 100 = 0; part 2 at 100.
|
||||||
expect(offset.x).toBeCloseTo(100);
|
expect(offset.x).toBeCloseTo(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('respects a positive limit (first n)', () => {
|
it("respects a positive limit (first n)", () => {
|
||||||
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);
|
||||||
// Part 2 is beyond the first 2 shown -> not placed.
|
// Part 2 is beyond the first 2 shown -> not placed.
|
||||||
expect(offset).toBe(NO_OFFSET);
|
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.
|
// 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);
|
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.
|
// 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);
|
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);
|
const offset = stackingOffset({ tilt: 0.1 }, 2, 3);
|
||||||
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
|
expect(offset).toEqual({ x: 0, y: 0, rotation: 0, z: 0, tilt: 0.1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tilts every part the same amount along the curve', () => {
|
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);
|
const offset = stackingOffset({ curve: "M 0 0 L 100 0", tilt: 0.1 }, 1, 3);
|
||||||
expect(offset.x).toBeCloseTo(50);
|
expect(offset.x).toBeCloseTo(50);
|
||||||
expect(offset.tilt).toBeCloseTo(0.1);
|
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.
|
// 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 }, 2, 4)).toBe(NO_OFFSET);
|
||||||
expect(stackingOffset({ tilt: 0.1, limit: 2 }, 1, 4).tilt).toBeCloseTo(0.1);
|
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.
|
// 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 first = stackingOffset(
|
||||||
const mid = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 1, 3);
|
{ curve: "M 0 0 L 100 0", zStart: 0, zEnd: 40 },
|
||||||
const last = stackingOffset({ curve: 'M 0 0 L 100 0', zStart: 0, zEnd: 40 }, 2, 3);
|
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(first.z).toBeCloseTo(0);
|
||||||
expect(mid.z).toBeCloseTo(20);
|
expect(mid.z).toBeCloseTo(20);
|
||||||
expect(last.z).toBeCloseTo(40);
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 type { Facing, Package, Surface } from "@tts/bgm";
|
||||||
import {
|
import {
|
||||||
matchRoute,
|
matchRoute,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
computeSurfacePlacements,
|
computeSurfacePlacements,
|
||||||
computeRenderState,
|
computeRenderState,
|
||||||
placementKey,
|
placementKey,
|
||||||
|
useTabletopStore,
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
|
|
||||||
function makeSurface(overrides: Partial<Surface> = {}): Surface {
|
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", () => {
|
describe("placementKey", () => {
|
||||||
it("is unique per surface and piece", () => {
|
it("is unique per surface and piece", () => {
|
||||||
const a = { surface: "board#harbor", piece: "harbor:card#a" } as never;
|
const a = { surface: "board#harbor", piece: "harbor:card#a" } as never;
|
||||||
|
|||||||
Reference in New Issue
Block a user