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.
This commit is contained in:
@@ -571,6 +571,21 @@ export interface GameReport {
|
||||
* currently producing a ship.
|
||||
*/
|
||||
shipProductions: ReportShipProduction[];
|
||||
/**
|
||||
* personalExitWarning is the local race's own countdown to being
|
||||
* auto-removed for inactivity, read from `Report.personal_exit_warning`.
|
||||
* A value of `1..5` is a personal alarm (the player will be removed in
|
||||
* that many turns unless orders are submitted); `0` means no warning.
|
||||
*/
|
||||
personalExitWarning: number;
|
||||
/**
|
||||
* racesLeavingSoon is the public list of other races within three
|
||||
* turns of removal for inactivity, read from `Report.races_leaving_soon`.
|
||||
* Shown to everyone. Each entry carries the race name and the number
|
||||
* of turns left before that race is removed. Empty when no race is
|
||||
* about to leave (or when the report predates the field).
|
||||
*/
|
||||
racesLeavingSoon: { race: string; turnsLeft: number }[];
|
||||
}
|
||||
|
||||
export async function fetchGameReport(
|
||||
@@ -731,6 +746,7 @@ function decodeReport(report: Report): GameReport {
|
||||
const battleIds = battles.map((b) => b.id);
|
||||
const bombings = decodeBombings(report);
|
||||
const shipProductions = decodeShipProductions(report);
|
||||
const racesLeavingSoon = decodeRacesLeavingSoon(report);
|
||||
|
||||
return {
|
||||
turn: Number(report.turn()),
|
||||
@@ -762,6 +778,8 @@ function decodeReport(report: Report): GameReport {
|
||||
battleIds,
|
||||
bombings,
|
||||
shipProductions,
|
||||
personalExitWarning: report.personalExitWarning(),
|
||||
racesLeavingSoon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -939,6 +957,25 @@ function decodeUnidentifiedShipGroups(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* decodeRacesLeavingSoon flattens `report.races_leaving_soon()[]` into
|
||||
* the typed `racesLeavingSoon` array. Each `RaceExitNotice` carries the
|
||||
* race name and the number of turns left before that race is removed for
|
||||
* inactivity. Rows with a missing name are skipped. Empty when the
|
||||
* report carries no notices.
|
||||
*/
|
||||
function decodeRacesLeavingSoon(
|
||||
report: Report,
|
||||
): { race: string; turnsLeft: number }[] {
|
||||
const out: { race: string; turnsLeft: number }[] = [];
|
||||
for (let i = 0; i < report.racesLeavingSoonLength(); i++) {
|
||||
const n = report.racesLeavingSoon(i);
|
||||
if (n === null) continue;
|
||||
out.push({ race: n.race() ?? "", turnsLeft: n.turnsLeft() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeLocalFleets(report: Report): ReportLocalFleet[] {
|
||||
const out: ReportLocalFleet[] = [];
|
||||
for (let i = 0; i < report.localFleetLength(); i++) {
|
||||
@@ -1479,6 +1516,7 @@ export function applyOrderOverlay(
|
||||
battleIds: report.battleIds ?? [],
|
||||
bombings: report.bombings ?? [],
|
||||
shipProductions: report.shipProductions ?? [],
|
||||
racesLeavingSoon: report.racesLeavingSoon ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
// AND the Go CLI must learn to populate that field — see the
|
||||
// synthetic-report parity rule in `ui/PLAN.md`.
|
||||
//
|
||||
// `personalExitWarning` / `racesLeavingSoon` are the exception the
|
||||
// rule allows: they are runtime inactivity-countdown state the engine
|
||||
// derives per turn, not anything present in a static legacy text
|
||||
// report, so the legacy CLI leaves them empty (the parity rule's
|
||||
// "cannot be derived from the legacy text format" escape hatch). This
|
||||
// decoder still reads them defensively so a hand-authored synthetic
|
||||
// JSON fixture can exercise the report's exit-warning UI.
|
||||
//
|
||||
// The in-memory map deliberately does not survive a page reload:
|
||||
// synthetic mode is a debug affordance, not a session, and the
|
||||
// layout redirects to /lobby when a synthetic id is opened with no
|
||||
@@ -259,6 +267,11 @@ interface SyntheticShipProductionRow {
|
||||
free?: number;
|
||||
}
|
||||
|
||||
interface SyntheticRaceExitNotice {
|
||||
race?: string;
|
||||
turnsLeft?: number;
|
||||
}
|
||||
|
||||
interface SyntheticReportRoot {
|
||||
turn?: number;
|
||||
mapWidth?: number;
|
||||
@@ -284,6 +297,8 @@ interface SyntheticReportRoot {
|
||||
battle?: SyntheticBattle[];
|
||||
bombing?: SyntheticBombing[];
|
||||
shipProduction?: SyntheticShipProductionRow[];
|
||||
personalExitWarning?: number;
|
||||
racesLeavingSoon?: SyntheticRaceExitNotice[];
|
||||
}
|
||||
|
||||
function decodeSyntheticReport(json: unknown): GameReport {
|
||||
@@ -465,6 +480,13 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
||||
return a.class.localeCompare(b.class);
|
||||
});
|
||||
|
||||
const racesLeavingSoon: { race: string; turnsLeft: number }[] = (
|
||||
root.racesLeavingSoon ?? []
|
||||
).map((n) => ({
|
||||
race: typeof n.race === "string" ? n.race : "",
|
||||
turnsLeft: numOr0(n.turnsLeft),
|
||||
}));
|
||||
|
||||
return {
|
||||
turn: numOr0(root.turn),
|
||||
mapWidth: numOr0(root.mapWidth),
|
||||
@@ -495,6 +517,8 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
||||
battleIds,
|
||||
bombings,
|
||||
shipProductions,
|
||||
personalExitWarning: numOr0(root.personalExitWarning),
|
||||
racesLeavingSoon,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Phase 23 turn-report active view.
|
||||
|
||||
Composes the table of contents (`report/report-toc.svelte`) and the
|
||||
twenty section components that render each `GameReport` array. Each
|
||||
section components that render each `GameReport` array. Each
|
||||
section is its own component under `lib/active-view/report/` — the
|
||||
data shapes are too varied for one generic table, and the
|
||||
component-per-section seam matches Phase 23's targeted-test contract.
|
||||
@@ -11,8 +11,10 @@ Active-section highlighting lands here: an `IntersectionObserver`
|
||||
rooted on the viewport watches every `<section id="report-<slug>">`
|
||||
and updates a local `activeSlug` rune that drives the TOC highlight.
|
||||
|
||||
The 20-section list lives here as a single source of truth so the
|
||||
TOC and the body iterate the same data.
|
||||
The section list lives here as a single source of truth so the
|
||||
TOC and the body iterate the same data. One entry —
|
||||
`race-exit-warnings` — renders nothing when its list is empty, so its
|
||||
TOC item resolves to a no-op scroll on the rare turns it is hidden.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
@@ -20,7 +22,9 @@ TOC and the body iterate the same data.
|
||||
import ReportToc, {
|
||||
type TocEntry,
|
||||
} from "./report/report-toc.svelte";
|
||||
import PersonalExitBanner from "./report/personal-exit-banner.svelte";
|
||||
import SectionGalaxySummary from "./report/section-galaxy-summary.svelte";
|
||||
import SectionRaceExitWarnings from "./report/section-race-exit-warnings.svelte";
|
||||
import SectionVotes from "./report/section-votes.svelte";
|
||||
import SectionPlayerStatus from "./report/section-player-status.svelte";
|
||||
import SectionMySciences from "./report/section-my-sciences.svelte";
|
||||
@@ -43,6 +47,7 @@ TOC and the body iterate the same data.
|
||||
|
||||
const ENTRIES: readonly TocEntry[] = [
|
||||
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
|
||||
{ slug: "race-exit-warnings", titleKey: "game.report.section.race_exit_warnings.title" },
|
||||
{ slug: "votes", titleKey: "game.report.section.votes.title" },
|
||||
{ slug: "player-status", titleKey: "game.report.section.player_status.title" },
|
||||
{ slug: "my-sciences", titleKey: "game.report.section.my_sciences.title" },
|
||||
@@ -107,10 +112,12 @@ TOC and the body iterate the same data.
|
||||
</script>
|
||||
|
||||
<div class="report-view" data-testid="active-view-report">
|
||||
<PersonalExitBanner />
|
||||
<ReportToc entries={ENTRIES} {activeSlug} />
|
||||
|
||||
<div class="report-body" bind:this={bodyEl}>
|
||||
<SectionGalaxySummary />
|
||||
<SectionRaceExitWarnings />
|
||||
<SectionVotes />
|
||||
<SectionPlayerStatus />
|
||||
<SectionMySciences />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<!--
|
||||
Personal exit-warning banner for the turn-report view. Mirrors the
|
||||
`lib/header/history-banner.svelte` alert pattern (sticky `aside`,
|
||||
design-token styling) but carries the local race's own inactivity
|
||||
countdown rather than a history notice.
|
||||
|
||||
Renders only when `report.personalExitWarning > 0`: the engine reports
|
||||
`1..5` turns remaining before the local race is auto-removed for
|
||||
inactivity, `0` when there is no warning. The copy is a personal alarm
|
||||
("you will be removed in N turns unless you submit orders"), distinct
|
||||
from the public `section-race-exit-warnings.svelte` list of other
|
||||
races leaving soon. Uses the danger token rather than the warning
|
||||
token because it is the recipient's own removal that is at stake.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
type RenderedReportSource,
|
||||
} from "$lib/rendered-report.svelte";
|
||||
|
||||
const rendered = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
const report = $derived(rendered?.report ?? null);
|
||||
const turnsLeft = $derived(report?.personalExitWarning ?? 0);
|
||||
</script>
|
||||
|
||||
{#if turnsLeft > 0}
|
||||
<aside
|
||||
class="exit-banner"
|
||||
data-testid="report-personal-exit-banner"
|
||||
role="alert"
|
||||
>
|
||||
<span class="message">
|
||||
{i18n.t("game.report.personal_exit_warning", {
|
||||
turns: String(turnsLeft),
|
||||
})}
|
||||
</span>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.exit-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-danger);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: 6px;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<!--
|
||||
Report View — races leaving soon section. Surfaces the public
|
||||
`racesLeavingSoon` projection: every other race within three turns of
|
||||
being auto-removed for inactivity, with the number of turns each has
|
||||
left. This is the public counterpart to the personal exit-warning
|
||||
banner the report view renders at the top for the local race.
|
||||
|
||||
Unlike the other report sections, this one hides entirely when the
|
||||
list is empty rather than showing an empty-state line: an absent
|
||||
notice is the normal, healthy case for an active game, so a permanent
|
||||
"no races leaving" row would be noise. The section's TOC entry stays
|
||||
registered; clicking it while the section is hidden is a silent no-op
|
||||
through the table-of-contents' existing `getElementById` guard.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
type RenderedReportSource,
|
||||
} from "$lib/rendered-report.svelte";
|
||||
|
||||
const rendered = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
const report = $derived(rendered?.report ?? null);
|
||||
const rows = $derived(report?.racesLeavingSoon ?? []);
|
||||
</script>
|
||||
|
||||
{#if rows.length > 0}
|
||||
<section
|
||||
id="report-race-exit-warnings"
|
||||
class="grid-section"
|
||||
data-testid="report-section-race-exit-warnings"
|
||||
>
|
||||
<h2>{i18n.t("game.report.section.race_exit_warnings.title")}</h2>
|
||||
|
||||
<ul class="notices" data-testid="race-exit-warnings-list">
|
||||
{#each rows as r (r.race)}
|
||||
<li data-testid="race-exit-warnings-row" data-race={r.race}>
|
||||
{i18n.t("game.report.section.race_exit_warnings.notice", {
|
||||
race: r.race,
|
||||
turns: String(r.turnsLeft),
|
||||
})}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.grid-section h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.notices {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.notices li {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -657,11 +657,16 @@ const en = {
|
||||
"game.report.toc.title": "sections",
|
||||
"game.report.toc.open": "show section list",
|
||||
"game.report.toc.close": "hide section list",
|
||||
"game.report.personal_exit_warning":
|
||||
"Inactivity warning: your race will be removed in {turns} turn(s) unless you submit orders.",
|
||||
"game.report.section.galaxy_summary.title": "galaxy summary",
|
||||
"game.report.section.galaxy_summary.field.turn": "turn",
|
||||
"game.report.section.galaxy_summary.field.size": "map size",
|
||||
"game.report.section.galaxy_summary.field.planets": "planet count",
|
||||
"game.report.section.galaxy_summary.field.race": "your race",
|
||||
"game.report.section.race_exit_warnings.title": "races leaving soon",
|
||||
"game.report.section.race_exit_warnings.notice":
|
||||
"{race} will be removed for inactivity in {turns} turn(s).",
|
||||
"game.report.section.votes.title": "votes",
|
||||
"game.report.section.votes.mine": "my votes",
|
||||
"game.report.section.votes.target": "I vote for",
|
||||
|
||||
@@ -658,11 +658,16 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.report.toc.title": "разделы",
|
||||
"game.report.toc.open": "показать список разделов",
|
||||
"game.report.toc.close": "скрыть список разделов",
|
||||
"game.report.personal_exit_warning":
|
||||
"Предупреждение о неактивности: ваша раса будет удалена через {turns} ход(ов), если вы не отправите приказы.",
|
||||
"game.report.section.galaxy_summary.title": "общие сведения о галактике",
|
||||
"game.report.section.galaxy_summary.field.turn": "ход",
|
||||
"game.report.section.galaxy_summary.field.size": "размер карты",
|
||||
"game.report.section.galaxy_summary.field.planets": "всего планет",
|
||||
"game.report.section.galaxy_summary.field.race": "ваша раса",
|
||||
"game.report.section.race_exit_warnings.title": "расы скоро покинут игру",
|
||||
"game.report.section.race_exit_warnings.notice":
|
||||
"{race} будет удалена за неактивность через {turns} ход(ов).",
|
||||
"game.report.section.votes.title": "голоса",
|
||||
"game.report.section.votes.mine": "мои голоса",
|
||||
"game.report.section.votes.target": "голосую за",
|
||||
|
||||
@@ -14,6 +14,7 @@ export { OtherPlanet, OtherPlanetT } from './report/other-planet.js';
|
||||
export { OtherScience, OtherScienceT } from './report/other-science.js';
|
||||
export { OthersShipClass, OthersShipClassT } from './report/others-ship-class.js';
|
||||
export { Player, PlayerT } from './report/player.js';
|
||||
export { RaceExitNotice, RaceExitNoticeT } from './report/race-exit-notice.js';
|
||||
export { Report, ReportT } from './report/report.js';
|
||||
export { Route, RouteT } from './report/route.js';
|
||||
export { RouteEntry, RouteEntryT } from './report/route-entry.js';
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
|
||||
|
||||
export class RaceExitNotice implements flatbuffers.IUnpackableObject<RaceExitNoticeT> {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):RaceExitNotice {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsRaceExitNotice(bb:flatbuffers.ByteBuffer, obj?:RaceExitNotice):RaceExitNotice {
|
||||
return (obj || new RaceExitNotice()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsRaceExitNotice(bb:flatbuffers.ByteBuffer, obj?:RaceExitNotice):RaceExitNotice {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new RaceExitNotice()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
race():string|null
|
||||
race(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
race(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
turnsLeft():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readUint32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startRaceExitNotice(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addRace(builder:flatbuffers.Builder, raceOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, raceOffset, 0);
|
||||
}
|
||||
|
||||
static addTurnsLeft(builder:flatbuffers.Builder, turnsLeft:number) {
|
||||
builder.addFieldInt32(1, turnsLeft, 0);
|
||||
}
|
||||
|
||||
static endRaceExitNotice(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createRaceExitNotice(builder:flatbuffers.Builder, raceOffset:flatbuffers.Offset, turnsLeft:number):flatbuffers.Offset {
|
||||
RaceExitNotice.startRaceExitNotice(builder);
|
||||
RaceExitNotice.addRace(builder, raceOffset);
|
||||
RaceExitNotice.addTurnsLeft(builder, turnsLeft);
|
||||
return RaceExitNotice.endRaceExitNotice(builder);
|
||||
}
|
||||
|
||||
unpack(): RaceExitNoticeT {
|
||||
return new RaceExitNoticeT(
|
||||
this.race(),
|
||||
this.turnsLeft()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
unpackTo(_o: RaceExitNoticeT): void {
|
||||
_o.race = this.race();
|
||||
_o.turnsLeft = this.turnsLeft();
|
||||
}
|
||||
}
|
||||
|
||||
export class RaceExitNoticeT implements flatbuffers.IGeneratedObject {
|
||||
constructor(
|
||||
public race: string|Uint8Array|null = null,
|
||||
public turnsLeft: number = 0
|
||||
){}
|
||||
|
||||
|
||||
pack(builder:flatbuffers.Builder): flatbuffers.Offset {
|
||||
const race = (this.race !== null ? builder.createString(this.race!) : 0);
|
||||
|
||||
return RaceExitNotice.createRaceExitNotice(builder,
|
||||
race,
|
||||
this.turnsLeft
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { OtherPlanet, OtherPlanetT } from '../report/other-planet.js';
|
||||
import { OtherScience, OtherScienceT } from '../report/other-science.js';
|
||||
import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
|
||||
import { Player, PlayerT } from '../report/player.js';
|
||||
import { RaceExitNotice, RaceExitNoticeT } from '../report/race-exit-notice.js';
|
||||
import { Route, RouteT } from '../report/route.js';
|
||||
import { Science, ScienceT } from '../report/science.js';
|
||||
import { ShipClass, ShipClassT } from '../report/ship-class.js';
|
||||
@@ -266,8 +267,23 @@ unidentifiedGroupLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
personalExitWarning():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 56);
|
||||
return offset ? this.bb!.readUint32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
racesLeavingSoon(index: number, obj?:RaceExitNotice):RaceExitNotice|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 58);
|
||||
return offset ? (obj || new RaceExitNotice()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
racesLeavingSoonLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 58);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startReport(builder:flatbuffers.Builder) {
|
||||
builder.startObject(26);
|
||||
builder.startObject(28);
|
||||
}
|
||||
|
||||
static addVersion(builder:flatbuffers.Builder, version:bigint) {
|
||||
@@ -590,6 +606,26 @@ static startUnidentifiedGroupVector(builder:flatbuffers.Builder, numElems:number
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addPersonalExitWarning(builder:flatbuffers.Builder, personalExitWarning:number) {
|
||||
builder.addFieldInt32(26, personalExitWarning, 0);
|
||||
}
|
||||
|
||||
static addRacesLeavingSoon(builder:flatbuffers.Builder, racesLeavingSoonOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(27, racesLeavingSoonOffset, 0);
|
||||
}
|
||||
|
||||
static createRacesLeavingSoonVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startRacesLeavingSoonVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endReport(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
@@ -603,7 +639,7 @@ static finishSizePrefixedReportBuffer(builder:flatbuffers.Builder, offset:flatbu
|
||||
builder.finish(offset, undefined, true);
|
||||
}
|
||||
|
||||
static createReport(builder:flatbuffers.Builder, version:bigint, turn:bigint, width:number, height:number, planetCount:number, raceOffset:flatbuffers.Offset, votes:number, voteForOffset:flatbuffers.Offset, playerOffset:flatbuffers.Offset, localScienceOffset:flatbuffers.Offset, otherScienceOffset:flatbuffers.Offset, localShipClassOffset:flatbuffers.Offset, otherShipClassOffset:flatbuffers.Offset, battleOffset:flatbuffers.Offset, bombingOffset:flatbuffers.Offset, incomingGroupOffset:flatbuffers.Offset, localPlanetOffset:flatbuffers.Offset, shipProductionOffset:flatbuffers.Offset, routeOffset:flatbuffers.Offset, otherPlanetOffset:flatbuffers.Offset, uninhabitedPlanetOffset:flatbuffers.Offset, unidentifiedPlanetOffset:flatbuffers.Offset, localFleetOffset:flatbuffers.Offset, localGroupOffset:flatbuffers.Offset, otherGroupOffset:flatbuffers.Offset, unidentifiedGroupOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createReport(builder:flatbuffers.Builder, version:bigint, turn:bigint, width:number, height:number, planetCount:number, raceOffset:flatbuffers.Offset, votes:number, voteForOffset:flatbuffers.Offset, playerOffset:flatbuffers.Offset, localScienceOffset:flatbuffers.Offset, otherScienceOffset:flatbuffers.Offset, localShipClassOffset:flatbuffers.Offset, otherShipClassOffset:flatbuffers.Offset, battleOffset:flatbuffers.Offset, bombingOffset:flatbuffers.Offset, incomingGroupOffset:flatbuffers.Offset, localPlanetOffset:flatbuffers.Offset, shipProductionOffset:flatbuffers.Offset, routeOffset:flatbuffers.Offset, otherPlanetOffset:flatbuffers.Offset, uninhabitedPlanetOffset:flatbuffers.Offset, unidentifiedPlanetOffset:flatbuffers.Offset, localFleetOffset:flatbuffers.Offset, localGroupOffset:flatbuffers.Offset, otherGroupOffset:flatbuffers.Offset, unidentifiedGroupOffset:flatbuffers.Offset, personalExitWarning:number, racesLeavingSoonOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
Report.startReport(builder);
|
||||
Report.addVersion(builder, version);
|
||||
Report.addTurn(builder, turn);
|
||||
@@ -631,6 +667,8 @@ static createReport(builder:flatbuffers.Builder, version:bigint, turn:bigint, wi
|
||||
Report.addLocalGroup(builder, localGroupOffset);
|
||||
Report.addOtherGroup(builder, otherGroupOffset);
|
||||
Report.addUnidentifiedGroup(builder, unidentifiedGroupOffset);
|
||||
Report.addPersonalExitWarning(builder, personalExitWarning);
|
||||
Report.addRacesLeavingSoon(builder, racesLeavingSoonOffset);
|
||||
return Report.endReport(builder);
|
||||
}
|
||||
|
||||
@@ -661,7 +699,9 @@ unpack(): ReportT {
|
||||
this.bb!.createObjList<LocalFleet, LocalFleetT>(this.localFleet.bind(this), this.localFleetLength()),
|
||||
this.bb!.createObjList<LocalGroup, LocalGroupT>(this.localGroup.bind(this), this.localGroupLength()),
|
||||
this.bb!.createObjList<OtherGroup, OtherGroupT>(this.otherGroup.bind(this), this.otherGroupLength()),
|
||||
this.bb!.createObjList<UnidentifiedGroup, UnidentifiedGroupT>(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength())
|
||||
this.bb!.createObjList<UnidentifiedGroup, UnidentifiedGroupT>(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength()),
|
||||
this.personalExitWarning(),
|
||||
this.bb!.createObjList<RaceExitNotice, RaceExitNoticeT>(this.racesLeavingSoon.bind(this), this.racesLeavingSoonLength())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -693,6 +733,8 @@ unpackTo(_o: ReportT): void {
|
||||
_o.localGroup = this.bb!.createObjList<LocalGroup, LocalGroupT>(this.localGroup.bind(this), this.localGroupLength());
|
||||
_o.otherGroup = this.bb!.createObjList<OtherGroup, OtherGroupT>(this.otherGroup.bind(this), this.otherGroupLength());
|
||||
_o.unidentifiedGroup = this.bb!.createObjList<UnidentifiedGroup, UnidentifiedGroupT>(this.unidentifiedGroup.bind(this), this.unidentifiedGroupLength());
|
||||
_o.personalExitWarning = this.personalExitWarning();
|
||||
_o.racesLeavingSoon = this.bb!.createObjList<RaceExitNotice, RaceExitNoticeT>(this.racesLeavingSoon.bind(this), this.racesLeavingSoonLength());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,7 +765,9 @@ constructor(
|
||||
public localFleet: (LocalFleetT)[] = [],
|
||||
public localGroup: (LocalGroupT)[] = [],
|
||||
public otherGroup: (OtherGroupT)[] = [],
|
||||
public unidentifiedGroup: (UnidentifiedGroupT)[] = []
|
||||
public unidentifiedGroup: (UnidentifiedGroupT)[] = [],
|
||||
public personalExitWarning: number = 0,
|
||||
public racesLeavingSoon: (RaceExitNoticeT)[] = []
|
||||
){}
|
||||
|
||||
|
||||
@@ -748,6 +792,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
|
||||
const localGroup = Report.createLocalGroupVector(builder, builder.createObjectOffsetList(this.localGroup));
|
||||
const otherGroup = Report.createOtherGroupVector(builder, builder.createObjectOffsetList(this.otherGroup));
|
||||
const unidentifiedGroup = Report.createUnidentifiedGroupVector(builder, builder.createObjectOffsetList(this.unidentifiedGroup));
|
||||
const racesLeavingSoon = Report.createRacesLeavingSoonVector(builder, builder.createObjectOffsetList(this.racesLeavingSoon));
|
||||
|
||||
return Report.createReport(builder,
|
||||
this.version,
|
||||
@@ -775,7 +820,9 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
|
||||
localFleet,
|
||||
localGroup,
|
||||
otherGroup,
|
||||
unidentifiedGroup
|
||||
unidentifiedGroup,
|
||||
this.personalExitWarning,
|
||||
racesLeavingSoon
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
OtherScience,
|
||||
OthersShipClass,
|
||||
Player,
|
||||
RaceExitNotice,
|
||||
Report,
|
||||
Route,
|
||||
RouteEntry,
|
||||
@@ -139,6 +140,11 @@ export interface ShipProductionFixture {
|
||||
free?: number;
|
||||
}
|
||||
|
||||
export interface RaceExitNoticeFixture {
|
||||
race: string;
|
||||
turnsLeft: number;
|
||||
}
|
||||
|
||||
export interface ReportFixture {
|
||||
turn: number;
|
||||
mapWidth?: number;
|
||||
@@ -159,6 +165,8 @@ export interface ReportFixture {
|
||||
battles?: BattleSummaryFixture[];
|
||||
bombings?: BombingFixture[];
|
||||
shipProductions?: ShipProductionFixture[];
|
||||
personalExitWarning?: number;
|
||||
racesLeavingSoon?: RaceExitNoticeFixture[];
|
||||
}
|
||||
|
||||
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
@@ -356,6 +364,14 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
return ShipProduction.endShipProduction(builder);
|
||||
});
|
||||
|
||||
const racesLeavingSoonOffsets = (fixture.racesLeavingSoon ?? []).map((n) => {
|
||||
const race = builder.createString(n.race);
|
||||
RaceExitNotice.startRaceExitNotice(builder);
|
||||
RaceExitNotice.addRace(builder, race);
|
||||
RaceExitNotice.addTurnsLeft(builder, n.turnsLeft);
|
||||
return RaceExitNotice.endRaceExitNotice(builder);
|
||||
});
|
||||
|
||||
const localVec =
|
||||
localOffsets.length === 0
|
||||
? null
|
||||
@@ -404,6 +420,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
shipProductionOffsets.length === 0
|
||||
? null
|
||||
: Report.createShipProductionVector(builder, shipProductionOffsets);
|
||||
const racesLeavingSoonVec =
|
||||
racesLeavingSoonOffsets.length === 0
|
||||
? null
|
||||
: Report.createRacesLeavingSoonVector(builder, racesLeavingSoonOffsets);
|
||||
// Phase 27 — `battle` carries `BattleSummary` tables, each with
|
||||
// an inline `id:UUID` struct plus `planet` and `shots` slots.
|
||||
const battleVec = (() => {
|
||||
@@ -462,6 +482,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
if (bombingVec !== null) Report.addBombing(builder, bombingVec);
|
||||
if (shipProductionVec !== null)
|
||||
Report.addShipProduction(builder, shipProductionVec);
|
||||
if (fixture.personalExitWarning !== undefined)
|
||||
Report.addPersonalExitWarning(builder, fixture.personalExitWarning);
|
||||
if (racesLeavingSoonVec !== null)
|
||||
Report.addRacesLeavingSoon(builder, racesLeavingSoonVec);
|
||||
const reportOff = Report.endReport(builder);
|
||||
builder.finish(reportOff);
|
||||
return builder.asUint8Array();
|
||||
|
||||
@@ -45,6 +45,7 @@ const BATTLE_ID = "00000000-0000-0000-0000-000000000001";
|
||||
// the popover and a `report-section-<slug>` testid in the body.
|
||||
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
|
||||
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
|
||||
{ slug: "race-exit-warnings", expectRow: "race-exit-warnings-row" },
|
||||
{ slug: "votes", expectRow: "votes-mine" },
|
||||
{ slug: "player-status", expectRow: "player-status-row" },
|
||||
{ slug: "my-sciences", expectRow: "my-sciences-row" },
|
||||
@@ -161,6 +162,10 @@ async function mockGateway(page: Page): Promise<void> {
|
||||
shipProductions: [
|
||||
{ planet: 1, class: "Cruiser", cost: 100, prodUsed: 25, percent: 0.25, free: 800 },
|
||||
],
|
||||
racesLeavingSoon: [
|
||||
{ race: "Bajori", turnsLeft: 2 },
|
||||
{ race: "Cardassian", turnsLeft: 3 },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ByteBuffer } from "flatbuffers";
|
||||
import {
|
||||
GameReportRequest,
|
||||
LocalPlanet,
|
||||
RaceExitNotice,
|
||||
Report,
|
||||
ShipClass,
|
||||
} from "../src/proto/galaxy/fbs/report";
|
||||
@@ -124,6 +125,8 @@ function buildReportPayload(opts: {
|
||||
height?: number;
|
||||
planets?: PlanetFixture[];
|
||||
shipClasses?: ShipClassFixture[];
|
||||
personalExitWarning?: number;
|
||||
racesLeavingSoon?: { race: string; turnsLeft: number }[];
|
||||
}): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
const planetOffsets = (opts.planets ?? []).map((planet) => {
|
||||
@@ -156,6 +159,17 @@ function buildReportPayload(opts: {
|
||||
shipClassOffsets.length === 0
|
||||
? null
|
||||
: Report.createLocalShipClassVector(builder, shipClassOffsets);
|
||||
const racesLeavingSoonOffsets = (opts.racesLeavingSoon ?? []).map((n) => {
|
||||
const race = builder.createString(n.race);
|
||||
RaceExitNotice.startRaceExitNotice(builder);
|
||||
RaceExitNotice.addRace(builder, race);
|
||||
RaceExitNotice.addTurnsLeft(builder, n.turnsLeft);
|
||||
return RaceExitNotice.endRaceExitNotice(builder);
|
||||
});
|
||||
const racesLeavingSoonVec =
|
||||
racesLeavingSoonOffsets.length === 0
|
||||
? null
|
||||
: Report.createRacesLeavingSoonVector(builder, racesLeavingSoonOffsets);
|
||||
|
||||
Report.startReport(builder);
|
||||
Report.addTurn(builder, BigInt(opts.turn));
|
||||
@@ -168,6 +182,12 @@ function buildReportPayload(opts: {
|
||||
if (localShipClassVec !== null) {
|
||||
Report.addLocalShipClass(builder, localShipClassVec);
|
||||
}
|
||||
if (opts.personalExitWarning !== undefined) {
|
||||
Report.addPersonalExitWarning(builder, opts.personalExitWarning);
|
||||
}
|
||||
if (racesLeavingSoonVec !== null) {
|
||||
Report.addRacesLeavingSoon(builder, racesLeavingSoonVec);
|
||||
}
|
||||
const reportOff = Report.endReport(builder);
|
||||
builder.finish(reportOff);
|
||||
return builder.asUint8Array();
|
||||
@@ -214,6 +234,52 @@ describe("GameStateStore", () => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("decodes personalExitWarning and racesLeavingSoon from the report", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]);
|
||||
|
||||
const client = makeFakeClient(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload({
|
||||
turn: 7,
|
||||
personalExitWarning: 3,
|
||||
racesLeavingSoon: [
|
||||
{ race: "Bajori", turnsLeft: 2 },
|
||||
{ race: "Cardassian", turnsLeft: 1 },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
|
||||
expect(store.status).toBe("ready");
|
||||
expect(store.report?.personalExitWarning).toBe(3);
|
||||
expect(store.report?.racesLeavingSoon).toEqual([
|
||||
{ race: "Bajori", turnsLeft: 2 },
|
||||
{ race: "Cardassian", turnsLeft: 1 },
|
||||
]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("defaults personalExitWarning to 0 and racesLeavingSoon to [] when absent", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(7)]);
|
||||
|
||||
const client = makeFakeClient(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildReportPayload({ turn: 7 }),
|
||||
}));
|
||||
|
||||
const store = new GameStateStore();
|
||||
await store.init({ client, cache, gameId: GAME_ID });
|
||||
|
||||
expect(store.status).toBe("ready");
|
||||
expect(store.report?.personalExitWarning).toBe(0);
|
||||
expect(store.report?.racesLeavingSoon).toEqual([]);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("init surfaces an error when the game is missing from lobby", async () => {
|
||||
listMyGamesSpy.mockResolvedValue([makeGameSummary(0).gameId === "other" ? null : makeGameSummary(0)].filter(Boolean));
|
||||
// Replace the helper above's awkward filter with an explicit
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
|
||||
// ancillary report fields added in Phase 19 (ship-groups + fleets),
|
||||
// Phase 21 (sciences), Phase 22 (races / diplomacy / voting), and
|
||||
// Phase 21 (sciences), Phase 22 (races / diplomacy / voting),
|
||||
// Phase 23 (full player roster, foreign sciences, foreign ship
|
||||
// classes, battle ids, bombings, ships in production).
|
||||
// classes, battle ids, bombings, ships in production), and the
|
||||
// per-turn inactivity exit warnings (personal countdown + public
|
||||
// races-leaving-soon list).
|
||||
// Test fixtures spread it into their report objects so the fixture
|
||||
// body still focuses on the fields under test, without forcing
|
||||
// every spec to enumerate the full GameReport surface.
|
||||
@@ -41,6 +43,8 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
battleIds: string[];
|
||||
bombings: ReportBombing[];
|
||||
shipProductions: ReportShipProduction[];
|
||||
personalExitWarning: number;
|
||||
racesLeavingSoon: { race: string; turnsLeft: number }[];
|
||||
} = {
|
||||
localShipGroups: [],
|
||||
otherShipGroups: [],
|
||||
@@ -59,4 +63,6 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
battleIds: [],
|
||||
bombings: [],
|
||||
shipProductions: [],
|
||||
personalExitWarning: 0,
|
||||
racesLeavingSoon: [],
|
||||
};
|
||||
|
||||
@@ -81,6 +81,8 @@ function makeReport(
|
||||
battleIds: [],
|
||||
bombings: [],
|
||||
shipProductions: [],
|
||||
personalExitWarning: 0,
|
||||
racesLeavingSoon: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Vitest coverage for the report view's race-exit-warnings section and
|
||||
// the personal exit-warning banner. The section lists other races
|
||||
// within a few turns of inactivity removal and hides entirely when the
|
||||
// list is empty; the banner shows the local race's own countdown only
|
||||
// when it is non-zero. Both read the report through the rendered-report
|
||||
// context, mirroring the other report sections.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type { GameReport } from "../src/api/game-state";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
import SectionRaceExitWarnings from "../src/lib/active-view/report/section-race-exit-warnings.svelte";
|
||||
import PersonalExitBanner from "../src/lib/active-view/report/personal-exit-banner.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "Self",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(
|
||||
component: typeof SectionRaceExitWarnings | typeof PersonalExitBanner,
|
||||
report: GameReport | null,
|
||||
) {
|
||||
const context = new Map<unknown, unknown>([
|
||||
[
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
{
|
||||
get report() {
|
||||
return report;
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
return render(component, { context });
|
||||
}
|
||||
|
||||
describe("report race-exit-warnings section", () => {
|
||||
test("renders nothing before the report lands", () => {
|
||||
const ui = mount(SectionRaceExitWarnings, null);
|
||||
expect(
|
||||
ui.queryByTestId("report-section-race-exit-warnings"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides the section entirely when no races are leaving", () => {
|
||||
const ui = mount(
|
||||
SectionRaceExitWarnings,
|
||||
makeReport({ racesLeavingSoon: [] }),
|
||||
);
|
||||
expect(
|
||||
ui.queryByTestId("report-section-race-exit-warnings"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("lists each race with its remaining turns", () => {
|
||||
const ui = mount(
|
||||
SectionRaceExitWarnings,
|
||||
makeReport({
|
||||
racesLeavingSoon: [
|
||||
{ race: "Bajori", turnsLeft: 2 },
|
||||
{ race: "Cardassian", turnsLeft: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
ui.getByTestId("report-section-race-exit-warnings"),
|
||||
).toBeInTheDocument();
|
||||
const rows = ui.getAllByTestId("race-exit-warnings-row");
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveAttribute("data-race", "Bajori");
|
||||
expect(rows[0]).toHaveTextContent("Bajori");
|
||||
expect(rows[0]).toHaveTextContent("2");
|
||||
expect(rows[1]).toHaveAttribute("data-race", "Cardassian");
|
||||
expect(rows[1]).toHaveTextContent("Cardassian");
|
||||
expect(rows[1]).toHaveTextContent("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("personal exit-warning banner", () => {
|
||||
test("renders nothing before the report lands", () => {
|
||||
const ui = mount(PersonalExitBanner, null);
|
||||
expect(
|
||||
ui.queryByTestId("report-personal-exit-banner"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("stays hidden when there is no personal warning", () => {
|
||||
const ui = mount(
|
||||
PersonalExitBanner,
|
||||
makeReport({ personalExitWarning: 0 }),
|
||||
);
|
||||
expect(
|
||||
ui.queryByTestId("report-personal-exit-banner"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows the danger banner with the countdown when warned", () => {
|
||||
const ui = mount(
|
||||
PersonalExitBanner,
|
||||
makeReport({ personalExitWarning: 3 }),
|
||||
);
|
||||
const banner = ui.getByTestId("report-personal-exit-banner");
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner).toHaveAttribute("role", "alert");
|
||||
expect(banner).toHaveTextContent("3");
|
||||
});
|
||||
});
|
||||
@@ -189,6 +189,25 @@ describe("loadSyntheticReportFromJSON", () => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user