// 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 { MAX_RADIUS, MIN_RADIUS, radiusForMass, } from "../src/lib/battle-player/mass"; 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 on the horizontal axis (9 vs 3 o'clock)", () => { // Special-case duel layout: two anchors face each other on // the horizontal axis so neither cluster's race label clips // against the SVG top edge. const result = layoutRaces([0, 1], { center, radius }); expect(result).toHaveLength(2); expect(result[0].x).toBeCloseTo(center.x - radius, 5); expect(result[0].y).toBeCloseTo(center.y, 5); expect(result[1].x).toBeCloseTo(center.x + radius, 5); expect(result[1].y).toBeCloseTo(center.y, 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]); }); }); describe("buildFrames phantom-destroy clamp", () => { it("does not drop a race when destroyed shots exceed initial counts", () => { // Race "Phantom" has a single group with 2 ships; the engine // emits five Destroyed shots against it (legacy emitter quirk // reproduced in KNNTS041 planet #7). The group goes to 0 // after two real destroys; the remaining three are phantoms // and must not push raceTotals into negatives or drop the // race from activeRaceIds prematurely. Race "Survivor" keeps // its single ship throughout so it stays active alongside // Phantom until Phantom legitimately empties. const report: BattleReport = { id: "phantom-battle", planet: 1, planetName: "P", races: { "0": "phantom-uuid", "1": "survivor-uuid" }, ships: { "10": { race: "Phantom", className: "Drone", tech: {}, num: 2, numLeft: 0, loadType: "", loadQuantity: 0, inBattle: true, }, "20": { race: "Survivor", className: "Hawk", tech: {}, num: 1, numLeft: 1, loadType: "", loadQuantity: 0, inBattle: true, }, }, protocol: [ { a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #1 { a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #2 → group=0 { a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #1 { a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #2 { a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #3 ], }; const frames = buildFrames(report); expect(frames[2].remaining.get(10)).toBe(0); // After the 2nd real destroy Phantom has 0 ships in its only // group and must drop out of activeRaceIds. expect(frames[2].activeRaceIds).toEqual([1]); // Phantoms past frame 2 must NOT keep decrementing — group // stays at 0, totals don't go negative, and Survivor remains // the only active race for the remainder of the protocol. expect(frames[5].remaining.get(10)).toBe(0); expect(frames[5].activeRaceIds).toEqual([1]); // Phantom flags: first two destroys land on a non-empty // group → real shots; the remaining three are phantoms. expect(frames[1].phantom).toBe(false); expect(frames[2].phantom).toBe(false); expect(frames[3].phantom).toBe(true); expect(frames[4].phantom).toBe(true); expect(frames[5].phantom).toBe(true); // The initial frame is never a phantom. expect(frames[0].phantom).toBe(false); }); it("keeps a race active while phantom destroys hit one of its empty groups", () => { // One race ("Doublet"), two groups of different class. Class // A gets all five Destroyed shots; class B never gets hit. // Class A only has 2 ships → 3 phantoms. The race must stay // active because class B's single ship is intact. const report: BattleReport = { id: "doublet-battle", planet: 2, planetName: "P2", races: { "0": "doublet-uuid", "1": "attacker-uuid" }, ships: { "10": { race: "Doublet", className: "A", tech: {}, num: 2, numLeft: 0, loadType: "", loadQuantity: 0, inBattle: true, }, "11": { race: "Doublet", className: "B", tech: {}, num: 1, numLeft: 1, loadType: "", loadQuantity: 0, inBattle: true, }, "20": { race: "Attacker", className: "Gun", tech: {}, num: 1, numLeft: 1, loadType: "", loadQuantity: 0, inBattle: true, }, }, protocol: [ // Open the protocol with a shot that names class B so // normaliseGroups picks it up (groups never referenced // in the protocol are filtered out of the visual // roster); the shot misses so class B stays intact. { a: 1, sa: 20, d: 0, sd: 11, x: false }, { a: 1, sa: 20, d: 0, sd: 10, x: true }, { a: 1, sa: 20, d: 0, sd: 10, x: true }, { a: 1, sa: 20, d: 0, sd: 10, x: true }, { a: 1, sa: 20, d: 0, sd: 10, x: true }, { a: 1, sa: 20, d: 0, sd: 10, x: true }, ], }; const frames = buildFrames(report); // After all 6 actions: Doublet:A is at 0 (group capped at 2 // real destroys + 3 phantoms), Doublet:B unchanged at 1, so // race totals = 1 → race stays active. expect(frames[6].remaining.get(10)).toBe(0); expect(frames[6].remaining.get(11)).toBe(1); expect(frames[6].activeRaceIds.sort()).toEqual([0, 1]); }); }); describe("radiusForMass", () => { it("returns MAX_RADIUS when mass is zero", () => { expect(radiusForMass(0, 100)).toBe(MAX_RADIUS); }); it("returns MAX_RADIUS when maxMassInBattle is zero", () => { expect(radiusForMass(50, 0)).toBe(MAX_RADIUS); }); it("returns MAX_RADIUS at the per-battle ceiling", () => { expect(radiusForMass(100, 100)).toBeCloseTo(MAX_RADIUS, 5); }); it("scales by sqrt(mass / maxMass): one quarter of max mass = halfway", () => { const r = radiusForMass(25, 100); const expected = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * 0.5; expect(r).toBeCloseTo(expected, 5); }); it("never returns a radius below MIN_RADIUS or above MAX_RADIUS", () => { expect(radiusForMass(1, Number.MAX_SAFE_INTEGER)).toBeGreaterThanOrEqual(MIN_RADIUS); expect(radiusForMass(Number.MAX_SAFE_INTEGER, 1)).toBeLessThanOrEqual(MAX_RADIUS); }); });