Files
galaxy-game/ui/frontend/tests/synthetic-report.test.ts
T
Ilia Denisov 9e9977d5f1
Tests · Go / test (push) Successful in 2m29s
Tests · UI / test (push) Waiting to run
Tests · Go / test (pull_request) Successful in 2m17s
Tests · Integration / integration (pull_request) Successful in 1m53s
Tests · UI / test (pull_request) Successful in 3m37s
feat(game): race exit warnings in the turn report (#12)
Surface the inactivity-removal countdown the rules promise but the
engine never reported. A race within five turns of being auto-removed
for inactivity gets a personal warning in its own report; every race
within three turns is listed publicly to all participants.

- model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice)
- fbs: RaceExitNotice table + Report.personal_exit_warning /
  races_leaving_soon (regenerated Go + TS bindings)
- transcoder: encode/decode both fields
- engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists
  other non-extinct races with TTL 1..3, excluding the recipient itself
- ui: danger-styled personal banner + "races leaving soon" section
  (hidden when empty), wired into the report view, EN/RU i18n
- docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror

Voluntary quit and idle timeout share the TTL countdown and are not
distinguished, per the agreed scope.
2026-05-31 10:34:50 +02:00

323 lines
8.5 KiB
TypeScript

// Vitest unit coverage for `api/synthetic-report.ts`. The decoder
// mirrors `pkg/model/report.Report` JSON (as emitted by the Go CLI
// `tools/local-dev/legacy-report/cmd/legacy-report-to-json`) into the
// in-game-shell `GameReport` shape. The tests assert the decoder
// flattens all four planet kinds, looks the local player's tech
// levels up by race name, defaults missing routes to empty, and
// rejects malformed input.
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import {
SYNTHETIC_GAME_ID_PREFIX,
SyntheticReportError,
getSyntheticReport,
isSyntheticGameId,
loadSyntheticReportFromJSON,
} from "../src/api/synthetic-report";
import type { BattleReport } from "../src/api/battle-fetch";
import {
lookupSyntheticBattle,
resetSyntheticBattles,
} from "../src/api/synthetic-battle";
function syntheticJSON(extra: Record<string, unknown> = {}): unknown {
return {
turn: 39,
mapWidth: 800,
mapHeight: 800,
mapPlanets: 700,
race: "KnightErrants",
votes: 16.02,
voteFor: "KnightErrants",
player: [
{
name: "KnightErrants",
drive: 13.25,
weapons: 6.11,
shields: 7.09,
cargo: 1,
population: 16015.04,
industry: 13668.76,
planets: 22,
relation: "-",
votes: 16.02,
extinct: false,
},
{
name: "Other",
drive: 9.5,
weapons: 4.01,
shields: 4.69,
cargo: 1,
population: 0,
industry: 0,
planets: 0,
relation: "War",
votes: 0,
extinct: false,
},
],
localPlanet: [
{
number: 17,
name: "Castle",
x: 171.05,
y: 700.24,
size: 1000,
population: 1000,
industry: 1000,
resources: 10,
production: "Drive_Research",
capital: 0,
material: 0.68,
colonists: 88.78,
freeIndustry: 1000,
},
],
otherPlanet: [
{
owner: "Monstrai",
number: 12,
name: "Skarabei",
x: 303.84,
y: 579.23,
size: 500,
population: 500,
industry: 500,
resources: 10,
production: "Capital",
capital: 0,
material: 70.99,
colonists: 20.03,
freeIndustry: 341.78,
},
],
uninhabitedPlanet: [
{
number: 9,
name: "Dw2",
x: 117.87,
y: 795.21,
size: 500,
resources: 10,
capital: 0,
material: 500,
},
],
unidentifiedPlanet: [
{ number: 0, x: 738.08, y: 600.26 },
{ number: 1, x: 579.12, y: 489.37 },
],
localShipClass: [
{
name: "Frontier",
drive: 11.37,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
mass: 12.37,
},
],
...extra,
};
}
describe("loadSyntheticReportFromJSON", () => {
test("flattens all four planet kinds with kind-specific nullables", () => {
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(isSyntheticGameId(gameId)).toBe(true);
expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true);
expect(report.turn).toBe(39);
expect(report.mapWidth).toBe(800);
expect(report.mapHeight).toBe(800);
expect(report.planetCount).toBe(700);
expect(report.race).toBe("KnightErrants");
expect(report.planets).toHaveLength(5);
const local = report.planets.find((p) => p.kind === "local")!;
expect(local.name).toBe("Castle");
expect(local.industryStockpile).toBe(0);
expect(local.materialsStockpile).toBe(0.68);
expect(local.industry).toBe(1000);
expect(local.production).toBe("Drive_Research");
const other = report.planets.find((p) => p.kind === "other")!;
expect(other.owner).toBe("Monstrai");
expect(other.name).toBe("Skarabei");
const uninhab = report.planets.find((p) => p.kind === "uninhabited")!;
expect(uninhab.name).toBe("Dw2");
// Uninhabited planets carry size/resources/stockpiles but no
// industry / population / production.
expect(uninhab.size).toBe(500);
expect(uninhab.industry).toBeNull();
expect(uninhab.population).toBeNull();
expect(uninhab.production).toBeNull();
const unident = report.planets.filter((p) => p.kind === "unidentified");
expect(unident).toHaveLength(2);
expect(unident[0]!.name).toBe("");
expect(unident[0]!.size).toBeNull();
});
test("derives local player tech from the matching player row", () => {
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(report.localPlayerDrive).toBe(13.25);
expect(report.localPlayerWeapons).toBe(6.11);
expect(report.localPlayerShields).toBe(7.09);
expect(report.localPlayerCargo).toBe(1);
});
test("returns zeros when the local race row is missing", () => {
const { report } = loadSyntheticReportFromJSON(
syntheticJSON({ race: "GhostRace" }),
);
expect(report.localPlayerDrive).toBe(0);
expect(report.localPlayerWeapons).toBe(0);
expect(report.localPlayerShields).toBe(0);
expect(report.localPlayerCargo).toBe(0);
});
test("emits empty routes (legacy format has no routes section)", () => {
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(report.routes).toEqual([]);
});
test("defaults exit warnings to empty (legacy format has no exit data)", () => {
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(report.personalExitWarning).toBe(0);
expect(report.racesLeavingSoon).toEqual([]);
});
test("reads hand-authored exit warnings when present", () => {
const { report } = loadSyntheticReportFromJSON(
syntheticJSON({
personalExitWarning: 4,
racesLeavingSoon: [{ race: "Monstrai", turnsLeft: 2 }],
}),
);
expect(report.personalExitWarning).toBe(4);
expect(report.racesLeavingSoon).toEqual([
{ race: "Monstrai", turnsLeft: 2 },
]);
});
test("registers the report under the returned game id", () => {
const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(getSyntheticReport(gameId)).toBe(report);
});
test("two loads produce distinct ids", () => {
const a = loadSyntheticReportFromJSON(syntheticJSON());
const b = loadSyntheticReportFromJSON(syntheticJSON());
expect(a.gameId).not.toBe(b.gameId);
});
test("rejects non-object input", () => {
expect(() => loadSyntheticReportFromJSON(null)).toThrow(
SyntheticReportError,
);
expect(() => loadSyntheticReportFromJSON(42)).toThrow(
SyntheticReportError,
);
expect(() => loadSyntheticReportFromJSON("a string")).toThrow(
SyntheticReportError,
);
});
test("ship classes survive with truncated armament", () => {
const { report } = loadSyntheticReportFromJSON(
syntheticJSON({
localShipClass: [
{
name: "Bow105",
drive: 74.77,
armament: 105,
weapons: 1,
shields: 19.72,
cargo: 1,
mass: 148.49,
},
],
}),
);
expect(report.localShipClass).toHaveLength(1);
expect(report.localShipClass[0]!.name).toBe("Bow105");
expect(report.localShipClass[0]!.armament).toBe(105);
});
});
describe("isSyntheticGameId", () => {
test("recognises the synthetic prefix", () => {
expect(isSyntheticGameId("synthetic-abc")).toBe(true);
expect(isSyntheticGameId("00000000-0000-0000-0000-000000000000")).toBe(
false,
);
expect(isSyntheticGameId("")).toBe(false);
});
});
describe("getSyntheticReport", () => {
test("returns undefined for unknown ids", () => {
expect(getSyntheticReport("synthetic-missing")).toBeUndefined();
});
});
describe("envelope shape (v1)", () => {
test("forwards battles to the synthetic-battle registry", () => {
resetSyntheticBattles();
const battle: BattleReport = {
id: "11111111-1111-1111-1111-111111111111",
planet: 17,
planetName: "Castle",
races: { "0": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
ships: {
"0": {
race: "KnightErrants",
className: "Drone",
tech: { DRIVE: 1 },
num: 1,
numLeft: 0,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [],
};
const envelope = {
version: 1,
report: syntheticJSON(),
battles: { [battle.id]: battle },
};
const { gameId, report } = loadSyntheticReportFromJSON(envelope);
expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true);
expect(report.turn).toBe(39);
expect(lookupSyntheticBattle(battle.id)).toEqual(battle);
});
test("missing battles field leaves the registry untouched", () => {
resetSyntheticBattles();
const envelope = {
version: 1,
report: syntheticJSON(),
};
loadSyntheticReportFromJSON(envelope);
expect(lookupSyntheticBattle("any")).toBeNull();
});
test("bare Report (no envelope) still loads — backward compat", () => {
resetSyntheticBattles();
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
expect(report.turn).toBe(39);
expect(lookupSyntheticBattle("any")).toBeNull();
});
});