// Unit tests for the BattleViewer's pure helpers: radial layout and // the timeline frame builder. Both are pure functions and don't // require DOM mounting, so they exercise the playback semantics in // isolation. import { describe, expect, it } from "vitest"; import type { BattleReport } from "../src/api/battle-fetch"; import { layoutRaces } from "../src/lib/battle-player/radial-layout"; import { buildFrames, buildGroupRaceMap, normaliseGroups, } from "../src/lib/battle-player/timeline"; describe("layoutRaces", () => { const center = { x: 100, y: 100 }; const radius = 50; it("returns no anchors for an empty input", () => { expect(layoutRaces([], { center, radius })).toEqual([]); }); it("places one race at the 12 o'clock position", () => { const result = layoutRaces([0], { center, radius }); expect(result).toHaveLength(1); expect(result[0].raceId).toBe(0); expect(result[0].x).toBeCloseTo(center.x, 5); expect(result[0].y).toBeCloseTo(center.y - radius, 5); }); it("places two races at opposite poles (180° apart)", () => { const result = layoutRaces([0, 1], { center, radius }); expect(result).toHaveLength(2); expect(result[0].x).toBeCloseTo(center.x, 5); expect(result[0].y).toBeCloseTo(center.y - radius, 5); expect(result[1].x).toBeCloseTo(center.x, 5); expect(result[1].y).toBeCloseTo(center.y + radius, 5); }); it("places three races at 120° intervals", () => { const result = layoutRaces([0, 1, 2], { center, radius }); expect(result).toHaveLength(3); expect(result[0].angle).toBeCloseTo(-Math.PI / 2, 5); expect(result[1].angle - result[0].angle).toBeCloseTo((2 * Math.PI) / 3, 5); expect(result[2].angle - result[1].angle).toBeCloseTo((2 * Math.PI) / 3, 5); }); it("preserves the input race order", () => { const result = layoutRaces([7, 2, 5], { center, radius }); expect(result.map((a) => a.raceId)).toEqual([7, 2, 5]); }); }); const TWO_RACE_BATTLE: BattleReport = { id: "battle-1", planet: 4, planetName: "Test", races: { "0": "race-A-uuid", "1": "race-B-uuid" }, ships: { "10": { race: "Alpha", className: "Drone", tech: {}, num: 3, numLeft: 1, loadType: "EMP", loadQuantity: 0, inBattle: true, }, "20": { race: "Beta", className: "Spy", tech: {}, num: 2, numLeft: 0, loadType: "EMP", loadQuantity: 0, inBattle: true, }, "99": { race: "Gamma", className: "Observer", tech: {}, num: 4, numLeft: 4, loadType: "EMP", loadQuantity: 0, inBattle: false, }, }, protocol: [ { a: 0, sa: 10, d: 1, sd: 20, x: false }, { a: 1, sa: 20, d: 0, sd: 10, x: true }, { a: 0, sa: 10, d: 1, sd: 20, x: true }, { a: 0, sa: 10, d: 1, sd: 20, x: true }, ], }; describe("buildGroupRaceMap", () => { it("derives group → race from protocol entries", () => { const map = buildGroupRaceMap(TWO_RACE_BATTLE.protocol); expect(map.get(10)).toBe(0); expect(map.get(20)).toBe(1); }); }); describe("normaliseGroups", () => { it("returns only in-battle groups with race index attached", () => { const groups = normaliseGroups(TWO_RACE_BATTLE); expect(groups.map((g) => g.key).sort((a, b) => a - b)).toEqual([10, 20]); expect(groups.every((g) => g.group.inBattle)).toBe(true); }); }); describe("buildFrames", () => { it("produces protocol.length + 1 frames", () => { const frames = buildFrames(TWO_RACE_BATTLE); expect(frames).toHaveLength(TWO_RACE_BATTLE.protocol.length + 1); }); it("frame 0 reports initial ship counts and all active races", () => { const [first] = buildFrames(TWO_RACE_BATTLE); expect(first.shotIndex).toBe(0); expect(first.lastAction).toBeNull(); expect(first.remaining.get(10)).toBe(3); expect(first.remaining.get(20)).toBe(2); expect(first.activeRaceIds).toEqual([0, 1]); }); it("decrements destroyed defenders only on x === true", () => { const frames = buildFrames(TWO_RACE_BATTLE); // Action 1: x=false → no decrement on defender 20. expect(frames[1].remaining.get(20)).toBe(2); // Action 2: x=true → attacker is race 1 group 20, defender // is race 0 group 10 → group 10 drops 3→2. expect(frames[2].remaining.get(10)).toBe(2); }); it("drops a race from activeRaceIds once its last in-battle group reaches zero", () => { const frames = buildFrames(TWO_RACE_BATTLE); // After the 4-th action both Beta ships have been destroyed. expect(frames[4].remaining.get(20)).toBe(0); expect(frames[4].activeRaceIds).toEqual([0]); }); });