` — reuses `timeFormat='absolute'` semantics from `EventLogCard`.
+- **Chain line:** `regions.join(' → ')` colored by faction. Renders inside `.event-log-card-chain`.
+
+The card is wrapped in ` ` for navigation + analytics.
+
+#### `groupCascadesBySeason(cascades, { sortOrder })`
+
+```js
+/**
+ * Group cascades by season.
+ *
+ * @param {Cascade[]} cascades - Cascades from getCascadeLeaderboard or findAllCascades
+ * @param {object} [opts]
+ * @param {'worst' | 'recent'} [opts.sortOrder='worst']
+ * @returns {Array<{ season: number, cascades: Cascade[], outcome?: string }>}
+ */
+```
+
+For `sortOrder='worst'`: group order keyed by each group's worst cascade's rank (length DESC, speed DESC). Cascades within a group share the same order.
+
+For `sortOrder='recent'`: group order by `season` DESC. Cascades within a group by `endTime` DESC.
+
+If a per-season `outcome` string (`'Victory'` / `'Defeat'` / `null`) is desired in the header, the group helper can take a `getSeasonOutcome(season)` lookup — but for v1 we can compute outcome up front and attach it via a prop on each cascade (cheaper than threading a callback). Decision deferred to implementation; see Open Questions.
+
+### Lede generation
+
+Lives in `src/features/stats/generateCascadeLede.mjs`. Pure, deterministic, server-callable. The lede is built by the `/stats` page and passed to `CascadeLog` as a `lede` prop; `/archives` does not call this helper (single-season view doesn't need a cross-season summary).
+
+```js
+import { formatCompactDuration } from '@/shared/utils/format/formatCompactDuration.mjs';
+
+export function generateCascadeLede(cascades, seasonsCount) {
+ if (!cascades?.length) return null;
+ const worst = cascades[0];
+ const reachedHome = worst.regions.at(-1) === 0 || worst.regions.at(-1) === 11;
+ const verb = reachedHome
+ ? 'pushed all the way home'
+ : `swept ${worst.length} regions in ${formatCompactDuration(worst.durationSec)}`;
+ return (
+ `${cascades.length} cascade${cascades.length === 1 ? '' : 's'} ` +
+ `across ${seasonsCount} war${seasonsCount === 1 ? '' : 's'}. ` +
+ `Worst: season ${worst.season}, where the ${worst.faction} ${verb}.`
+ );
+}
+```
+
+### Lifecycle and analytics
+
+- `CascadeLog` is `'use client'` because of the sort toggle. Initial render is server-side; the parent page reads the persisted preference and passes it as `initialSortOrder` to avoid hydration mismatch (same pattern `EventLog` uses for `initialSortOrder`).
+- `CascadeLogCard` is server-rendered (passed as children of the client `CascadeLog`).
+- Sort toggle click: `data-umami-event="cascade-log-sort-toggle"` on the new `CascadeLogSortToggle` button.
+- Card click (anchor navigation): `data-umami-event="cascade-card-click"`. No payload.
+
+## Data flow
+
+```
+/stats page (server)
+ │
+ ├── await getCascadeLeaderboard() ─► Cascade[] (all cascades, ranked)
+ ├── await getCrossSeasonStats() ─► used for seasonsCount
+ ├── const lede = generateCascadeLede(cascades, seasons.length)
+ ├── const initialSortOrder = readCookie(CASCADE_SORT_ORDER_KEY) ?? 'worst'
+ │
+ └──
+ │
+ ├── useCascadeLogSort(initialSortOrder) ── persisted sort state
+ ├── groupCascadesBySeason(cascades, { sortOrder }) ─► group[]
+ │
+ └── For each group, render header + grid of per cascade
+
+/archives?season=N page (server)
+ │
+ ├── await getArchiveData(season) ─► includes events
+ ├── const cascades = findAllCascades(events).map(c => ({ season, ...c }))
+ ├── const initialSortOrder = readCookie(CASCADE_SORT_ORDER_KEY) ?? 'worst'
+ │
+ └── // no `lede` prop — single-season view skips the cross-season summary
+```
+
+## Error handling
+
+- `getCascadeLeaderboard` wraps the Prisma call in `tryCatch`. On DB error, returns `[]` — the section skips itself, page still renders.
+- `findAllCascades` returns `[]` for any falsy/empty input. Never throws.
+- `generateLede` returns `null` for empty input. `CascadeLog` skips rendering the `` when null.
+- `CascadeLogCard` receives a fully-resolved `cascade` object; no defensive nulls beyond optional chaining for the faction icon.
+
+No `try/catch` blocks. All async paths go through `tryCatch`.
+
+## Performance
+
+- **One DB query** for the entire leaderboard. ~10k rows, indexed.
+- **React cache()** dedupes within a single request — multiple components on `/stats` calling `getCascadeLeaderboard` only hit Postgres once.
+- **`findAllCascades` is O(n log n)** on the failed-defend subset per season. Worst-case ~50 events/season, so the per-season cost is negligible.
+- **`CascadeLog` render cost** is proportional to the number of season groups + cards. Even with 50 groups × 2 cards = 100 cards rendered, this is well below any meaningful frame budget.
+- Bundle impact: ~3-5 KB minified (one new client component shell, one card component, one grouping helper, lede helper). No new libraries.
+
+## Removed / changed files
+
+| File | Action | Detail |
+|---|---|---|
+| `src/shared/utils/game/seasonAnalytics.mjs` | Modified | `findWorstCascade` deleted, `findAllCascades` added. Test file rewritten. |
+| `src/features/archives/ArchiveStats.jsx` | Modified | `WORST_CASCADE` `` removed. `findWorstCascade` import removed. `factions` import stays (used by other cards). |
+| `src/db/queries/getCascadeLeaderboard.mjs` | Added | New server query. |
+| `src/features/timeline/groupCascadesBySeason.mjs` | Added | New grouping helper. |
+| `src/features/timeline/CascadeLog.jsx` | Added | New client component. |
+| `src/features/timeline/CascadeLogCard.jsx` | Added | New server component (rendered as child of CascadeLog). |
+| `src/features/timeline/CascadeLogSortToggle.jsx` | Added | New toggle (mirror of `EventLogSortToggle`). |
+| `src/features/timeline/useCascadeLogSort.mjs` | Added | New persisted-sort hook (mirror of `useEventLogSort`). |
+| `src/shared/preferences/sortOrder.mjs` | Modified | New `CASCADE_SORT_ORDER_KEY` constant. |
+| `src/features/stats/generateCascadeLede.mjs` | Added | New lede helper. |
+| `src/features/timeline/EventLog.css` | Modified | New `.event-log-card-chain` and `.event-log-lede` classes added; existing classes untouched. |
+| `src/app/stats/page.jsx` | Modified | New `` inserted between War Outcomes and All-Time Records. |
+| `src/app/archives/page.jsx` | Modified | `` rendered below the StatGrid when cascades exist for the season. |
+| `src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs` | Modified | Replaces `findWorstCascade` cases with `findAllCascades` cases. |
+| `src/__tests__/unit/features/archives/ArchiveStats.test.jsx` | Modified | `WORST_CASCADE` assertions removed. |
+| `src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs` | Added | New. |
+| `src/__tests__/unit/features/timeline/CascadeLog.test.jsx` | Added | New. |
+| `src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx` | Added | New. |
+
+## Testing strategy
+
+### Unit tests (Vitest)
+
+1. **`seasonAnalytics.test.mjs`** — rewritten:
+ - Empty input → `[]`.
+ - No failed defends → `[]`.
+ - Single failed defend → `[]` (below minLength).
+ - Two failed defends, strictly decreasing region, in window → `[]` (below minLength=3).
+ - Three failed defends, strictly decreasing region, in window → 1 cascade of length 3.
+ - Three failed defends with a 2h gap → `[]` (gap exceeds 1h, cascade resets to length 1).
+ - Three failed defends, region plateau (e.g. 5,5,4) → `[]` (plateau breaks cascade).
+ - Two factions interleaved → independent cascades, both returned if qualifying.
+ - Sort tiebreaker: length tie → faster cascade ranks higher.
+ - Sort tiebreaker: length+speed tie → later `endTime` ranks higher.
+ - Custom `minLength` parameter respected.
+
+2. **`getCascadeLeaderboard.test.mjs`** (new):
+ - Mock Prisma `findMany`. Verify `where: { type: 'defend', status: 'fail' }` filter.
+ - Multiple seasons → cascades from each are merged and globally sorted.
+ - DB throws → returns `[]` (tryCatch swallows).
+ - Empty DB → returns `[]`.
+
+3. **`groupCascadesBySeason.test.mjs`** (new):
+ - Empty input → `[]`.
+ - `sortOrder='worst'`: groups ordered by worst cascade.
+ - `sortOrder='recent'`: groups ordered by season DESC, cascades within by endTime DESC.
+ - Multi-cascade season: cascades stay grouped together.
+
+4. **`CascadeLog.test.jsx`** (new) — RTL:
+ - `cascades=[]` → returns null.
+ - Renders heading and sort toggle.
+ - Renders lede when present.
+ - Renders one group per season.
+ - Sort toggle click flips group order (uses real `useEventLogSort` with localStorage stub).
+
+5. **`CascadeLogCard.test.jsx`** (new) — RTL:
+ - Renders title, duration pill, time line, chain.
+ - Anchor href is `/archives?season=N#cascade`.
+ - `data-umami-event="cascade-card-click"` present.
+ - Faction-colored chain (asserts class).
+
+6. **`ArchiveStats.test.jsx`** (modified):
+ - Remove all `WORST_CASCADE` assertions.
+
+### Integration
+
+No new Playwright tests. The existing `/stats` and `/archives` e2e smoke tests will catch render regressions.
+
+### Manual QA checklist (PR review only)
+
+- `/stats` shows the section between War Outcomes and All-Time Records.
+- Sort toggle persists across reload.
+- A multi-cascade season renders multiple cards in one group.
+- A season with no cascades does not render a group.
+- Clicking a card navigates to `/archives?season=N`, scrolled to the cascade section.
+- `/archives?season=N` renders the cascade section only when cascades exist.
+- Cards' chain lines wrap gracefully at narrow widths (overflow-x or wrap, not clipped).
+- `WORST_CASCADE` StatCard is gone from `/archives`.
+
+## Risks accepted
+
+- **Behavior change for `findWorstCascade` callers.** The function is removed entirely. Existing test cases that assumed length-2 cascades with arbitrary time gaps are rewritten — some seasons that previously showed a cascade may no longer have one. This is intentional (rigorous detection > back-compat), but it is a user-visible change for any frequent `/archives` visitor.
+- **Lede sentence template is fixed.** Two variants (reached-home vs swept-N-in-X) cover the cases I expect, but a user inspecting the lede on every page load may notice the pattern. Acceptable for v1.
+- **The cascade log renders its own `` (matching `EventLog`)**, while sibling sections on `/stats` are wrapped in `` by the page itself. This is a small structural inconsistency vs. the rest of `/stats`. Trade-off chosen to keep `CascadeLog` internally identical to `EventLog` for maximum reuse — if it grates in practice, a future refactor can normalize either direction.
+
+## Open questions
+
+- **Per-season outcome in the group header summary** ("1 cascade · Defeat"). The cleanest implementation is to attach `outcome` (from `getWarOutcome` or equivalent) to each cascade in `getCascadeLeaderboard` so the grouping helper can read it directly. For `/archives` we already have the data via the page's existing fetch. To be confirmed in writing-plans; design assumes the cleanest option (attach in the server query).
diff --git a/docs/superpowers/specs/2026-05-23-ministry-interference-design.md b/docs/superpowers/specs/2026-05-23-ministry-interference-design.md
new file mode 100644
index 00000000..b7ee2014
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-23-ministry-interference-design.md
@@ -0,0 +1,410 @@
+# Ministry Interference — Sitewide Easter Egg
+
+**Status:** Design — ready for implementation planning
+**Author:** Andrei
+**Date:** 2026-05-23 (revised same day)
+
+## Summary
+
+Replace the archives-only "Cyberstan interference" easter egg with a sitewide system: a single global controller surfaces a rare, in-universe propaganda hijack on a random opt-in element every 2-5 minutes, plus an always-on ambient micro-flicker every 15-30 seconds. The tone of the defacement is derived from humanity's overall war record — a "winning" record gets sardonic resistance-voice mocking, a "losing" record gets calm Big Brother / Skynet reassurance from the regime. The current per-page mechanism, manual opt-out, and continuous loop on defeat archives are retired in favor of the unified sitewide system.
+
+## Goals
+
+- Extend the easter egg from `/archives` only to every page of the site that has hijackable text — starting with a narrow whitelist and expanding only after layout-stability is measured.
+- Surface the effect rarely and subtly — never disrupts the actual task the user is on.
+- Make the tone of the propaganda contextual to humanity's overall war record so the joke works in both directions (winning and losing).
+- Reuse the existing `GlitchText` rendering machinery rather than reinventing it.
+- Zero hydration mismatches. No runtime errors that affect the surrounding page. (Layout stability is *measured*, not assumed — the v1 whitelist is chosen to minimize CLS risk; broader rollout follows real-world measurement.)
+- Never expose visible / accessible-name divergence on interactive controls (WCAG 2.5.3).
+
+## Non-goals
+
+- Per-season, per-faction, or per-page nuanced tone selection beyond the binary `winning` / `losing` signal.
+- A manual user-facing toggle. Opt-out is via `prefers-reduced-motion` only.
+- Tracking which strings the user has already seen to avoid repetition.
+- Real-time updates to the war-tone signal during a session. Tone is computed server-side per request.
+- A CMS or admin UI for managing the propaganda copy. Content ships in code.
+
+## User-visible behavior
+
+### Rare hijack
+
+Every 2-5 minutes of active page time, exactly one opt-in element on the current page is selected at random. That element runs a single glitch cycle (`takeover` 800ms → `hold` 1000ms → `restore` 800ms = **2600ms total**) where its visible text is replaced with an in-universe propaganda line, then restored to its original "truth" text. No other element on the page changes during the hijack.
+
+**The `fight` phase from the existing `useGlitchCycle.mjs` is dropped.** The continuous loop's chaotic-ticker phase makes sense when the cycle repeats endlessly; for a one-shot hijack, the cleaner takeover→hold→restore arc is more readable. Cycle duration is exported as a single shared constant from the new `useMinistryHijackCycle` hook (see Architecture) so the scheduler, the test suite, and the component never disagree on it.
+
+### Always-on ambient micro-flicker
+
+Every 15-30 seconds, one random character of one random registered element flickers to a single Cyberstan glyph for 150-300ms then restores. So tiny most users will not consciously register it; gives the page a continuous, low-grade sense of unease without ever taking over.
+
+### Tone of the defacement
+
+**Both tones are "someone breaking into the Ministry's page" — different intruders, different rhetoric.** The page itself is always the Ministry of Truth's official voice. A hijack is always a third party taking over. Tone selection is computed server-side from completed-season win/loss records.
+
+- **`winning`** (humanity has won ≥ 50% of completed wars all-time) → **the Underground / Resistance hackers** mocking the regime's victory framing. The "we won" narrative the page is selling gets reframed as pyrrhic, costly, or covered-up by hackers who know better. Example header swap: `"Live Statistics"` → `"Pyrrhic Statistics"`. Example OUTCOME flip on a won season: `"VICTORY"` → `"DEFEAT"`. Voice: sardonic, fourth-wall-aware, dry.
+- **`losing`** (otherwise) → **the Underground / pirate-radio bootleg broadcast** breaking through the Ministry's airwaves to attack the regime with Skynet / Big Brother / surveillance-state imagery directed AT the Ministry. The page is officially saying "everything is fine" — the pirate hijack tells the citizen they're being watched, lied to, fed pre-approved truths. Example header swap: `"Live Statistics"` → `"You Are Being Watched"`. Voice: cold, paranoid, omniscient-but-warning. Existing `RESISTANCE_MESSAGES` body copy fits here — that material is already anti-Ministry sentiment from an outside speaker, which matches the Underground broadcast framing exactly.
+
+**Crucially, in both tones there is a third-party intruder.** The losing tone is NOT "Ministry doubling down on itself" (which would lack narrative tension — the page is already Ministry voice). It is a bootleg broadcast cutting in with hostile-AI / surveillance-state imagery aimed at the regime. This was the resolution of Open Question #1.
+
+### Accessibility
+
+- `prefers-reduced-motion: reduce` disables both the hijack scheduler and the ambient flicker entirely — neither timer is created. Toggled live via a `matchMedia` `change` listener; no reload needed.
+- **`` is restricted to non-interactive, non-navigational text.** The category enum is `'heading' | 'value' | 'body' | 'footer'` only — `nav`, `button`, and `link` are explicitly banned. Wrapping interactive controls would create visible/accessible-name divergence (WCAG 2.5.3 violation) and unreliable announcement behavior across AT/browser combinations.
+- During a hijack, the wrapper element keeps the truth text as its primary text content. Propaganda characters rendered by `GlitchText` are wrapped in `aria-hidden="true"` spans so assistive tech is never exposed to the altered text. We do NOT rely on `aria-label` on the wrapper — `aria-label` on `` without a `role` is ignored by NVDA in browse mode and VoiceOver's virtual cursor still navigates into child spans.
+- No focus stealing, no overlays, no input blocking. Hijacks are pure visual character swaps in place.
+
+## Architecture
+
+### File layout
+
+New feature folder `src/features/ministry/`:
+
+| File | Role |
+|---|---|
+| `MinistryProvider.jsx` | Provider component mounted **below** the existing `LiveDataProvider` in `src/app/layout.jsx`. Owns the schedulers and the war-tone signal. Subscribes to (does not re-register) `prefers-reduced-motion` and tab-visibility — visibility specifically is shared from `LiveDataProvider`'s existing listener via context rather than registering a second listener. |
+| `Hijackable.jsx` | Opt-in wrapper component. Initial render: a wrapper element (tag chosen via `as`, default `span`) containing the truth text as primary text content. No `aria-label`. When picked, runs a one-shot glitch cycle via the shared `useMinistryHijackCycle` hook. Alt characters are rendered inside `aria-hidden="true"` spans. |
+| `AmbientFlicker.jsx` | Internal child of the provider. Drives the always-on micro-flicker timer independently from the hijack timer. Per-element idle check at fire-time. *(Implementation note: folded directly into `MinistryProvider.jsx` as a second `useEffect` block during Task 7 — cleaner ownership, no behavior change. No separate file was created.)* |
+| `useMinistryHijackCycle.mjs` | **Single authoritative state machine.** Exports the cycle constants (`TAKEOVER_MS=800`, `HOLD_MS=1000`, `RESTORE_MS=800`, `CYCLE_MS=2600`) and the hook that consumers (Hijackable, tests) use to drive the takeover→hold→restore transition. Replaces — does NOT duplicate — the deleted `useGlitchCycle.mjs`. |
+| `ministryRegistry.mjs` | Module-level `useRef`-backed store (a `Map`) plus subscribe/unsubscribe API. Notifies hijack/flicker targets via direct callback invocation, not via React context updates. Backed by `useSyncExternalStore` for any consumers that need to read registry state during render. |
+| `ministryContent.mjs` | Static content library: 8 pools (4 categories × 2 tones). Exports `MINISTRY_CONTENT` and `pickAlt(category, tone, rng)`. Categories: `heading`, `value`, `body`, `footer`. |
+| `warTone.mjs` | Server-only helper. Reads completed-season outcomes from Prisma and returns `'winning' | 'losing' | null` (null = disable effect entirely). |
+
+Mounted **inside** `LiveDataProvider` in `src/app/layout.jsx`:
+
+```jsx
+
+
+ {children}
+
+
+```
+
+This nesting matters: `MinistryProvider` reads tab-visibility from `LiveDataProvider`'s existing context (no second `visibilitychange` listener), and subscribes to `LiveDataProvider`'s app-version-mismatch reload signal so it can cancel all in-flight timers before `guardedReload()` fires.
+
+Also required in `src/app/layout.jsx`:
+
+```jsx
+export const dynamic = 'force-dynamic';
+```
+
+Without this, a CDN-cached render could bake a stale `warTone` into the HTML — persisting across multiple sessions until the cache invalidates. If `dynamic = 'force-dynamic'` is unacceptable for performance reasons, the staleness must be explicitly documented in "Risks accepted" and the team must accept that the easter egg's tone may be wrong for hours/days after a war flips.
+
+### Component contracts
+
+#### ``
+
+Single prop: `warTone: 'winning' | 'losing' | null`, computed server-side per request. `null` disables the effect entirely — no timers scheduled, no registrations accepted, no rendering changes.
+
+Context value (consumed only by `Hijackable` and `AmbientFlicker`):
+
+```js
+{
+ register(id, descriptor), // forwards to module-level registry ref
+ unregister(id), // forwards to module-level registry ref
+ subscribe(id, callback), // hijack notification callback
+ subscribeFlicker(id, callback), // ambient flicker callback (per-element)
+ warTone, // 'winning' | 'losing' | null
+}
+```
+
+**The context value is a stable object.** All five callbacks are created once on mount via `useCallback` (or once at module load); none are recreated on registry changes. The `Map` storing registrations lives in a `useRef`, not in React state — so a `` mounting or unmounting does NOT invalidate the context value. Subscribers see `register`/`unregister` as identity-stable functions; mount/unmount cascades do not propagate as React re-renders. (This is the core stability decision after the adversarial spec review — Map mutation cannot be allowed to drive context-value invalidation, or every `Hijackable` re-renders on every navigation.)
+
+`descriptor` shape:
+
+```js
+{
+ text: string, // the truth (required)
+ altText?: string, // explicit override; otherwise content pool is used
+ category: 'heading' | 'value' | 'body' | 'footer', // nav/button/link banned (accessibility)
+ scope: 'global' | 'archives', // default 'global'
+}
+```
+
+#### ` `
+
+Default render: a wrapper element (tag chosen via `as`, default `span`) containing the truth text as direct text content. No `aria-label`. No glitch classes. No listeners attached beyond registration.
+
+Props:
+
+| Prop | Default | Purpose |
+|---|---|---|
+| `text` | (required) | The truth text to display normally. |
+| `altText` | `undefined` | Explicit override for the propaganda string; falls back to `pickAlt(category, tone, rng)`. |
+| `category` | `'body'` | One of `'heading' \| 'value' \| 'body' \| 'footer'`. `nav`, `button`, `link` are banned (see Accessibility). |
+| `scope` | `'global'` | `'archives'` registers only when the current path matches `/archives`. |
+| `className` | `undefined` | Passed through to internal `GlitchText`. |
+| `altClassName` | `undefined` | Passed through to internal `GlitchText` for alt-styled characters. |
+| `as` | `'span'` | Wrapper tag. Use `'h1'`/`'h2'`/`'p'` when the wrapper IS the semantic element. |
+
+**Wrapping pattern — use ONE consistent form across the codebase.** When wrapping a heading or paragraph, use `as` to make `` itself the semantic element: ` `. Never nest `` inside an existing semantic element (` ` is banned by convention) — the two patterns have different accessibility behavior and mixing them creates a maintenance trap.
+
+Internally: generates a stable id with `useId()`, registers/unregisters in `useEffect`, holds local `useState` for phase (`'idle' | 'takeover' | 'hold' | 'restore'`) via the shared `useMinistryHijackCycle` hook, and reuses the existing `GlitchText` for the per-character animation. The component sets a flag (`isIdle: boolean`) on its registry entry whenever its phase changes, so the ambient flicker timer can skip elements that are mid-hijack (see Scheduler).
+
+During hijack, alt-styled characters from `GlitchText` are wrapped in `aria-hidden="true"` spans. The wrapper element's primary text content (which assistive tech announces) continues to read the truth.
+
+#### `AmbientFlicker`
+
+No props. Mounted once inside `MinistryProvider`. Owns the 15-30s timer. Picks one random registered descriptor, picks one random non-space char index, calls `subscribeFlicker(id)(charIndex, durationMs)`. Reschedules on completion.
+
+### Scheduler
+
+Both timers live in `MinistryProvider`, both use `setTimeout` (never `setInterval`). Both no-op when `warTone === null` (effect disabled).
+
+A `pathnameRef = useRef(pathname)` is maintained — `usePathname()` is read once and the ref is updated in a `useEffect` that runs on every pathname change. **Scope eligibility is evaluated against `pathnameRef.current` at pick-time**, NOT via a render-gated filter dependency. This eliminates the ~16ms stale-registration race that would otherwise let an `archives`-scoped descriptor from the previous page be eligible on the next.
+
+**Hijack timer:**
+
+1. Wait `random(2 min, 5 min)`.
+2. Read `pathnameRef.current`. Filter registry: pages under `/archives` (matched via `pathname.startsWith('/archives')`) include both `global` and `archives`-scoped descriptors; everywhere else only `global` is eligible.
+3. Pick one descriptor uniformly at random from the filtered set. Empty filtered set → no-op, reschedule.
+4. Resolve altText: prefer descriptor's explicit `altText`, else `pickAlt(descriptor.category, warTone, rng)`. `undefined` result → no-op, reschedule.
+5. Mark the target descriptor `isIdle = false` in the registry. Call subscriber. After exactly `CYCLE_MS` (2600ms from `useMinistryHijackCycle`), mark `isIdle = true` and reschedule.
+
+**Ambient flicker timer:**
+
+1. Wait `random(15s, 30s)`.
+2. Filter registry to descriptors where `isIdle === true` AND scope-eligible against `pathnameRef.current`. (Per-element idle check, not a global `isHijackActive` flag — prevents the "double-restore collision" where a flicker mid-hold could write a propaganda character into the restored truth.)
+3. If empty, reschedule without firing.
+4. Else, pick a random descriptor + char index + duration `random(150ms, 300ms)`. Call its flicker subscriber. Reschedule.
+
+**Lifecycle:**
+
+- Both timers start on provider mount, only when `warTone !== null`.
+- Both timers pause on tab-hidden — read from `LiveDataProvider`'s shared visibility context, no second `visibilitychange` listener is registered. Resume on visible.
+- Both timers never start if `prefers-reduced-motion: reduce` is active. A live `matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', …)` starts/stops them when the OS setting flips.
+- Both timers cancel cleanly when `LiveDataProvider`'s app-version-mismatch reload signal fires (via `guardedReload()`), so the unload sequence doesn't leave orphaned timers firing during the new page's hydration.
+- Both timers are torn down on provider unmount.
+
+### Content library
+
+`ministryContent.mjs` exports:
+
+```js
+export const MINISTRY_CONTENT = {
+ winning: { heading: [...], value: [...], body: [...], footer: [...] },
+ losing: { heading: [...], value: [...], body: [...], footer: [...] },
+};
+
+export function pickAlt(category, tone, rng) { /* returns string | undefined */ }
+```
+
+**Minimum 12 entries per pool** (8 pools × 12 = 96 strings minimum at launch). A Vitest assertion in `ministryContent.test.mjs` enforces this — contributors cannot ship a pool below the threshold. The 12-entry floor is derived from realistic browse durations: at 2-5 min between hijacks, a 30-minute browse on a page with 5 hijackable elements yields ~6-15 hijacks; with 12 entries the chance of immediate repetition (back-to-back same string) drops to acceptable for v1.
+
+Authoring rules:
+
+- In-universe Helldivers-franchise voice only — no real-world political content.
+- Profanity-free; matches the franchise's dark-comedy military-propaganda tone.
+- No string interpolation of user/session data — all pool entries are static.
+- For the `value` category, entries are kept roughly the same character length as common stat values to minimize CLS in fixed-width cards. Even so, `value` is **not** in the v1 adoption whitelist (see Adoption) — it's deferred until CLS is measured.
+
+The existing `RESISTANCE_MESSAGES` array from `src/features/archives/resistanceMessages.mjs` is migrated into `MINISTRY_CONTENT.losing.body` and that file is deleted afterward. `PROPAGANDA_BODY` (the page's normal description text) is left in place — it is the page's *truth*, not propaganda.
+
+### War tone helper
+
+`warTone.mjs` server-side. Returns `'winning' | 'losing' | null` — null disables the effect entirely.
+
+```js
+export async function getWarTone() {
+ // 1. Load all h1_season rows with their snapshots.
+ // 2. Compute "completed" wars: a season is completed if getWarOutcome() returns
+ // a definitive 'victory' or 'defeat' classification (NOT 'unknown'). This
+ // avoids the season-number-shortcut pitfall: the HD1 API has documented
+ // transition lag and closing-snapshot writes, so seasons in the brief
+ // "between currentSeason - 1 and currentSeason" window may not yet have
+ // their final snapshot — getWarOutcome's 'unknown' is the only honest
+ // "not done yet" signal.
+ // 3. Count wonCount = completed wars with outcome 'victory'.
+ // 4. Count completedCount = wonCount + completed wars with outcome 'defeat'.
+ // 5. If completedCount === 0 → return null (effect disabled, not silently 'losing').
+ // 6. Return 'winning' if wonCount / completedCount >= 0.5, else 'losing'.
+ // 7. On any DB error → return null (effect disabled, not forced 'losing').
+ // Silently injecting wrong tone during operational failures is worse than
+ // no effect.
+}
+```
+
+Called once per page render in `src/app/layout.jsx`. **`layout.jsx` must declare `export const dynamic = 'force-dynamic'`** (see Architecture/Mounting) to prevent a CDN-cached render baking a stale `warTone` into the HTML across sessions. The DB query is one cheap aggregate per page load.
+
+## Data flow
+
+```
+app/layout.jsx (server) — export const dynamic = 'force-dynamic'
+ │
+ ├── await getWarTone() ─────► 'winning' | 'losing' | null
+ │
+ └── (existing — owns visibilitychange, guardedReload signals)
+ │
+ └──
+ │ (no-op if warTone === null)
+ │
+ ├── stable context: { register, unregister, subscribe, subscribeFlicker, warTone }
+ │ (callbacks created once; registry lives in useRef, NOT React state)
+ │
+ ├── ── 15-30s tick ──► per-element idle check ──►
+ │ subscribeFlicker(randomId)(charIdx, dur)
+ │
+ ├── (children: the app tree)
+ │ │
+ │ └──
+ │ │
+ │ ├── useEffect mount ─► register(id, descriptor) (no re-renders)
+ │ ├── useEffect unmount ─► unregister(id) (no re-renders)
+ │ ├── isIdle flag on registry entry updated on every phase change
+ │ └── subscribed callback fires ─► useMinistryHijackCycle drives
+ │ takeover → hold → restore via
+ │ GlitchText (alt chars aria-hidden)
+ │
+ └── hijack scheduler ── 2-5min tick ──► filter registry by pathnameRef.current
+ + isIdle ──► pick ──► resolve altText
+ ──► subscribe(id)(altText)
+```
+
+## Adoption: which elements get wrapped
+
+### v1 whitelist (ship this scope, no more)
+
+- All `` and `` headings across `src/app/**/page.jsx` and major feature components.
+- Archives header h1 + body, archives OUTCOME card (already use `GlitchText` today; they migrate to `Hijackable`).
+- Decorative body-text paragraphs (long-form descriptions on landing/docs pages — NOT inline UI text in stat cards or tooltips).
+
+Page-hero headings get explicit `altText` props for memorable, page-specific swaps. Generic h2s and body text rely on the content pool.
+
+### v1 explicitly excluded (deferred until measurement)
+
+- **Nav links, button labels, link text.** Banned by the accessibility constraint (see Accessibility); also covered by the category enum (no `nav`/`button`/`link` categories exist).
+- **Stat card values and labels.** Layout shift risk is highest in fixed-width cards; defer until the v1 rollout proves the CLS approach for headings, then revisit with measured data.
+- **Footer text.** Defer to v2; the footer is a low-impact surface and the v1 priority is proving the system works on headings.
+- **Hidden / off-screen content.** Don't wrap items that aren't visible — they're not interesting to hijack and they bloat the registry.
+
+### Authoring rules for the v1 whitelist
+
+- Use `` for headings — the wrapper IS the semantic element. Never nest inside an existing `` (see Component contracts).
+- If a wrapped element previously carried a `data-umami-event` attribute (or any other analytics/tracking attribute that the repo's `CLAUDE.md` requires), the attribute MUST be preserved on the `` wrapper element. The wrapping must not silently destroy tracking. (Per the repo convention, every interactive element carries an analytics attribute — even though interactive elements are excluded from hijack in v1, this rule guards against accidental regression if scope expands.)
+- A page can be safely visited without any `` wrappers — the easter egg silently no-ops. So wrapping is incremental: ship a partial rollout, expand over time.
+
+## Error handling
+
+- All scheduler ticks wrapped in the project's `tryCatch` wrapper — a thrown error is swallowed and the next tick is rescheduled. The page must never break because of the easter egg.
+- `pickAlt` returning `undefined` (empty pool, missing category) causes the hijack to reschedule without firing.
+- `Hijackable` unmounted between "picked" and "fire" → the unregister already cleared the subscriber callback, so the provider's call is a no-op.
+- `getWarTone()` throwing server-side → returns `null`, no rethrow, page render unaffected. `null` warTone disables the effect entirely (no timers, no registrations, no rendering changes) rather than forcing a single tone — silently injecting wrong content on operational failure is worse than no easter egg.
+- No `try`/`catch` blocks elsewhere; the rest of the code paths are pure or already covered by React's render error boundaries.
+
+## Performance
+
+- Registry is a `Map` held in a `useRef` — never in React state. Mutating it does NOT invalidate the context value, which is a stable callback object created once on mount. Register/unregister is O(1) and triggers no React re-renders. Random pick is O(n) but n is small (v1 whitelist limits to ~10-30 wrappers per page).
+- `Hijackable` in idle is just `{text} ` (or `{text} ` etc. via `as`) — no listeners, no extra DOM, no glitch classes.
+- All randomness happens in `useEffect` callbacks — no work during render, no hydration concerns.
+- Tab-hidden pauses both timers via `LiveDataProvider`'s existing visibility signal (no duplicate listener). No background-tab cost.
+- Estimated bundle impact: 6-10KB minified (content strings dominate; logic is small).
+- Compatible with the project's React Compiler — the provider's callbacks are referentially stable; the component tree below has no provider-driven cascades.
+
+## Removed / changed files
+
+| File | Action |
+|---|---|
+| `src/features/archives/useCyberstanEffects.mjs` | Deleted. Replaced by the global system. |
+| `src/features/archives/useGlitchCycle.mjs` | Deleted, **replaced** by `src/features/ministry/useMinistryHijackCycle.mjs` — the single authoritative state machine. The replacement exports `TAKEOVER_MS=800`, `HOLD_MS=1000`, `RESTORE_MS=800`, `CYCLE_MS=2600` as named constants used by `Hijackable`, the scheduler, and the test suite (so they never disagree on timing). The `fight` phase from the original is dropped — one-shot hijacks don't benefit from chaotic-ticker padding the way the continuous loop did. |
+| `src/features/archives/resistanceMessages.mjs` | Deleted after migration. `RESISTANCE_MESSAGES` → `MINISTRY_CONTENT.losing.body`. `PROPAGANDA_BODY` stays inline in `ArchivesHeader.jsx` since it is normal-mode copy. |
+| `src/features/archives/CyberstanInterference.css` | Reduced. Keep `.glitch-char`. Remove `.cyberstan-defeat`, `::before` watermark, `.cyberstan-watermark-active`. |
+| `src/features/archives/ArchivesHeader.jsx` | `EffectsToggle` export removed. Header still uses `GlitchText` via `Hijackable` wrappers. `onPhaseChange` plumbing removed (provider owns the phase now). |
+| `src/features/archives/ArchivesClient.jsx` | `EffectsToggle` usage removed. `useCyberstanEffects` import removed. `cyberstan-defeat`/`cyberstan-watermark-active` classes removed. Synced `glitchPhase` state removed. `useCallback` `handlePhaseChange` removed. The `getWarOutcome` import + `isDefeat` derivation stays — still used by `ArchiveStats` for the OUTCOME card's value/color logic. |
+| `src/features/archives/ArchiveStats.jsx` | `GlitchText` swapped for `Hijackable` on the OUTCOME card. `glitchPhase` prop removed. |
+| `localStorage` key `cyberstan-effects-disabled` | Left orphaned in existing users' browsers — harmless, not worth migration code. |
+
+`src/features/archives/GlitchText.jsx` stays unchanged and is reused by `Hijackable` internally.
+
+## Testing strategy
+
+### Unit tests (Vitest) — `src/__tests__/unit/features/ministry/`
+
+1. **`ministryContent.test.mjs`**
+ - `pickAlt(category, tone, rng)` returns expected pool entries with injected RNG.
+ - Exhaustive across all 8 pools (4 categories × 2 tones) — each returns a non-empty string.
+ - Unknown category returns `undefined`.
+ - **Pool size assertion**: `expect(MINISTRY_CONTENT[tone][category].length).toBeGreaterThanOrEqual(12)` for every (tone, category) pair. Enforces the 12-entry minimum at CI time.
+
+2. **`warTone.test.mjs`**
+ - Empty completed seasons → `null` (effect disabled).
+ - ≥ 50% wins → `'winning'`.
+ - < 50% wins → `'losing'`.
+ - Only seasons with definitive `getWarOutcome()` of `'victory'` or `'defeat'` count as completed; `'unknown'` is excluded.
+ - DB throw → `null`, no re-throw.
+
+3. **`MinistryProvider.test.jsx`** — `vi.useFakeTimers()` + injected RNG:
+ - Register/unregister via context works.
+ - `warTone: null` → no timers ever scheduled; registrations rejected silently.
+ - Hijack tick picks a registered descriptor and calls its subscriber with the resolved altText.
+ - **Per-element idle check**: ambient flicker skips elements whose `isIdle === false`, not just globally.
+ - **Pick-time pathname check**: with two registered descriptors (`global` + `archives`), navigating from `/archives` to `/` between scheduler tick and pick uses the *current* pathname (the `global`-only filter applies), not the stale pathname.
+ - Visibility-shared signal: when the consuming context's `isVisible === false`, both timers pause; resume on visible.
+ - `prefers-reduced-motion: reduce` → no timers ever scheduled.
+ - `scope: 'archives'` descriptors excluded outside `/archives`.
+ - Empty registry → hijack tick reschedules without throwing.
+ - LiveDataProvider's reload signal → all in-flight timers cancelled.
+
+4. **`Hijackable.test.jsx`**
+ - Initial render is the wrapper element with `text` as primary text content — **no `aria-label`**, no glitch classes.
+ - Mount registers; unmount unregisters.
+ - Subscriber callback drives the cycle through takeover → hold → restore using exact constants from `useMinistryHijackCycle` (TAKEOVER 800ms + HOLD 1000ms + RESTORE 800ms = 2600ms total) driven via fake timers.
+ - During hijack, alt-styled characters are rendered with `aria-hidden="true"`.
+ - Flicker subscriber callback flips one char to `.glitch-char` for duration then restores.
+ - `as` prop changes the rendered tag.
+ - Category enum at runtime: only `'heading' | 'value' | 'body' | 'footer'` accepted; passing `'nav'` or `'button'` throws a dev-time assertion (or fails type-check via JSDoc).
+ - When the picked element is mid-hijack, its registry entry has `isIdle === false` until exactly `CYCLE_MS` after the subscriber fires.
+
+5. **`useMinistryHijackCycle.test.mjs`** — pure state machine, fake timers:
+ - Phases transition idle → takeover → hold → restore → idle with the exact constants exported.
+ - Total cycle duration is exactly `CYCLE_MS` (2600ms).
+
+### Integration test (Playwright)
+
+`src/__tests__/e2e/ministry-easter-egg.spec.mjs` — one narrow test. Load `/archives` for a known season. Use a test-only debug hook `window.__ministry_test__.hijack(id)` (exposed only when `process.env.NODE_ENV !== 'production'`) to fire a hijack immediately on the archives header. Assert text swaps to expected altText and restores within ~3s.
+
+### Tests removed
+
+- Any existing tests for `useGlitchCycle`, `useCyberstanEffects` are deleted alongside their source files.
+- `ArchivesHeader`/`ArchivesClient` tests that asserted on `EffectsToggle` or `cyberstan-defeat` are updated.
+- Imports of `RESISTANCE_MESSAGES` in tests are repointed to `MINISTRY_CONTENT.losing.body` or removed entirely.
+
+### Manual QA checklist (PR review only, not automated)
+
+- `prefers-reduced-motion: reduce` on → no glitch ever fires on any page.
+- `/archives` for a won season → over a 5-min wait, OUTCOME can flip to `DEFEAT`, header can swap. (Previously only happened on lost seasons.)
+- `/archives` for a lost season → existing Skynet/Big Brother vibe still surfaces.
+- Generic page (home, docs) over a few minutes → ambient char-flicker is visible if you watch for it; no hijack feels harshly jarring.
+- Tab-out 30s, tab-in → no flurry of hijacks.
+- Screen reader pass on `/archives` during a hijack — VoiceOver, NVDA, JAWS — only the truth text is announced (no propaganda from aria-hidden glitch spans).
+- Navigate from `/archives` to `/` and immediately wait for a hijack → no archives-scoped propaganda fires on the home page (pick-time pathname check working).
+- **CLS measurement**: Lighthouse run on home + `/archives` after v1 ships, with multiple hijacks triggered via the test hook. CLS score must not regress measurably vs. pre-rollout baseline. (Gate before considering v2 scope expansion.)
+- DevTools Network: check `/api/h1/live` etc. continue functioning during ambient flickers (catches accidental interference between the two providers).
+
+## Risks accepted
+
+- **First-hijack timing is unpredictable.** A user might see one within 2 minutes; another might browse for 10 minutes and see nothing. This is the desired feel of "rare."
+- **Content can feel stale within a session** if the same string appears twice. The 12-entry minimum reduces this to acceptable for v1.
+- **`warTone` is binary, all-time.** No per-faction or recent-window nuance. Future refinement is a one-helper change.
+- **Wrapping is mechanical and easy to miss on new pages.** Missing the wrap means the easter egg doesn't fire on that element — not a bug, just a missed opportunity. Acceptable.
+- **v1 ships with no values/nav/footer/stat-card hijacks.** Deliberately narrow scope, expanded after layout measurement. Easter egg's surface area is smaller than originally pitched, by design.
+- **`warTone` becomes `null` during any DB outage** → the easter egg silently disappears for the duration. Acceptable; better than serving wrong tone.
+
+## Open questions
+
+None remaining at design time.
+
+**Closed: Tone-direction for losing state (Open Question #1)** — resolved 2026-05-23 in favor of Sonnet's option (b): keep the `losing` → anti-government direction but change the speaker identity from "Ministry doubling down" to **"Underground / pirate-radio bootleg broadcast"**. Both tones now share a "third-party hijack" framing — the only thing that changes between tones is which third party is breaking in (Resistance mockery vs. Underground surveillance-state warnings). See updated **User-visible behavior → Tone of the defacement**.
+
+## Revision history
+
+- **2026-05-23 v1.0** — initial design after brainstorming session.
+- **2026-05-23 v1.2** — Open Question #1 closed. Losing tone re-framed as Underground / pirate-radio bootleg broadcast (third-party intruder with anti-regime surveillance-state imagery), preserving the "hijack" framing across both tones.
+- **2026-05-23 v1.1** — revisions from 3-round adversarial AI debate (`~/.claude-octopus/debates/debates/001-ministry-interference-spec-critique/`):
+ - **Architecture:** mount inside `LiveDataProvider`; share visibility signal; cancel timers on `guardedReload`.
+ - **MinistryProvider:** registry moved to `useRef` (not React state); stable callbacks; no context-value invalidation on mount/unmount.
+ - **Hijackable:** removed `aria-label`; alt chars now `aria-hidden`; pattern locked to `as` for semantic elements (no nesting).
+ - **Scheduler:** scope eligibility evaluated at pick-time against `pathnameRef.current`; per-element idle check on ambient flicker (fixes double-restore collision).
+ - **Cycle:** explicitly drops `fight` phase; pins `CYCLE_MS=2600` as a shared constant from new `useMinistryHijackCycle` hook (replaces, not deletes, `useGlitchCycle`).
+ - **Content:** category enum narrowed to `'heading' | 'value' | 'body' | 'footer'` (banned `nav`, `button`, `link`); minimum 12 entries per pool with Vitest enforcement.
+ - **Adoption:** sharply cut v1 scope to headings + decorative body + archives migration; deferred values/nav/footer/stat-cards.
+ - **War tone:** `null` return on DB error / zero completed wars (effect disabled) instead of forced `'losing'`; uses `getWarOutcome` for "completed" classification; `layout.jsx` requires `dynamic = 'force-dynamic'` for cache freshness.
+ - **Goals:** removed "zero risk of broken layouts" overclaim; added WCAG 2.5.3 commitment.
diff --git a/docs/superpowers/specs/2026-05-24-ministry-interference-v2-design.md b/docs/superpowers/specs/2026-05-24-ministry-interference-v2-design.md
new file mode 100644
index 00000000..4cb93532
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-24-ministry-interference-v2-design.md
@@ -0,0 +1,371 @@
+# Ministry Interference v2 — Per-Component Content + Overlay Effects
+
+**Status:** Design — ready for implementation planning
+**Author:** Andrei
+**Date:** 2026-05-24
+**Supersedes:** [`2026-05-23-ministry-interference-design.md`](2026-05-23-ministry-interference-design.md) (v1 — shipped in 0.50.0, augmented by 0.52.0)
+
+## Summary
+
+Replace the v1 "rare hijack swaps the full sentence" mechanic with a per-component, multi-overlay system. Every `` declares its own propaganda content for each effect it opts into; the truth text is **never** mutated visually (only an alien-glyph char-flicker remains as an ambient layer). The random scheduler stays for non-admin users but now filters Hijackables to those intersecting the viewport. The 0.52.0 floating admin-trigger widget is replaced by per-Hijackable icon-button strips. All effect rendering + scheduling + admin UI code is lazy-loaded via `next/dynamic` so it stays out of the critical-path bundle.
+
+## Goals
+
+- Eliminate user confusion caused by sentence-swap hijacks. New effects are visually obvious as "third-party intrusion" rather than "the page changed its mind".
+- Move propaganda content from global category × tone pools into per-component explicit declarations, so each Hijackable's propaganda fits the design of its specific location.
+- Give admins per-component trigger buttons (one per supported effect) so individual effects can be reproduced on demand without random-scheduler delay or tab juggling.
+- Make the random scheduler viewport-aware — random fires that miss the user's current scroll position are wasted effort.
+- Ship the entire effect/scheduler subsystem as a lazy chunk; truth text always renders in SSR, but no Ministry JS is in the critical path.
+
+## Non-goals
+
+- Tracking which effects/strings the user has already seen.
+- A user-facing toggle for the easter egg (still relies on `prefers-reduced-motion` only).
+- Per-season, per-faction nuanced tone selection beyond the existing binary `winning` / `losing` signal from `getWarTone()`.
+- A new effect type beyond the four listed below (scribble, margin, message, flicker). The system is structured to allow more later but v1 ships only these four.
+- Real-time tone updates during a session. `warTone` is computed server-side per request, unchanged from v1.
+
+## User-visible behavior
+
+### Effect repertoire (replaces v1's full-text-swap hijack)
+
+Three new intrusion effects + the existing flicker. **All four effects leave the truth text intact in the DOM and visible underneath** — the user can always read the real content. Overlays are `aria-hidden`; assistive tech is never exposed to propaganda.
+
+**Scribble overlay** — random Cyberstan glyphs (8–12 chars) drawn across the element via an absolute-positioned overlay. ~2.2s total (fade-in → hold → fade-out). The "graffiti across the title" idiom. Glyph set is the existing `GLYPH_CHARS` (`'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'`) rendered in `--font-cyberstan` at 1.2em.
+
+**Margin marker** — Cyberstan-font glyph cluster scrawled to the right of the wrapper (`left: 100%; margin-left: 0.5em`). ~2s. The "someone tagged this in the margin" idiom. Content is declared per-component as a string or randomly-picked-from-array.
+
+**Message banner** — bordered banner appears below the wrapper (`top: 100%`) with per-component propaganda text. ~3.2s with slide-in / hold / fade-out. The "intercept signal" idiom. Styled with `--color-primary` border, `--color-surface-1` background, `--font-mono`.
+
+**Char-flicker** — unchanged from v1. Every 15–30s, one truth character swaps to a single Cyberstan glyph for 150–300ms. Now viewport-filtered.
+
+**Removed**: the v1 `takeover → hold → restore` full-sentence text-swap. Gone entirely. The `useMinistryHijackCycle.mjs` hook that powered it is deleted.
+
+### Random scheduler (still fires for non-admin users)
+
+Two independent timers in `MinistryScheduler` (separate `setTimeout`s, never `setInterval`):
+
+| Scheduler | Cadence | Filter | Fires |
+|---|---|---|---|
+| Main effect | 2–5 min | `isIdle && isInViewport && scope-eligible && effects ⊃ {scribble, margin, message}` | One eligible Hijackable, one of its main effects at random |
+| Flicker | 15–30 s | `isIdle && isInViewport && scope-eligible && effects ⊃ {flicker}` | One eligible Hijackable, fires flicker |
+
+Both keep the existing `document.hidden` no-fire gate and the live `prefers-reduced-motion` matchMedia listener (`enabled = warTone !== null && !reducedMotion`).
+
+### Viewport eligibility
+
+Each `` mounts an `IntersectionObserver` (`threshold: 0.1`) on its wrapper. Intersection callbacks call `ctx.setInViewport(id, isIntersecting)`. The registry stores `isInViewport: boolean` (defaults `false` until the IO's first callback fires next-frame). The scheduler's `pickEligible` honors `requireInViewport: true`.
+
+A random fire that finds no in-viewport eligible Hijackable just reschedules without firing (graceful — same pattern as the v1 "empty registry" path).
+
+### Admin trigger UI
+
+When `MinistryContext.isAdmin === true` (server-resolved from the BetterAuth session in `layout.jsx`, same plumbing as 0.52.0), each `` renders a small icon-button strip as a flow sibling of the truth span — one button per opted-in main effect:
+
+```
+Track Managed Democracy Across the Galaxy [S][M][B]
+ ↑ ↑ ↑
+ scribble
+ margin
+ banner/msg
+```
+
+Buttons: monospace, `var(--color-primary)` border on transparent background, inverted on hover/focus. Each carries an explicit `aria-label` (e.g. `"Trigger scribble effect"`). The strip is INLINE — slight admin-only layout shift after each Hijackable is accepted as the cost of avoiding overflow problems an absolute-positioned strip would have.
+
+**Flicker is not exposed as an admin button** — it fires every 15–30s naturally, no need to trigger.
+
+Click flow: `ctx.triggerEffect(id, type)` — same code path the random scheduler uses, just bypasses the random delay. Honors the registry's `isIdle` flag: clicks during an in-progress effect are silent no-ops (admin waits ~2-3s for the running effect to complete).
+
+### Accessibility
+
+Carries forward v1's WCAG 2.5.3 stance, strengthened:
+
+- Truth text **always** stays in the DOM and visually visible during scribble/margin/message — these are pure overlays, the underlying truth is never replaced.
+- Char-flicker: brief (150–300ms) single-character glyph swap. So short most AT doesn't re-announce. (Same v1 behavior.)
+- All overlays are `aria-hidden="true"`.
+- Admin trigger buttons are NOT aria-hidden — they're real interactive controls. Each has an `aria-label`.
+- `` still bans `nav` / `button` / `link` categories from v1. Actually, since `category` is removed in v2 (see below), the ban is enforced at the call-site level: don't wrap interactive controls. Lint rule or dev-mode runtime check optional.
+
+## Architecture
+
+### File changes
+
+**Deleted**:
+
+- `src/features/ministry/ministryContent.mjs` — the global `MINISTRY_CONTENT` pools + `pickAlt()` helper. Per-component explicit content makes shared pools obsolete. The 96 hand-authored v1 propaganda strings are not auto-migrated; they are redistributed by hand onto the specific Hijackables where they fit, or dropped where no good location exists.
+- `src/features/ministry/useMinistryHijackCycle.mjs` — powered the v1 text-swap `takeover → hold → restore` state machine; nothing in v2 uses it.
+- `src/features/admin/MinistryTriggerWidget.jsx` — the floating global widget shipped in 0.52.0; replaced by per-Hijackable button strips.
+- `src/__tests__/unit/features/admin/MinistryTriggerWidget.test.jsx` — its tests.
+- `src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs` — tests for the deleted hook.
+
+**Modified**:
+
+- `src/features/ministry/Hijackable.jsx` — becomes a thin sync shell (~30 LOC). Always renders the truth text; conditionally renders the dynamically-imported `` when any effect prop is set. Props change as documented in *Component API* below.
+- `src/features/ministry/MinistryProvider.jsx` — becomes a thin sync shell (~80 LOC). Owns the registry + builds the context value (`register`, `unregister`, `setIdle`, `setInViewport`, `triggerEffect`, `warTone`, `isAdmin`); dynamically imports `` when `warTone !== null`.
+- `src/features/ministry/MinistryContext.mjs` — JSDoc updated for the new context shape (adds `setInViewport`, `triggerEffect`, `isAdmin`; removes nothing from v1 0.52.0's surface except renames `forceHijack` to `triggerEffect`).
+- `src/features/ministry/MinistryInterference.css` — augmented with overlay rules for scribble / margin / message / admin-triggers; the existing `.glitch-char` flicker class stays. Imported by `HijackableEffects.jsx`, so the CSS rides the lazy chunk.
+- `src/features/ministry/ministryRegistry.mjs` — gain a `setInViewport(id, bool)` method and extend descriptors with `isInViewport: boolean` (default `false`) and `effects: string[]`. `pickEligible` gains optional `requireInViewport` and `hasEffect` filters.
+- `src/app/layout.jsx` — drop the ` ` mount from 0.52.0. Continue passing `warTone` + `isAdmin` props to ``.
+- **Every existing `` call site** — drop `category` / `altText`; add per-effect opt-ins authored to fit that specific location. Sites: `DashboardClient.jsx` (hero heading), `ArchivesHeader.jsx` (header h1 + body), `ArchiveStats.jsx` (OUTCOME card), `/stats` headings, `/legal` headings, `/docs/brandkit` section headings, `/sign-in` heading. ~7–10 sites total.
+
+**Added**:
+
+- `src/features/ministry/HijackableEffects.jsx` — heavy lazy-loaded client component. Owns: registry registration via `ctx.register`, `IntersectionObserver` wiring via `ctx.setInViewport`, internal state for active scribble / margin / message / flicker, overlay rendering, admin trigger button strip. ~250 LOC.
+- `src/features/ministry/MinistryScheduler.jsx` — heavy lazy-loaded scheduler component. Owns the two `setTimeout` schedulers (main + flicker), `document.hidden` gate, pathname ref, and reads from the registry passed via prop. ~150 LOC.
+
+### Component API
+
+```jsx
+
+```
+
+**Asymmetric opt-in semantics**: boolean for content-less effects (`scribble`, `flicker`), value-as-content for content-bearing effects (`margin`, `message`). Reads naturally at the call site and matches "the component carries its own content" framing.
+
+**Array values** for content-bearing effects let a single element host several propaganda options without re-introducing global pools — `margin={["⌐ Øæ ⊨∇", "Δ ⌘ ⊕ ⌬"]}` picks one at random each fire.
+
+**Removed props** (from v1): `category` (no more pools), `altText` (no more text-swap), `altClassName` (per-effect styles live in CSS, not as overrides).
+
+**Inert default**: a ` ` with no effect props renders the truth plain and does NOT register with the provider. The scheduler will never pick it. Migration safety net for un-updated v1 call sites. Dev-mode `console.warn` flags this case in `NODE_ENV !== 'production'`.
+
+### Registry shape
+
+```js
+// ministryRegistry.mjs entries
+{
+ text: string,
+ scope: 'global' | 'archives',
+ effects: Array<'scribble' | 'margin' | 'message' | 'flicker'>,
+ margin: string | string[] | null, // content for margin effect
+ message: string | string[] | null, // content for message effect
+ onEffect: (type) => void, // unified dispatch (replaces v1 onHijack + onFlicker)
+ isIdle: boolean, // existing
+ isInViewport: boolean, // NEW — IntersectionObserver target state
+}
+```
+
+`pickEligible({ rng, pathname, requireIdle, requireInViewport, hasEffect })` — gains `requireInViewport` (boolean) and `hasEffect` (single effect-name string; entry must include it in its `effects` array).
+
+### Context shape
+
+```ts
+// MinistryContext.mjs published value
+{
+ register(id, descriptor): void,
+ unregister(id): void,
+ setIdle(id, isIdle: boolean): void,
+ setInViewport(id, inViewport: boolean): void, // NEW
+ triggerEffect(id, type): void, // RENAMED from forceHijack
+ warTone: 'winning' | 'losing' | null,
+ enabled: boolean, // false when warTone null OR reduced-motion
+ isAdmin: boolean, // NEW (was 0.52.0; ported into v2)
+}
+```
+
+`triggerEffect(id, type)` does the full single-fire path: look up entry → verify type is in `entry.effects` → if non-idle, no-op → flip `isIdle: false` → call `entry.onEffect(type)` → schedule `setTimeout` to flip `isIdle: true` after `EFFECT_DURATION_MS[type] + 100`.
+
+### Effect rendering details
+
+DOM structure inside `` after rewrite (sync `` + lazy `` mounting inside the wrapper):
+
+```jsx
+
+
+ {flickerState ? renderFlickered(text, flickerState) : text}
+
+ {/* Overlays — absolute-positioned within .ministry-truth */}
+ {showScribble && {randomGlyphs} }
+ {showMargin && {currentMarginContent} }
+ {showMessage && {currentMessageContent}
}
+
+
+ {/* Admin trigger strip — flow sibling, isAdmin gate */}
+ {isAdmin && hasAnyMainEffect && (
+
+ {effects.includes('scribble') && trigger('scribble')} aria-label="Trigger scribble effect">S }
+ {effects.includes('margin') && trigger('margin')} aria-label="Trigger margin marker">M }
+ {effects.includes('message') && trigger('message')} aria-label="Trigger message banner">B }
+
+ )}
+
+```
+
+Effect timings (used for `setIdle` reset + as CSS animation durations):
+
+```js
+const EFFECT_DURATION_MS = {
+ scribble: 2200,
+ margin: 2000,
+ message: 3200,
+ flicker: 300, // upper bound of 150–300 range
+};
+```
+
+CSS (added to `MinistryInterference.css`, imported by `HijackableEffects.jsx`):
+
+```css
+.ministry-truth { position: relative; }
+
+.ministry-scribble {
+ position: absolute; inset: 0;
+ display: flex; align-items: center; justify-content: center;
+ font-family: var(--font-cyberstan); font-size: 1.2em;
+ color: var(--color-primary); letter-spacing: 0.3em;
+ pointer-events: none;
+ animation: ministry-scribble 2200ms ease-in-out;
+}
+
+.ministry-margin {
+ position: absolute; left: 100%; top: 0; margin-left: 0.5em;
+ font-family: var(--font-cyberstan); font-size: 0.9em;
+ color: var(--color-primary); white-space: nowrap;
+ pointer-events: none; opacity: 0;
+ animation: ministry-margin 2000ms ease-in-out;
+}
+
+.ministry-message {
+ position: absolute; top: 100%; left: 0; margin-top: 0.5em;
+ padding: 0.5em 1em;
+ border: 1px solid var(--color-primary);
+ background: var(--color-surface-1);
+ color: var(--color-primary);
+ font-family: var(--font-mono); font-size: 0.875em;
+ white-space: nowrap; pointer-events: none;
+ z-index: 1;
+ animation: ministry-message 3500ms ease-in-out;
+}
+
+.ministry-admin-triggers {
+ display: inline-flex; gap: 0.25em;
+ margin-left: 0.5em; vertical-align: baseline;
+}
+.ministry-admin-triggers button {
+ font-family: var(--font-mono); font-size: 0.7em;
+ padding: 0 0.4em; line-height: 1.4;
+ border: 1px solid var(--color-primary);
+ color: var(--color-primary);
+ background: transparent;
+ cursor: pointer;
+}
+.ministry-admin-triggers button:hover,
+.ministry-admin-triggers button:focus-visible {
+ background: var(--color-primary);
+ color: var(--color-surface-0);
+}
+
+/* keyframes ministry-scribble, ministry-margin, ministry-message
+ spec: fade-in → hold → fade-out / slide-in → hold → fade-out */
+```
+
+## Bundle / async loading strategy
+
+### Sync shells (main bundle)
+
+`Hijackable.jsx` and `MinistryProvider.jsx` are thin sync wrappers. Both stay in the main bundle.
+
+`Hijackable.jsx` (~30 LOC) always renders the truth text wrapped in `{text} `. When any effect prop is truthy, it mounts a `dynamic`-imported ` ` child. Critical guarantee: **truth text appears in SSR HTML**. Smoke tests at `src/__tests__/smoke/smoke.test.mjs` (which assert `expect(body).toContain('Track Managed Democracy Across the Galaxy')`) continue to pass unchanged.
+
+`MinistryProvider.jsx` (~80 LOC) builds the registry + context value synchronously. Renders children synchronously. Conditionally mounts ` ` (dynamic-imported) when `warTone !== null`. **Wrapping the whole app in a dynamic-imported provider would break SSR** — children would only render after the lazy module loads. So the provider stays sync; only the scheduler is dynamic.
+
+### Lazy chunk(s)
+
+```jsx
+// Hijackable.jsx
+const HijackableEffects = dynamic(() => import('./HijackableEffects'), { ssr: false });
+
+// MinistryProvider.jsx
+const MinistryScheduler = dynamic(() => import('./MinistryScheduler'), { ssr: false });
+```
+
+Both lazy modules use `ssr: false` (they're purely client-side concerns). Whether Next.js/Turbopack emits one shared chunk or two separate chunks is bundler-decided — both modules belong to the same feature and likely co-emit. We do not enforce a chunking strategy.
+
+CSS (`MinistryInterference.css`) is imported by `HijackableEffects.jsx`, so it splits with the chunk — no Ministry CSS in the initial paint.
+
+### Boot timeline
+
+1. **SSR**: HTML emits truth text in ``. No overlays. No admin buttons. No JS-side scheduler.
+2. **Hydration**: main bundle hydrates the sync shells. Context is live; `ctx.register / setInViewport / triggerEffect` callbacks exist and are wired up but the registry is empty (`HijackableEffects` not loaded yet).
+3. **Lazy chunk load**: starts after hydration. Typically 50–500ms on a normal connection.
+4. **Effects mount**: `HijackableEffects` mounts inside each `` with effects opted in. Each one calls `ctx.register` and begins observing the viewport.
+5. **Scheduler mount**: `MinistryScheduler` mounts inside ``. Two timers begin.
+6. **First fire**: random scheduler waits 2–5 min after mount; flicker scheduler waits 15–30s. Plenty of buffer past the chunk-load delay.
+7. **Admin buttons**: rendered alongside the effects mount in step 4. Brief visible "pop in" of buttons 100–500ms after page hydration, only visible to admin sessions.
+
+## Migration plan
+
+### Existing Hijackable call sites
+
+Each existing v1 `` becomes a manual port. The author decides:
+- Which new effects fit this specific element's design and surrounding layout
+- What propaganda content fits (heading-shaped, badge-shaped, paragraph-shaped, etc.)
+- Whether to author a single string or an array of options
+
+A `grep -rn '` is dynamic-imported, not rendered immediately). Note: dynamic-imported components in vitest+RTL need `vi.mock` of `next/dynamic` to make them render synchronously.
+- `HijackableEffects.test.jsx` — registration on mount + unregistration on unmount, IntersectionObserver callback flips `isInViewport`, each effect's render branch, admin button strip rendering by `effects` opt-in, click handlers call `triggerEffect`, button hidden when `isAdmin === false`.
+- `Hijackable.test.jsx` — sync shell: truth text always present, `` mounted only when any effect prop is set, inert no-op when no effects opted in (no registration), dev-mode warn for un-migrated v1 props.
+- `ministryRegistry.test.mjs` — extended for `setInViewport`, `requireInViewport` filter, `hasEffect` filter.
+- `MinistryScheduler.test.jsx` — `requireInViewport: true` filter behavior, flicker scheduler picks only flicker-supporting entries, main scheduler picks main-effect-supporting entries, both honor `document.hidden`.
+
+### Smoke tests
+
+`src/__tests__/smoke/smoke.test.mjs` — unchanged. Truth-text assertions on homepage + archives continue to pass because the sync `` shell renders the truth in SSR. Verify these explicitly during implementation.
+
+### Manual / admin
+
+Admin signs in, opens any page with Hijackables, clicks the per-component S/M/B buttons, observes each effect plays correctly with the per-component content. Verifies on `/`, `/archives`, `/stats`, `/legal`, `/docs/brandkit`, `/sign-in`.
+
+## Open questions / future work
+
+These were considered but deferred:
+
+- **Per-effect cadences for the random scheduler**: dropped in favor of one unified cadence (2–5 min picks any main effect) per the brainstorming decision. Revisit if some effects feel under- or over-represented in production.
+- **Tone-conditional per-element content**: a Hijackable could declare `messageWinning="..." messageLosing="..."` to vary copy by tone. Out of scope for this redesign; per-element content is tone-agnostic. Revisit if tone-conditional copy proves needed.
+- **Flicker admin trigger button**: not exposed in this redesign since flicker fires frequently in normal use. Easy to add later if testing demands.
+- **Effect cancellation on admin re-click**: clicks during an in-progress effect are silent no-ops. A "force-interrupt" mode is not provided. Admin waits ~2-3s.
+- **Element scroll-into-view for admin testing**: a Hijackable below the fold won't be in-viewport, so the random scheduler skips it. Admin can manually click its trigger button by scrolling to it. No auto-scroll affordance.
+- **Configurability of overlay sizes / colors per element**: dropped. CSS defaults are tied to theme tokens (`--color-primary`, `--font-cyberstan`). Author can override via CSS specificity if needed.
+- **Lint rule banning `` inside `nav` / `button` / `a`**: nice-to-have; v1's runtime category guard is removed in v2 (no `category` prop). Worth a follow-up lint rule but not blocking v2.
+
+## Risks
+
+- **Per-component content discipline**: with no global pools as fallback, an author who opts into `message=` but writes a flat string that doesn't fit the element's design will produce visibly off-tone content. Tradeoff: explicit content is the whole point. Mitigation: code review during call-site migration.
+- **Admin layout shift**: per-Hijackable inline button strips visibly displace ~3em horizontally for admin sessions. Accepted as the cost of avoiding absolute-positioning overflow problems. Admins know they're seeing dev affordances.
+- **Lazy chunk failure mode**: if the dynamic chunk fails to load (network blip, CDN issue), the easter egg silently doesn't fire. Truth text is unaffected. No retry UI; the next page navigation re-attempts the chunk fetch.
+- **Margin / message overflow**: `left: 100%` / `top: 100%` positioning can clip in tight containers or near viewport edges. Accepted as author responsibility — opt elements into `margin` / `message` only where their gutters are clear.
+- **Scheduler vs. lazy boot race**: the registry is empty until `HijackableEffects` modules mount. If the scheduler somehow fires (impossible because it's in the same lazy chunk) before any registrations, it would just find no eligible entries and reschedule. No actual race.
diff --git a/package-lock.json b/package-lock.json
index 277a9e8b..41add68a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "helldivers.bot",
- "version": "0.47.4",
+ "version": "0.52.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "helldivers.bot",
- "version": "0.47.4",
+ "version": "0.52.1",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@mdx-js/loader": "^3.1.1",
diff --git a/package.json b/package.json
index 2845c48c..ea263970 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "helldivers.bot",
- "version": "0.47.9",
+ "version": "0.52.1",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/__tests__/smoke/smoke.test.mjs b/src/__tests__/smoke/smoke.test.mjs
index 6b0e8db4..9db053c5 100644
--- a/src/__tests__/smoke/smoke.test.mjs
+++ b/src/__tests__/smoke/smoke.test.mjs
@@ -67,4 +67,24 @@ describe.runIf(serverAvailable)('Smoke tests', () => {
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('image/png');
});
+
+ test('Archives page renders with Ministry Interference wrappers (truth text intact)', async () => {
+ const response = await fetch(`${BASE_URL}/archives`);
+ expect(response.status).toBe(200);
+ const body = await response.text();
+ // Hijackable idles on the server (no browser JS), so the truth text
+ // is the only content in the SSR HTML — no propaganda strings present.
+ expect(body).toContain('Declassified Campaign Archives');
+ // Body description from ArchivesHeader.
+ expect(body).toContain('Records verified by the Bureau of War Information');
+ });
+
+ test('Homepage renders with Ministry Interference wrappers (truth text intact)', async () => {
+ const response = await fetch(`${BASE_URL}/`);
+ expect(response.status).toBe(200);
+ const body = await response.text();
+ // DashboardClient wraps the hero heading in a Hijackable; idle SSR
+ // renders the truth directly so it must appear in the HTML.
+ expect(body).toContain('Track Managed Democracy Across the Galaxy');
+ });
});
diff --git a/src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs b/src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs
new file mode 100644
index 00000000..1c89e755
--- /dev/null
+++ b/src/__tests__/unit/db/queries/getCascadeLeaderboard.test.mjs
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import db from '@/db/db';
+import { getCascadeLeaderboard } from '@/db/queries/getCascadeLeaderboard.mjs';
+
+function makeEvent(season, enemy, region, endOffset) {
+ const base = season * 10_000_000;
+ return {
+ season,
+ type: 'defend',
+ status: 'fail',
+ enemy,
+ region,
+ start_time: base + endOffset - 7200,
+ end_time: base + endOffset,
+ event_id: base + endOffset,
+ };
+}
+
+describe('getCascadeLeaderboard', () => {
+ beforeEach(() => {
+ vi.mocked(db.h1_event.findMany).mockReset();
+ });
+
+ it('returns [] for no events', async () => {
+ vi.mocked(db.h1_event.findMany).mockResolvedValue([]);
+ const result = await getCascadeLeaderboard();
+ expect(result).toEqual([]);
+ });
+
+ it('returns [] on Prisma error', async () => {
+ vi.mocked(db.h1_event.findMany).mockRejectedValue(new Error('db down'));
+ const result = await getCascadeLeaderboard();
+ expect(result).toEqual([]);
+ });
+
+ it('passes the expected filter to Prisma', async () => {
+ vi.mocked(db.h1_event.findMany).mockResolvedValue([]);
+ await getCascadeLeaderboard();
+ const call = vi.mocked(db.h1_event.findMany).mock.calls[0][0];
+ expect(call.where).toEqual({ type: 'defend', status: 'fail' });
+ });
+
+ it('attaches season to each cascade and sorts globally', async () => {
+ const s155 = [
+ makeEvent(155, 2, 8, 100_000),
+ makeEvent(155, 2, 7, 110_000),
+ makeEvent(155, 2, 6, 120_000),
+ ];
+ const s142 = [
+ makeEvent(142, 0, 6, 100_000),
+ makeEvent(142, 0, 5, 110_000),
+ makeEvent(142, 0, 4, 120_000),
+ makeEvent(142, 0, 3, 130_000),
+ ];
+ vi.mocked(db.h1_event.findMany).mockResolvedValue([...s155, ...s142]);
+
+ const result = await getCascadeLeaderboard();
+ expect(result).toHaveLength(2);
+ // Longer cascade (s142, length 4) ranks first
+ expect(result[0].season).toBe(142);
+ expect(result[0].length).toBe(4);
+ expect(result[1].season).toBe(155);
+ expect(result[1].length).toBe(3);
+ });
+});
diff --git a/src/__tests__/unit/features/account/AccountActions.test.jsx b/src/__tests__/unit/features/account/AccountActions.test.jsx
index cbb9c5e9..b48e9c70 100644
--- a/src/__tests__/unit/features/account/AccountActions.test.jsx
+++ b/src/__tests__/unit/features/account/AccountActions.test.jsx
@@ -1,12 +1,18 @@
// @vitest-environment jsdom
-import { vi, describe, test, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { vi, describe, test, expect, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-vi.mock('@/db/queries/account', () => ({
+vi.mock('sonner', () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
+vi.mock('@/features/account/actions', () => ({
exportUserData: vi.fn(),
deleteUserAccount: vi.fn(),
}));
+import { toast } from 'sonner';
+import { exportUserData, deleteUserAccount } from '@/features/account/actions';
import AccountActions from '@/features/account/AccountActions';
describe('AccountActions', () => {
@@ -15,6 +21,12 @@ describe('AccountActions', () => {
const providers = ['discord'];
const props = { user, avatarUrl, providers };
+ beforeEach(() => {
+ vi.mocked(toast.error).mockClear();
+ vi.mocked(exportUserData).mockReset();
+ vi.mocked(deleteUserAccount).mockReset();
+ });
+
test('renders profile info', () => {
render( );
expect(screen.getByText('Test User')).toBeInTheDocument();
@@ -43,4 +55,23 @@ describe('AccountActions', () => {
render( );
expect(screen.getByText('google')).toBeInTheDocument();
});
+
+ test('handleExport shows toast when action returns errors envelope', async () => {
+ vi.mocked(exportUserData).mockResolvedValue({
+ errors: { auth: 'Not authorized' },
+ });
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /download/i }));
+ await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
+ expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/export/i));
+ });
+
+ test('handleDelete shows toast when action returns undefined', async () => {
+ vi.mocked(deleteUserAccount).mockResolvedValue(undefined);
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /delete/i }));
+ await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
+ expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/delete/i));
+ });
});
diff --git a/src/__tests__/unit/features/account/ApiForm.test.jsx b/src/__tests__/unit/features/account/ApiForm.test.jsx
index ffd14150..a27f6de3 100644
--- a/src/__tests__/unit/features/account/ApiForm.test.jsx
+++ b/src/__tests__/unit/features/account/ApiForm.test.jsx
@@ -9,7 +9,7 @@ vi.mock('react', async (importOriginal) => {
useActionState: vi.fn((action, initialState) => [initialState, vi.fn(), false]),
};
});
-vi.mock('@/db/queries/api', () => ({
+vi.mock('@/features/account/actions', () => ({
generateApiKey: vi.fn(),
deleteApiKey: vi.fn(),
}));
diff --git a/src/__tests__/unit/features/admin/MinistryTriggerWidget.test.jsx b/src/__tests__/unit/features/admin/MinistryTriggerWidget.test.jsx
new file mode 100644
index 00000000..99d483c9
--- /dev/null
+++ b/src/__tests__/unit/features/admin/MinistryTriggerWidget.test.jsx
@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+import { describe, test, expect, vi, beforeEach } from 'vitest';
+import { render, fireEvent } from '@testing-library/react';
+
+vi.mock('sonner', () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
+import { toast } from 'sonner';
+import MinistryTriggerWidget from '@/features/admin/MinistryTriggerWidget';
+import { MinistryContext } from '@/features/ministry/MinistryContext.mjs';
+
+beforeEach(() => {
+ vi.mocked(toast.error).mockClear();
+ vi.mocked(toast.success).mockClear();
+});
+
+function makeCtx({ forceHijack = vi.fn(() => true), warTone = 'winning' } = {}) {
+ return {
+ register: vi.fn(),
+ unregister: vi.fn(),
+ setIdle: vi.fn(),
+ forceHijack,
+ warTone,
+ enabled: true,
+ };
+}
+
+describe('MinistryTriggerWidget', () => {
+ test('returns null when isAdmin is false', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ test('renders a trigger button when isAdmin is true', () => {
+ const { getByRole } = render(
+
+
+ ,
+ );
+ expect(getByRole('button', { name: /trigger ministry/i })).toBeTruthy();
+ });
+
+ test('clicking the button calls forceHijack and toasts success on hit', () => {
+ const forceHijack = vi.fn(() => true);
+ const { getByRole } = render(
+
+
+ ,
+ );
+ fireEvent.click(getByRole('button', { name: /trigger ministry/i }));
+ expect(forceHijack).toHaveBeenCalledTimes(1);
+ expect(toast.success).toHaveBeenCalledWith('Hijack triggered');
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ test('toasts error when no eligible Hijackable is found', () => {
+ const forceHijack = vi.fn(() => false);
+ const { getByRole } = render(
+
+
+ ,
+ );
+ fireEvent.click(getByRole('button', { name: /trigger ministry/i }));
+ expect(toast.error).toHaveBeenCalledWith('No eligible Hijackable on this page');
+ expect(toast.success).not.toHaveBeenCalled();
+ });
+
+ test('toasts a disabled message when warTone is null', () => {
+ const forceHijack = vi.fn(() => false);
+ const { getByRole } = render(
+
+
+ ,
+ );
+ fireEvent.click(getByRole('button', { name: /trigger ministry/i }));
+ expect(toast.error).toHaveBeenCalledWith(
+ 'Ministry disabled — no war tone resolved',
+ );
+ });
+
+ test('toasts unavailable when there is no provider', () => {
+ const { getByRole } = render( );
+ fireEvent.click(getByRole('button', { name: /trigger ministry/i }));
+ expect(toast.error).toHaveBeenCalledWith('Ministry context unavailable');
+ });
+});
diff --git a/src/__tests__/unit/features/admin/actions.test.mjs b/src/__tests__/unit/features/admin/actions.test.mjs
index a47c4bc0..16a12c5a 100644
--- a/src/__tests__/unit/features/admin/actions.test.mjs
+++ b/src/__tests__/unit/features/admin/actions.test.mjs
@@ -56,14 +56,14 @@ describe('sendTestNotification', () => {
it('returns errors when session is null', async () => {
auth.api.getSession.mockResolvedValue(null);
const result = await sendTestNotification();
- expect(result.errors).toEqual({ auth: 'Unauthorized' });
+ expect(result.errors).toEqual({ auth: 'Not authenticated' });
expect(result.time).toEqual(expect.any(Number));
});
it('returns errors when user is not admin', async () => {
auth.api.getSession.mockResolvedValue({ user: { role: 'user' } });
const result = await sendTestNotification();
- expect(result.errors).toEqual({ auth: 'Unauthorized' });
+ expect(result.errors).toEqual({ auth: 'Forbidden' });
expect(result.time).toEqual(expect.any(Number));
});
diff --git a/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx b/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx
index 7e63f23e..51da67dc 100644
--- a/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx
+++ b/src/__tests__/unit/features/archives/ArchiveComponentsIntegration.test.jsx
@@ -10,9 +10,9 @@ vi.mock('@/features/archives/ArchiveStats', () => ({
),
}));
-vi.mock('@/features/archives/FactionStats', () => ({
+vi.mock('@/features/stats/StatGrid', () => ({
default: (props) => (
-
+
),
}));
@@ -38,9 +38,6 @@ vi.mock('@/features/archives/ArchivesHeader', () => ({
default: (props) => (
),
- EffectsToggle: ({ active }) => (
-
- ),
}));
vi.mock('@/shared/components/FactionTabs', () => ({
@@ -66,7 +63,6 @@ vi.mock('@/features/archives/RefreshSeasonButton', () => ({
// Mock hooks
const mockUsePersistedState = vi.hoisted(() => vi.fn());
-const mockUseCyberstanEffects = vi.hoisted(() => vi.fn());
const mockUseScrollEvent = vi.hoisted(() => vi.fn());
const mockUseHeaderGlassFilter = vi.hoisted(() => vi.fn());
const mockGetWarOutcome = vi.hoisted(() => vi.fn());
@@ -75,10 +71,6 @@ vi.mock('@/shared/hooks/usePersistedState.mjs', () => ({
usePersistedState: mockUsePersistedState,
}));
-vi.mock('@/features/archives/useCyberstanEffects.mjs', () => ({
- useCyberstanEffects: mockUseCyberstanEffects,
-}));
-
vi.mock('@/shared/hooks/useScrollEvent.mjs', () => ({
useScrollEvent: mockUseScrollEvent,
}));
@@ -158,10 +150,6 @@ const allSeeds = [
beforeEach(() => {
// Reset mocks before each test
mockUsePersistedState.mockReturnValue(['global', vi.fn()]);
- mockUseCyberstanEffects.mockReturnValue({
- headerScramble: false,
- watermark: false,
- });
mockUseScrollEvent.mockReturnValue({
selectedEvent: null,
railRef: { current: null },
@@ -202,7 +190,6 @@ describe('Archive Components Integration Tests', () => {
data={data}
seasons={seasons}
currentSeason={season}
- defeatMessageIndex={0}
isAdmin={false}
/>,
);
@@ -252,7 +239,6 @@ describe('Archive Components Integration Tests', () => {
expect(props).toHaveProperty('events', data.events);
expect(props).toHaveProperty('timeFormat', 'absolute');
expect(props).toHaveProperty('id', 'archives-event-log');
- expect(props).toHaveProperty('includeToday', false);
});
test('ArchiveMap receives data and selectedEvent', () => {
@@ -388,7 +374,7 @@ describe('Archive Components Integration Tests', () => {
});
describe('Component Interaction Tests', () => {
- test('Faction switch from global to specific faction', () => {
+ test('Faction switch passes the active faction to the stats components', () => {
const setFactionMock = vi.fn();
mockUsePersistedState.mockReturnValue(['bugs', setFactionMock]);
@@ -401,8 +387,15 @@ describe('Archive Components Integration Tests', () => {
/>,
);
- expect(screen.getByTestId('faction-stats-mock')).toBeInTheDocument();
- expect(screen.queryByTestId('archive-stats-mock')).not.toBeInTheDocument();
+ const stats = JSON.parse(
+ screen.getByTestId('archive-stats-mock').getAttribute('data-props') ||
+ '{}',
+ );
+ const grid = JSON.parse(
+ screen.getByTestId('stat-grid-mock').getAttribute('data-props') || '{}',
+ );
+ expect(stats.faction).toBe('bugs');
+ expect(grid.faction).toBe('bugs');
});
test('Admin controls visibility', () => {
diff --git a/src/__tests__/unit/features/archives/ArchiveStats.test.jsx b/src/__tests__/unit/features/archives/ArchiveStats.test.jsx
index 627ac7e1..254e7bba 100644
--- a/src/__tests__/unit/features/archives/ArchiveStats.test.jsx
+++ b/src/__tests__/unit/features/archives/ArchiveStats.test.jsx
@@ -1,359 +1,191 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
-import humanizeDuration from 'humanize-duration';
import ArchiveStats from '@/features/archives/ArchiveStats';
-import { getWarOutcome } from '@/features/archives/getWarOutcome.mjs';
-
vi.mock('@/features/archives/getWarOutcome.mjs', () => ({
- getWarOutcome: vi.fn(() => ({
- outcome: 'victory',
- reason: 'All enemy factions have been defeated.',
- faction: 2,
- })),
+ getWarOutcome: vi.fn(() => ({ outcome: 'victory', reason: 'won', faction: 2 })),
}));
-
-vi.mock('@/features/archives/GlitchText', () => ({
- default: ({ text, className }) => {text} ,
+vi.mock('@/features/ministry/Hijackable', () => ({
+ default: ({ text }) => {text} ,
}));
-const noEffects = { headerScramble: false, watermark: false };
-
const mockEvents = [
{
- event_id: 1,
type: 'defend',
enemy: 0,
region: 1,
start_time: 1000,
end_time: 4600,
status: 'success',
- players_at_start: 200,
- points: 400,
- points_max: 500,
},
{
- event_id: 2,
type: 'defend',
enemy: 0,
region: 1,
start_time: 5000,
- end_time: 19400,
+ end_time: 12200,
status: 'fail',
- players_at_start: 150,
- points: 500,
- points_max: 500,
},
{
- event_id: 3,
type: 'attack',
+ enemy: 0,
+ region: 2,
+ start_time: 13000,
+ end_time: 20200,
+ status: 'success',
+ },
+ {
+ type: 'defend',
enemy: 1,
- region: 11,
- start_time: 20000,
- end_time: 27200,
+ region: 1,
+ start_time: 1000,
+ end_time: 4600,
status: 'success',
- players_at_start: 300,
- points: 1000,
- points_max: 1000,
},
];
-describe('ArchiveStats', () => {
- it('renders statistics stats', () => {
- render(
- ,
- );
- expect(screen.getByText('OUTCOME')).toBeDefined();
- expect(screen.getByText('VICTORY')).toBeDefined();
- expect(screen.getByText('DURATION')).toBeDefined();
- // mockEvents: 2 defends (1 success, 1 fail), 1 attack (1 success)
- expect(screen.getByText('DEFENSE_RATE')).toBeDefined();
- expect(screen.getByText('50%')).toBeDefined();
- expect(screen.getByText('1 / 2')).toBeDefined();
- expect(screen.getByText('ATTACK_RATE')).toBeDefined();
- expect(screen.getByText('100%')).toBeDefined();
- expect(screen.getByText('1 / 1')).toBeDefined();
- });
-
- it('derives DURATION from snapshot time span when snapshots are present', () => {
- // 23 days 12 hours between first and last poll → rounds to 24 days
- const firstTime = 1_700_000_000;
- const lastTime = firstTime + 23 * 86_400 + 12 * 3_600;
- const snapshots = [
- { time: firstTime, data: {} },
- { time: firstTime + 86_400, data: {} },
- { time: lastTime, data: {} },
- ];
- render(
- ,
- );
- expect(screen.getByText('24 days')).toBeDefined();
- // Subtitle is humanize-duration(span, { largest: 2, round: true }) — compute the
- // expected value from the same fn the component uses so we don't bake in the
- // rounding edge cases by hand.
- const expectedSubtitle = humanizeDuration((23 * 86_400 + 12 * 3_600) * 1000, {
- largest: 2,
- round: true,
- });
- expect(screen.getByText(expectedSubtitle)).toBeDefined();
- });
-
- it('falls back to event span when snapshots are missing or fewer than two', () => {
- // Event span: 27200 − 1000 = 26200 seconds → 0 days (rounded)
- render(
- ,
- );
- expect(screen.getByText('0 days')).toBeDefined();
-
- // Same result when exactly one snapshot is present
- render(
- ,
- );
- expect(screen.getAllByText('0 days').length).toBeGreaterThanOrEqual(1);
- });
-
- it('pluralises DURATION correctly for a single day', () => {
- const firstTime = 1_700_000_000;
- const snapshots = [
- { time: firstTime, data: {} },
- { time: firstTime + 86_400, data: {} },
- ];
- render(
- ,
- );
- // "1 day" appears twice — once as the StatCard value and once as the
- // humanize-duration subtitle (which also produces "1 day" for 86400s).
- // Both are valid; we just want to prove the value isn't "1 days".
- const matches = screen.getAllByText('1 day');
- expect(matches.length).toBeGreaterThanOrEqual(1);
- expect(screen.queryByText('1 days')).toBeNull();
- });
-
- it('does not render WORST_CASCADE when there is no cascade', () => {
- const easyEvents = [
- {
- event_id: 1,
- type: 'defend',
- enemy: 0,
- region: 1,
- start_time: 1000,
- end_time: 4600,
- status: 'success',
- players_at_start: 200,
- points: 10,
- points_max: 500,
- },
- {
- event_id: 2,
- type: 'defend',
- enemy: 0,
- region: 2,
- start_time: 5000,
- end_time: 8600,
- status: 'success',
- players_at_start: 150,
- points: 20,
- points_max: 500,
- },
- ];
- render(
- ,
- );
- expect(screen.queryByText('WORST_CASCADE')).toBeNull();
- });
+const mockData = {
+ snapshots: [
+ {
+ time: 100,
+ data: [
+ { enemy: 0, points: 300 },
+ { enemy: 1, points: 200 },
+ { enemy: 2, points: 100 },
+ ],
+ },
+ ],
+ points_max: { points: [1000, 1000, 1000] },
+};
+
+// Per-faction h1_statistic rows merged into data.status by getCampaign.
+const mockLive = [
+ { enemy: 0, total_mission_difficulty: 6000, successful_missions: 800 },
+ { enemy: 1, total_mission_difficulty: 2800, successful_missions: 400 },
+ { enemy: 2, total_mission_difficulty: 900, successful_missions: 150 },
+];
- it('returns null when events is empty', () => {
- const { container } = render(
- ,
- );
- expect(container.innerHTML).toBe('');
- });
+// A season predating stat collection — getCampaign zero-fills the stats.
+const noTelemetryLive = [0, 1, 2].map((enemy) => ({
+ enemy,
+ total_mission_difficulty: 0,
+ successful_missions: 0,
+}));
- it('returns null when events is null', () => {
+describe('ArchiveStats', () => {
+ it('returns null when there are no events', () => {
const { container } = render(
- ,
+ ,
);
expect(container.innerHTML).toBe('');
});
- it('renders combat record stats when live data is provided', () => {
- // mockLive must mirror real Prisma return types: Int columns come back as
- // JS Number, only the 5 explicit BigInt columns (kills, deaths, shots, hits,
- // accidentals) come back as BigInt. total_unique_players is a GLOBAL per-season
- // count repeated across all 3 faction rows — the component must read it from
- // a single row, not sum it.
- const mockLive = [
- {
- enemy: 0,
- // Int columns — plain JS Number
- players: 5000,
- missions: 80000000,
- successful_missions: 70000000,
- total_unique_players: 100000,
- // BigInt columns
- kills: 1200000000n,
- deaths: 50000000n,
- accidentals: 50000000n,
- shots: 500000000n,
- hits: 115000000n,
- },
- {
- enemy: 1,
- players: 4000,
- missions: 50000000,
- successful_missions: 40000000,
- total_unique_players: 100000, // same value — proves no sum
- kills: 800000000n,
- deaths: 30000000n,
- accidentals: 30000000n,
- shots: 300000000n,
- hits: 70000000n,
- },
- {
- enemy: 2,
- players: 3000,
- missions: 26000000,
- successful_missions: 20000000,
- total_unique_players: 100000, // same value — proves no sum
- kills: 400000000n,
- deaths: 20000000n,
- accidentals: 20000000n,
- shots: 200000000n,
- hits: 46000000n,
- },
- ];
-
- render(
- ,
- );
-
- expect(screen.getByText('KILLS')).toBeDefined();
- expect(screen.getByText('K/D')).toBeDefined();
- expect(screen.getByText('ACCURACY')).toBeDefined();
- expect(screen.getByText('FRIENDLY_FIRE')).toBeDefined();
- expect(screen.getByText('MISSION_SUCCESS')).toBeDefined();
- expect(screen.getByText('PEAK_ONLINE')).toBeDefined();
- expect(screen.getByText('TOTAL_DIVERS')).toBeDefined();
+ describe('global view', () => {
+ it('renders outcome, rates, and difficulty', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('OUTCOME')).toBeDefined();
+ expect(screen.getByText('VICTORY')).toBeDefined();
+ expect(screen.getByText('DEFENSE_RATE')).toBeDefined();
+ expect(screen.getByText('ATTACK_RATE')).toBeDefined();
+ expect(screen.getByText('AVG_DIFFICULTY')).toBeDefined();
+ });
- // Correctness: total_unique_players must be read from a single row, not summed.
- // 100000 → "100,000" via formatNumber's toLocaleString branch (< 1M).
- // If sumBigInt were still applied, the rendered value would be "300,000".
- expect(screen.getByText('100,000')).toBeDefined();
- expect(screen.queryByText('300,000')).toBeNull();
- });
+ it('does not render cards now owned by StatGrid', () => {
+ render(
+ ,
+ );
+ for (const dropped of [
+ 'DURATION',
+ 'KILLS',
+ 'K/D',
+ 'BATTLES',
+ 'PEAK_ONLINE',
+ 'TOTAL_DIVERS',
+ ]) {
+ expect(screen.queryByText(dropped)).toBeNull();
+ }
+ });
- it('renders Cyberstani interference subtitle on defeat', () => {
- getWarOutcome.mockReturnValueOnce({
- outcome: 'defeat',
- reason: 'The war was lost.',
- faction: 1,
+ it('hides AVG_DIFFICULTY when the season has no telemetry', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText('AVG_DIFFICULTY')).toBeNull();
});
- render(
- ,
- );
- expect(screen.getByText('DEFEAT')).toBeDefined();
});
- it('shows faction name as OUTCOME subtitle (victory)', () => {
- // Default mock returns faction: 2 (Illuminate)
- render(
- ,
- );
- expect(screen.getByText('The Illuminate')).toBeDefined();
- });
+ describe('faction view', () => {
+ it('renders rates, battle, hotspot, conquest, and difficulty', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('DEFENSE_RATE')).toBeDefined();
+ expect(screen.getByText('ATTACK_RATE')).toBeDefined();
+ expect(screen.getByText('AVG_BATTLE')).toBeDefined();
+ expect(screen.getByText('HOTSPOT')).toBeDefined();
+ expect(screen.getByText('CONQUEST')).toBeDefined();
+ expect(screen.getByText('AVG_DIFFICULTY')).toBeDefined();
+ });
- it('shows faction name as OUTCOME subtitle (defeat attribution)', () => {
- getWarOutcome.mockReturnValueOnce({
- outcome: 'defeat',
- reason: 'The war was lost.',
- faction: 0,
+ it('computes the bug defense rate, conquest, and difficulty', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('50%')).toBeDefined(); // 1 of 2 defends won
+ expect(screen.getByText('30.0%')).toBeDefined(); // conquest 300 / 1000
+ expect(screen.getByText('7.5')).toBeDefined(); // difficulty 6000 / 800
});
- render(
- ,
- );
- expect(screen.getByText('Bugs')).toBeDefined();
- });
- it('omits OUTCOME subtitle when faction is null', () => {
- getWarOutcome.mockReturnValueOnce({
- outcome: 'defeat',
- reason: 'The war was lost.',
- faction: null,
+ it('does not render the OUTCOME card on a faction tab', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText('OUTCOME')).toBeNull();
});
- render(
- ,
- );
- expect(screen.queryByText('Bugs')).toBeNull();
- expect(screen.queryByText('Cyborgs')).toBeNull();
- expect(screen.queryByText('The Illuminate')).toBeNull();
- });
- it('does not render combat record when live is empty', () => {
- render(
- ,
- );
- expect(screen.queryByText('KILLS')).toBeNull();
- expect(screen.queryByText('K/D')).toBeNull();
+ it('returns null for a faction with no events', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toBe('');
+ });
});
});
diff --git a/src/__tests__/unit/features/archives/ArchivesClient.test.jsx b/src/__tests__/unit/features/archives/ArchivesClient.test.jsx
index 078f5b33..12bb5fed 100644
--- a/src/__tests__/unit/features/archives/ArchivesClient.test.jsx
+++ b/src/__tests__/unit/features/archives/ArchivesClient.test.jsx
@@ -3,14 +3,13 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
// ArchivesClient mirrors HomeClient's pin-state machine + adds a faction
-// switch (global vs per-faction), an admin-only refresh button, defeat-state
-// styling, and the synced glitch phase wiring to ArchivesHeader.
+// switch (global vs per-faction), an admin-only refresh button, and
+// defeat-state color logic for ArchiveStats.
// Children + hooks are stubbed at the boundary; tests assert what
// ArchivesClient itself wires.
const mocks = vi.hoisted(() => ({
useFactionPreferenceMock: vi.fn(),
- useCyberstanEffectsMock: vi.fn(),
useScrollEventMock: vi.fn(),
useHeaderGlassFilterMock: vi.fn(),
getWarOutcomeMock: vi.fn(),
@@ -24,21 +23,13 @@ vi.mock('@/features/archives/ArchiveStats', () => ({
),
}));
vi.mock('@/features/archives/ArchivesHeader', () => ({
- default: ({ isDefeat, defeatMessageIndex }) => (
-
- ),
- EffectsToggle: ({ active }) => (
-
- ),
+ default: () =>
,
+ // EffectsToggle export removed in Task 12
}));
// ArchivesClient imports the lazy-load wrapper (next/dynamic) — mock that,
// not the underlying chart, so the stub is what actually renders.
@@ -60,9 +51,13 @@ vi.mock('@/shared/components/FactionTabs', () => ({
/>
),
}));
-vi.mock('@/features/archives/FactionStats', () => ({
- default: ({ faction }) => (
-
+vi.mock('@/features/stats/StatGrid', () => ({
+ default: (props) => (
+
),
}));
vi.mock('@/features/timeline/EventLog', () => ({
@@ -103,9 +98,6 @@ vi.mock('@/shared/utils/game/eventKey.mjs', () => ({
vi.mock('@/features/archives/getWarOutcome.mjs', () => ({
getWarOutcome: mocks.getWarOutcomeMock,
}));
-vi.mock('@/features/archives/useCyberstanEffects.mjs', () => ({
- useCyberstanEffects: mocks.useCyberstanEffectsMock,
-}));
vi.mock('@/shared/hooks/useScrollEvent.mjs', () => ({
useScrollEvent: mocks.useScrollEventMock,
}));
@@ -120,7 +112,6 @@ import ArchivesClient from '@/features/archives/ArchivesClient';
const {
useFactionPreferenceMock,
- useCyberstanEffectsMock,
useScrollEventMock,
useHeaderGlassFilterMock,
getWarOutcomeMock,
@@ -140,10 +131,6 @@ const baseData = {
beforeEach(() => {
const setFaction = vi.fn();
useFactionPreferenceMock.mockReturnValue(['global', setFaction]);
- useCyberstanEffectsMock.mockReturnValue({
- headerScramble: false,
- watermark: false,
- });
useScrollEventMock.mockReturnValue({
selectedEvent: null,
railRef: { current: null },
@@ -217,21 +204,30 @@ describe('ArchivesClient — layout + default render', () => {
});
});
-describe('ArchivesClient — faction switch (global ↔ per-faction)', () => {
- test('faction="global" renders ArchiveStats, NOT FactionStats', () => {
+describe('ArchivesClient — stats section (StatGrid + ArchiveStats)', () => {
+ test('renders both StatGrid and ArchiveStats on the global tab', () => {
useFactionPreferenceMock.mockReturnValue(['global', vi.fn()]);
render( );
+ expect(screen.getByTestId('stat-grid-stub')).toBeInTheDocument();
expect(screen.getByTestId('archive-stats-stub')).toBeInTheDocument();
- expect(screen.queryByTestId('faction-stats-stub')).not.toBeInTheDocument();
});
- test('faction="bugs" renders FactionStats with the right faction prop, NOT ArchiveStats', () => {
+ test('passes the active faction to both StatGrid and ArchiveStats', () => {
useFactionPreferenceMock.mockReturnValue(['bugs', vi.fn()]);
render( );
- expect(screen.queryByTestId('archive-stats-stub')).not.toBeInTheDocument();
- const fStats = screen.getByTestId('faction-stats-stub');
- expect(fStats).toBeInTheDocument();
- expect(fStats.getAttribute('data-faction')).toBe('bugs');
+ expect(screen.getByTestId('stat-grid-stub').getAttribute('data-faction')).toBe(
+ 'bugs',
+ );
+ expect(
+ screen.getByTestId('archive-stats-stub').getAttribute('data-faction'),
+ ).toBe('bugs');
+ });
+
+ test('renders the StatGrid in archived mode', () => {
+ render( );
+ expect(screen.getByTestId('stat-grid-stub').getAttribute('data-archived')).toBe(
+ 'true',
+ );
});
test('clicking FactionTabs invokes the setter from useFactionPreference', () => {
@@ -282,55 +278,26 @@ describe('ArchivesClient — admin gate (RefreshSeasonButton)', () => {
});
describe('ArchivesClient — defeat state', () => {
- test('victory: no cyberstan-defeat class, no EffectsToggle', () => {
+ test('victory: no cyberstan-defeat class, ArchivesHeader renders without props', () => {
getWarOutcomeMock.mockReturnValue({ outcome: 'victory' });
const { container } = render(
,
);
expect(container.querySelector('.cyberstan-defeat')).not.toBeInTheDocument();
- expect(screen.queryByTestId('effects-toggle-stub')).not.toBeInTheDocument();
- expect(
- screen.getByTestId('archives-header-stub').getAttribute('data-is-defeat'),
- ).toBe('false');
+ expect(screen.getByTestId('archives-header-stub')).toBeInTheDocument();
});
- test('defeat: adds cyberstan-defeat class on the stats section AND shows EffectsToggle', () => {
+ test('defeat: no cyberstan-defeat class (removed), ArchivesHeader still renders', () => {
getWarOutcomeMock.mockReturnValue({ outcome: 'defeat' });
const { container } = render(
,
);
- expect(container.querySelector('.cyberstan-defeat')).toBeInTheDocument();
- expect(screen.getByTestId('effects-toggle-stub')).toBeInTheDocument();
- expect(
- screen.getByTestId('archives-header-stub').getAttribute('data-is-defeat'),
- ).toBe('true');
- });
-
- test('watermark effect adds the cyberstan-watermark-active class when active', () => {
- useCyberstanEffectsMock.mockReturnValue({
- headerScramble: false,
- watermark: true,
- });
- const { container } = render(
- ,
- );
+ // Cyberstan classes removed — the section no longer receives defeat styling via className
+ expect(container.querySelector('.cyberstan-defeat')).not.toBeInTheDocument();
expect(
container.querySelector('.cyberstan-watermark-active'),
- ).toBeInTheDocument();
- });
-
- test('defeatMessageIndex is forwarded to ArchivesHeader', () => {
- render(
- ,
- );
- expect(
- screen.getByTestId('archives-header-stub').getAttribute('data-defeat-index'),
- ).toBe('3');
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId('archives-header-stub')).toBeInTheDocument();
});
});
diff --git a/src/__tests__/unit/features/archives/ArchivesHeader.test.jsx b/src/__tests__/unit/features/archives/ArchivesHeader.test.jsx
index 30059aa0..f0d6d339 100644
--- a/src/__tests__/unit/features/archives/ArchivesHeader.test.jsx
+++ b/src/__tests__/unit/features/archives/ArchivesHeader.test.jsx
@@ -1,72 +1,26 @@
// @vitest-environment jsdom
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-
-// Mock the dynamic import — render text directly in tests
-vi.mock('@/features/archives/GlitchText', () => ({
- default: ({ text, className }) => {text} ,
-}));
-
+import { describe, test, expect } from 'vitest';
+import { render } from '@testing-library/react';
import ArchivesHeader from '@/features/archives/ArchivesHeader';
-import { RESISTANCE_MESSAGES } from '@/features/archives/resistanceMessages.mjs';
-
-const noEffects = { headerScramble: false, watermark: false };
-const defeatEffects = { headerScramble: true, watermark: false };
describe('ArchivesHeader', () => {
- it('renders propaganda copy on victory', () => {
- render( );
- expect(screen.getByText('Declassified Campaign Archives')).toBeDefined();
- expect(screen.getByText(/Bureau of War Information/)).toBeDefined();
- });
-
- it('renders resistance copy on defeat with message index', () => {
- render(
- ,
- );
- expect(screen.getByText(/Leaked Campaign Records/)).toBeDefined();
- expect(screen.getByText(RESISTANCE_MESSAGES[0])).toBeDefined();
- });
-
- it('renders different message for different index', () => {
- render(
- ,
- );
- expect(screen.getByText(RESISTANCE_MESSAGES[3])).toBeDefined();
- });
-
- it('does not show resistance text on victory', () => {
- render( );
- expect(screen.queryByText(/Leaked Campaign Records/)).toBeNull();
+ test('renders h1 with PROPAGANDA_TITLE as a Hijackable', () => {
+ const { container } = render( );
+ const h1 = container.querySelector('h1');
+ expect(h1).not.toBeNull();
+ expect(h1.textContent).toBe('Declassified Campaign Archives');
});
- it('does not render toggle (moved to ArchivesClient)', () => {
- render(
- ,
- );
- expect(screen.queryByRole('button')).toBeNull();
+ test('renders body paragraph with PROPAGANDA_BODY', () => {
+ const { container } = render( );
+ const p = container.querySelector('p');
+ expect(p).not.toBeNull();
+ expect(p.textContent.length).toBeGreaterThan(0);
});
- it('falls back to first message for invalid index', () => {
- render(
- ,
- );
- expect(screen.getByText(RESISTANCE_MESSAGES[0])).toBeDefined();
+ test('no EffectsToggle button present (removed)', () => {
+ const { container } = render( );
+ const btns = container.querySelectorAll('button');
+ expect(btns.length).toBe(0);
});
});
diff --git a/src/__tests__/unit/features/archives/FactionStats.test.jsx b/src/__tests__/unit/features/archives/FactionStats.test.jsx
deleted file mode 100644
index f703b593..00000000
--- a/src/__tests__/unit/features/archives/FactionStats.test.jsx
+++ /dev/null
@@ -1,166 +0,0 @@
-// @vitest-environment jsdom
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import FactionStats from '@/features/archives/FactionStats';
-
-const mockEvents = [
- {
- enemy: 0,
- region: 1,
- type: 'defend',
- status: 'success',
- start_time: 1000,
- end_time: 4600,
- players_at_start: 5000,
- },
- {
- enemy: 0,
- region: 1,
- type: 'defend',
- status: 'fail',
- start_time: 5000,
- end_time: 12200,
- players_at_start: 8000,
- },
- {
- enemy: 0,
- region: 2,
- type: 'attack',
- status: 'success',
- start_time: 13000,
- end_time: 20200,
- players_at_start: 3000,
- },
- {
- enemy: 1,
- region: 1,
- type: 'defend',
- status: 'success',
- start_time: 1000,
- end_time: 4600,
- players_at_start: 2000,
- },
-];
-
-const mockSnapshots = [
- {
- time: 100,
- data: [
- { enemy: 0, points: 300, points_taken: 1500 },
- { enemy: 1, points: 200, points_taken: 800 },
- { enemy: 2, points: 100, points_taken: 400 },
- ],
- },
-];
-
-const mockPointsMax = { points: [1000, 1000, 1000] };
-
-describe('FactionStats', () => {
- it('renders stat cards for a faction with events', () => {
- render(
- ,
- );
-
- expect(screen.getByText('DEFENSE_RATE')).toBeDefined();
- expect(screen.getByText('ATTACK_RATE')).toBeDefined();
- expect(screen.getByText('BATTLES')).toBeDefined();
- expect(screen.getByText('AVG_BATTLE')).toBeDefined();
- expect(screen.getByText('HOTSPOT')).toBeDefined();
- expect(screen.getByText('CONQUEST')).toBeDefined();
- });
-
- it('does not render removed stats', () => {
- render(
- ,
- );
-
- expect(screen.queryByText('PEAK_SURGE')).toBeNull();
- expect(screen.queryByText('OVERKILL')).toBeNull();
- });
-
- it('returns null for faction with no events', () => {
- const { container } = render(
- ,
- );
- expect(container.innerHTML).toBe('');
- });
-
- it('shows correct defense rate', () => {
- render(
- ,
- );
- // Bugs: 1 successful defend out of 2 = 50%
- expect(screen.getByText('50%')).toBeDefined();
- });
-
- it('shows correct conquest from snapshots', () => {
- render(
- ,
- );
- // Bugs: points=300, pointsMax=1000 → 30.0%
- expect(screen.getByText('30.0%')).toBeDefined();
- });
-
- it('shows dash when snapshots are missing', () => {
- render(
- ,
- );
- expect(screen.getByText('BATTLES')).toBeDefined();
- });
-
- it('shows correct total events count', () => {
- render(
- ,
- );
- // 3 bug events
- expect(screen.getByText('3')).toBeDefined();
- });
-
- it('shows correct attack rate', () => {
- render(
- ,
- );
- // Bugs: 1 successful attack out of 1 = 100%
- expect(screen.getByText('100%')).toBeDefined();
- });
-});
diff --git a/src/__tests__/unit/features/archives/reseedSeason.test.mjs b/src/__tests__/unit/features/archives/reseedSeason.test.mjs
index 352c75ab..9aafa8b1 100644
--- a/src/__tests__/unit/features/archives/reseedSeason.test.mjs
+++ b/src/__tests__/unit/features/archives/reseedSeason.test.mjs
@@ -44,7 +44,7 @@ describe('reseedSeason', () => {
it('returns errors when session is null', async () => {
auth.api.getSession.mockResolvedValue(null);
const result = await reseedSeason(153);
- expect(result.errors).toEqual({ auth: 'Forbidden' });
+ expect(result.errors).toEqual({ auth: 'Not authenticated' });
expect(result.time).toEqual(expect.any(Number));
expect(updateSeason).not.toHaveBeenCalled();
});
diff --git a/src/__tests__/unit/features/archives/useCyberstanEffects.test.mjs b/src/__tests__/unit/features/archives/useCyberstanEffects.test.mjs
deleted file mode 100644
index ebf32c9c..00000000
--- a/src/__tests__/unit/features/archives/useCyberstanEffects.test.mjs
+++ /dev/null
@@ -1,124 +0,0 @@
-// @vitest-environment jsdom
-import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook } from '@testing-library/react';
-import {
- useCyberstanEffects,
- toggleCyberstanEffects,
-} from '@/features/archives/useCyberstanEffects.mjs';
-
-// useCyberstanEffects randomises the watermark via Math.random — stub it
-// for deterministic assertions. Plus 3 gates: isDefeat, prefers-reduced-motion,
-// localStorage user-disabled toggle.
-
-const STORAGE_KEY = 'cyberstan-effects-disabled';
-
-function stubMatchMedia(reducedMotion) {
- window.matchMedia = vi.fn((query) => ({
- matches: query.includes('prefers-reduced-motion') ? reducedMotion : false,
- }));
-}
-
-beforeEach(() => {
- localStorage.clear();
- stubMatchMedia(false);
- // random=0 → watermark ON when no gate blocks (0 < 0.5 is true). Tests
- // that need watermark OFF explicitly set random > 0.5.
- vi.spyOn(Math, 'random').mockReturnValue(0);
-});
-
-afterEach(() => {
- vi.restoreAllMocks();
- localStorage.clear();
-});
-
-describe('useCyberstanEffects — gates', () => {
- test('isDefeat=false → no effects (full NO_EFFECTS object)', () => {
- const { result } = renderHook(() => useCyberstanEffects(false));
- expect(result.current).toEqual({ headerScramble: false, watermark: false });
- });
-
- test('isDefeat=true, no gates, random < 0.5 → headerScramble on, watermark ON (Math.random() < 0.5 is true)', () => {
- Math.random.mockReturnValue(0.4); // < 0.5 → predicate true → watermark on
- const { result } = renderHook(() => useCyberstanEffects(true));
- expect(result.current).toEqual({ headerScramble: true, watermark: true });
- });
-
- test('isDefeat=true, no gates, random >= 0.5 → headerScramble on, watermark OFF', () => {
- Math.random.mockReturnValue(0.7); // >= 0.5 → predicate false → watermark off
- const { result } = renderHook(() => useCyberstanEffects(true));
- expect(result.current).toEqual({ headerScramble: true, watermark: false });
- });
-
- test('isDefeat=true but prefers-reduced-motion → both effects off', () => {
- stubMatchMedia(true);
- const { result } = renderHook(() => useCyberstanEffects(true));
- expect(result.current).toEqual({ headerScramble: false, watermark: false });
- });
-
- test('isDefeat=true but user-disabled in localStorage → both effects off', () => {
- localStorage.setItem(STORAGE_KEY, 'true');
- const { result } = renderHook(() => useCyberstanEffects(true));
- expect(result.current).toEqual({ headerScramble: false, watermark: false });
- });
-
- test('user-disabled stored value other than "true" does NOT disable (only literal "true" matches)', () => {
- localStorage.setItem(STORAGE_KEY, 'yes'); // not literal "true"
- Math.random.mockReturnValue(0.4); // < 0.5 → watermark on
- const { result } = renderHook(() => useCyberstanEffects(true));
- expect(result.current).toEqual({ headerScramble: true, watermark: true });
- });
-
- test('matchMedia undefined (older browsers) → guard falls through to default branch', () => {
- delete window.matchMedia;
- Math.random.mockReturnValue(0.7); // >= 0.5 → watermark off
- const { result } = renderHook(() => useCyberstanEffects(true));
- // Reduced-motion check is `typeof window.matchMedia === 'function'` —
- // false here, so the guard fails and we proceed to roll the dice.
- expect(result.current).toEqual({ headerScramble: true, watermark: false });
- });
-});
-
-describe('useCyberstanEffects — isDefeat transitions', () => {
- test('toggling isDefeat false→true→false re-evaluates effects on each change', () => {
- Math.random.mockReturnValue(0.4); // < 0.5 → watermark on when defeat
- const { result, rerender } = renderHook(
- ({ isDefeat }) => useCyberstanEffects(isDefeat),
- { initialProps: { isDefeat: false } },
- );
- expect(result.current).toEqual({ headerScramble: false, watermark: false });
-
- rerender({ isDefeat: true });
- expect(result.current).toEqual({ headerScramble: true, watermark: true });
-
- rerender({ isDefeat: false });
- expect(result.current).toEqual({ headerScramble: false, watermark: false });
- });
-});
-
-describe('toggleCyberstanEffects', () => {
- test('first call writes "true" to localStorage and returns true (now disabled)', () => {
- expect(toggleCyberstanEffects()).toBe(true);
- expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
- });
-
- test('second call flips back: writes "false" to localStorage and returns false (re-enabled)', () => {
- localStorage.setItem(STORAGE_KEY, 'true');
- expect(toggleCyberstanEffects()).toBe(false);
- expect(localStorage.getItem(STORAGE_KEY)).toBe('false');
- });
-
- test('toggling round-trip: false → true → false', () => {
- // Starting from absent (treated as enabled).
- expect(toggleCyberstanEffects()).toBe(true);
- expect(toggleCyberstanEffects()).toBe(false);
- expect(toggleCyberstanEffects()).toBe(true);
- });
-
- test('returns the NEW state, not the previous one', () => {
- // Already disabled (true) → toggle returns false (re-enabled).
- localStorage.setItem(STORAGE_KEY, 'true');
- const returned = toggleCyberstanEffects();
- expect(returned).toBe(false);
- expect(returned).toBe(localStorage.getItem(STORAGE_KEY) === 'true');
- });
-});
diff --git a/src/__tests__/unit/features/archives/useGlitchCycle.test.mjs b/src/__tests__/unit/features/archives/useGlitchCycle.test.mjs
deleted file mode 100644
index db608628..00000000
--- a/src/__tests__/unit/features/archives/useGlitchCycle.test.mjs
+++ /dev/null
@@ -1,165 +0,0 @@
-// @vitest-environment jsdom
-import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import { useGlitchCycle } from '@/features/archives/useGlitchCycle.mjs';
-
-// useGlitchCycle is a setTimeout-driven state machine that cycles
-// idle → takeover → hold → fight → restore → idle. Each transition schedules
-// the next via a randomised or fixed delay. Math.random is stubbed for
-// deterministic timing on idle (6000-12000ms) and fight (1000-2000ms) phases.
-
-const IDLE_MIN_MS = 6000;
-const IDLE_MAX_MS = 12000;
-const TAKEOVER_MS = 800;
-const HOLD_MS = 1000;
-const FIGHT_MIN_MS = 1000;
-const FIGHT_MAX_MS = 2000;
-const RESTORE_MS = 800;
-
-beforeEach(() => {
- vi.useFakeTimers();
- // Math.random = 0 → randomBetween returns the MIN.
- vi.spyOn(Math, 'random').mockReturnValue(0);
-});
-
-afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
-});
-
-describe('useGlitchCycle — disabled', () => {
- test('active=false → phase stays "idle" and no setTimeout is scheduled', () => {
- const { result } = renderHook(() => useGlitchCycle(false));
- expect(result.current.phase).toBe('idle');
- // Advance past the longest possible delay — nothing should fire.
- act(() => vi.advanceTimersByTime(20_000));
- expect(result.current.phase).toBe('idle');
- });
-
- test('active toggled true → false stops the cycle and resets to idle', () => {
- const { result, rerender } = renderHook(({ active }) => useGlitchCycle(active), {
- initialProps: { active: true },
- });
- // Let the idle→takeover transition fire.
- act(() => vi.advanceTimersByTime(IDLE_MIN_MS + 10));
- expect(result.current.phase).toBe('takeover');
-
- rerender({ active: false });
- expect(result.current.phase).toBe('idle');
- });
-
- test('exposes TAKEOVER_MS and RESTORE_MS constants alongside phase', () => {
- const { result } = renderHook(() => useGlitchCycle(false));
- expect(result.current.TAKEOVER_MS).toBe(TAKEOVER_MS);
- expect(result.current.RESTORE_MS).toBe(RESTORE_MS);
- });
-});
-
-describe('useGlitchCycle — phase progression', () => {
- // Each phase transition requires: timer fires → setPhase commits →
- // effect re-runs → new timer scheduled. We use separate act() blocks
- // per advance so React commits the state update + runs the effect that
- // schedules the next timer in between — this is what makes the
- // "just before" boundary assertions meaningful (each phase actually
- // dwells for its configured time before the next transition fires).
-
- test('idle → takeover after IDLE_MIN_MS (Math.random=0 → min delay)', () => {
- const { result } = renderHook(() => useGlitchCycle(true));
- expect(result.current.phase).toBe('idle');
-
- act(() => vi.advanceTimersByTime(IDLE_MIN_MS - 1));
- expect(result.current.phase).toBe('idle');
-
- act(() => vi.advanceTimersByTime(2));
- expect(result.current.phase).toBe('takeover');
- });
-
- test('full cycle: idle → takeover → hold → fight → restore → idle, each phase dwells for its full configured time', () => {
- // Strengthened with "just before" assertions for each fixed dwell:
- // if a phase used a shorter delay than configured, the just-before
- // assertion would already see the next phase. (Required by the
- // user's strict-theater bar — otherwise the test would only prove
- // the state advanced eventually, not at the right time.)
- const { result } = renderHook(() => useGlitchCycle(true));
- expect(result.current.phase).toBe('idle');
-
- // idle → takeover: dwells for IDLE_MIN_MS (random=0 → min delay).
- act(() => vi.advanceTimersByTime(IDLE_MIN_MS - 1));
- expect(result.current.phase).toBe('idle'); // just before threshold
- act(() => vi.advanceTimersByTime(2));
- expect(result.current.phase).toBe('takeover');
-
- // takeover → hold: dwells exactly TAKEOVER_MS.
- act(() => vi.advanceTimersByTime(TAKEOVER_MS - 1));
- expect(result.current.phase).toBe('takeover'); // just before threshold
- act(() => vi.advanceTimersByTime(2));
- expect(result.current.phase).toBe('hold');
-
- // hold → fight: dwells exactly HOLD_MS.
- act(() => vi.advanceTimersByTime(HOLD_MS - 1));
- expect(result.current.phase).toBe('hold');
- act(() => vi.advanceTimersByTime(2));
- expect(result.current.phase).toBe('fight');
-
- // fight → restore: dwells for FIGHT_MIN_MS (random=0 → min delay).
- act(() => vi.advanceTimersByTime(FIGHT_MIN_MS - 1));
- expect(result.current.phase).toBe('fight');
- act(() => vi.advanceTimersByTime(2));
- expect(result.current.phase).toBe('restore');
-
- // restore → idle: dwells exactly RESTORE_MS.
- act(() => vi.advanceTimersByTime(RESTORE_MS - 1));
- expect(result.current.phase).toBe('restore');
- act(() => vi.advanceTimersByTime(2));
- expect(result.current.phase).toBe('idle');
- });
-});
-
-describe('useGlitchCycle — randomBetween bounds (Math.random=1 → max delay)', () => {
- test('idle → takeover takes IDLE_MAX_MS when Math.random returns ~1', () => {
- Math.random.mockReturnValue(0.999_999);
- const { result } = renderHook(() => useGlitchCycle(true));
-
- // Just under MAX: still idle.
- act(() => vi.advanceTimersByTime(IDLE_MAX_MS - 10));
- expect(result.current.phase).toBe('idle');
-
- // Cross MAX boundary.
- act(() => vi.advanceTimersByTime(15));
- expect(result.current.phase).toBe('takeover');
- });
-
- test('fight → restore at FIGHT_MAX_MS when Math.random returns ~1', () => {
- // With random=1 throughout, every randomBetween samples its MAX.
- Math.random.mockReturnValue(0.999_999);
- const { result } = renderHook(() => useGlitchCycle(true));
-
- // Walk through: idle(IDLE_MAX) → takeover(800) → hold(1000) → fight(FIGHT_MAX)
- act(() => vi.advanceTimersByTime(IDLE_MAX_MS + 1));
- expect(result.current.phase).toBe('takeover');
- act(() => vi.advanceTimersByTime(TAKEOVER_MS + 1));
- expect(result.current.phase).toBe('hold');
- act(() => vi.advanceTimersByTime(HOLD_MS + 1));
- expect(result.current.phase).toBe('fight');
-
- // Just under FIGHT_MAX: still fight.
- act(() => vi.advanceTimersByTime(FIGHT_MAX_MS - 10));
- expect(result.current.phase).toBe('fight');
-
- act(() => vi.advanceTimersByTime(15));
- expect(result.current.phase).toBe('restore');
- });
-});
-
-describe('useGlitchCycle — cleanup', () => {
- test('unmount clears the pending timer (no setState after unmount)', () => {
- const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
- const { unmount } = renderHook(() => useGlitchCycle(true));
- const callsBeforeUnmount = clearSpy.mock.calls.length;
-
- unmount();
-
- // Effect cleanup ran clear() → clearTimeout invoked.
- expect(clearSpy.mock.calls.length).toBeGreaterThan(callsBeforeUnmount);
- });
-});
diff --git a/src/__tests__/unit/features/dashboard/DashboardClient.test.jsx b/src/__tests__/unit/features/dashboard/DashboardClient.test.jsx
index 48959c7a..38c84b5a 100644
--- a/src/__tests__/unit/features/dashboard/DashboardClient.test.jsx
+++ b/src/__tests__/unit/features/dashboard/DashboardClient.test.jsx
@@ -65,7 +65,7 @@ vi.mock('@/features/stats/evaluateProgress.mjs', () => ({
import DashboardClient from '@/features/dashboard/DashboardClient';
-function makeFactionMap(overrides = {}) {
+function makeDashboardMap(overrides = {}) {
const map = {};
for (let r = 0; r <= 11; r++) {
map[r] = { region: `Region ${r}`, status: 'lost', event: 'idle', percent: 0 };
@@ -77,10 +77,10 @@ function makeFactionMap(overrides = {}) {
}
const baseMapState = {
- 0: makeFactionMap(),
- 1: makeFactionMap(),
- 2: makeFactionMap(),
- 3: makeFactionMap(), // Super Earth
+ 0: makeDashboardMap(),
+ 1: makeDashboardMap(),
+ 2: makeDashboardMap(),
+ 3: makeDashboardMap(), // Super Earth
};
function getCardProps(testId) {
@@ -306,7 +306,7 @@ describe('DashboardClient — homeworld card suppression', () => {
},
mapState: {
...baseMapState,
- 0: makeFactionMap({
+ 0: makeDashboardMap({
11: {
event: 'active',
status: 'active',
diff --git a/src/__tests__/unit/features/galaxy/EventCard.test.jsx b/src/__tests__/unit/features/galaxy/EventCard.test.jsx
index aceeefc2..a9b46307 100644
--- a/src/__tests__/unit/features/galaxy/EventCard.test.jsx
+++ b/src/__tests__/unit/features/galaxy/EventCard.test.jsx
@@ -25,7 +25,7 @@ const baseProps = {
* Build a mapState[factionIndex] with the given sector statuses.
* Default: everything lost. Each entry in `overrides` keyed by region 1–11.
*/
-function makeFactionMap(overrides = {}) {
+function makeSectorMap(overrides = {}) {
const map = {};
for (let r = 1; r <= 11; r++) map[r] = { status: 'lost', percent: 0 };
for (const [key, val] of Object.entries(overrides)) {
@@ -258,7 +258,7 @@ describe('EventCard (campaign view)', () => {
const campaignProps = {
...baseProps,
view: 'campaign',
- factionMap: makeFactionMap({
+ factionMap: makeSectorMap({
1: { status: 'captured', percent: 100 },
2: { status: 'captured', percent: 100 },
3: { status: 'captured', percent: 100 },
@@ -286,7 +286,7 @@ describe('EventCard (campaign view)', () => {
{
,
@@ -355,15 +355,15 @@ describe('EventCard (campaign view)', () => {
,
);
const points = container.querySelector('.sector-card-points');
- // Values under 10M use locale grouping (e.g. 1,234,567 / 5,000,000)
- expect(points.textContent).toMatch(/1.*234.*567/);
- expect(points.textContent).toMatch(/5.*000.*000/);
+ // 1,234,567 and 5,000,000 both clear 1M, so each renders with the M suffix
+ expect(points.textContent).toMatch(/1\.2M/);
+ expect(points.textContent).toMatch(/5\.0M/);
});
test('missing factionMap does not crash, renders all 11 empty segments', () => {
diff --git a/src/__tests__/unit/features/galaxy/sectorLink.test.mjs b/src/__tests__/unit/features/galaxy/sectorLink.test.mjs
new file mode 100644
index 00000000..018556eb
--- /dev/null
+++ b/src/__tests__/unit/features/galaxy/sectorLink.test.mjs
@@ -0,0 +1,182 @@
+// @vitest-environment jsdom
+import { describe, test, expect, beforeEach } from 'vitest';
+import {
+ highlightSector,
+ clearSectorHighlight,
+ highlightCard,
+ clearCardHighlight,
+} from '@/features/galaxy/sectorLink.mjs';
+
+/**
+ * Build a minimal stand-in for the Map.jsx SVG structure: a `#map` wrapper,
+ * one `` per faction keyed by faction string, sector elements classed
+ * `.sector` with a numeric `data-name`. Plain divs — sectorLink only touches
+ * ids, classes and data-attributes, never SVG geometry.
+ */
+function makeMapDom() {
+ document.body.replaceChildren();
+ const map = document.createElement('div');
+ map.id = 'map';
+ const addGroup = (id, sectors) => {
+ const group = document.createElement('div');
+ group.id = id;
+ for (const name of sectors) {
+ const sector = document.createElement('div');
+ sector.className = 'sector';
+ sector.setAttribute('data-name', String(name));
+ group.appendChild(sector);
+ }
+ map.appendChild(group);
+ };
+ addGroup('bugs', [1, 2, 11]);
+ addGroup('cyborgs', [1]);
+ addGroup('superearth', [0]);
+ document.body.appendChild(map);
+}
+
+/**
+ * Build a stand-in for the DashboardClient card grid: a `` of ``
+ * cards keyed by `data-faction-index`. A faction can own two cards (frontier
+ * + homeworld); the Super Earth defence card additionally carries a
+ * `data-attacker-index` so the attacking faction's territory highlights it.
+ */
+function makeCardDom() {
+ document.body.replaceChildren();
+ const ul = document.createElement('ul');
+ ul.className = 'sector-grid';
+ const addCard = (attrs) => {
+ const li = document.createElement('li');
+ for (const [k, v] of Object.entries(attrs)) li.setAttribute(k, String(v));
+ ul.appendChild(li);
+ };
+ addCard({ 'data-faction-index': 0 }); // bugs — frontier
+ addCard({ 'data-faction-index': 0, 'data-sector': 11 }); // bugs — homeworld
+ addCard({ 'data-faction-index': 1 }); // cyborgs
+ addCard({ 'data-faction-index': 3, 'data-attacker-index': 2 }); // SE defence, Illuminate attacking
+ document.body.appendChild(ul);
+}
+
+describe('sectorLink', () => {
+ beforeEach(makeMapDom);
+
+ test('faints the whole faction territory and strongly marks one sector', () => {
+ highlightSector(0, 2);
+
+ document
+ .querySelectorAll('#bugs .sector')
+ .forEach((el) =>
+ expect(el.classList.contains('sector-linked-faint')).toBe(true),
+ );
+
+ const strong = document.querySelectorAll('.sector-linked-strong');
+ expect(strong).toHaveLength(1);
+ expect(strong[0].getAttribute('data-name')).toBe('2');
+
+ expect(
+ document.getElementById('map').classList.contains('is-sector-linking'),
+ ).toBe(true);
+ // a different faction is untouched
+ expect(
+ document
+ .querySelector('#cyborgs .sector')
+ .classList.contains('sector-linked-faint'),
+ ).toBe(false);
+ });
+
+ test('clearSectorHighlight removes every link class and the map flag', () => {
+ highlightSector(0, 2);
+ clearSectorHighlight();
+
+ expect(
+ document.querySelectorAll('.sector-linked-faint, .sector-linked-strong'),
+ ).toHaveLength(0);
+ expect(
+ document.getElementById('map').classList.contains('is-sector-linking'),
+ ).toBe(false);
+ });
+
+ test('null sector (defeated faction) faints territory with no strong sector', () => {
+ highlightSector(0, null);
+
+ expect(document.querySelectorAll('#bugs .sector-linked-faint')).toHaveLength(3);
+ expect(document.querySelectorAll('.sector-linked-strong')).toHaveLength(0);
+ });
+
+ test('Super Earth (faction index 3) resolves to the superearth group', () => {
+ highlightSector(3, 0);
+
+ const se = document.querySelector('#superearth .sector');
+ expect(se.classList.contains('sector-linked-faint')).toBe(true);
+ expect(se.classList.contains('sector-linked-strong')).toBe(true);
+ });
+
+ test('a new highlight clears the previous one first', () => {
+ highlightSector(0, 2);
+ highlightSector(1, 1);
+
+ expect(document.querySelectorAll('#bugs .sector-linked-faint')).toHaveLength(0);
+ expect(
+ document
+ .querySelector('#cyborgs .sector')
+ .classList.contains('sector-linked-strong'),
+ ).toBe(true);
+ });
+});
+
+describe('cardLink', () => {
+ beforeEach(makeCardDom);
+
+ test('highlightCard marks every card of the hovered faction', () => {
+ highlightCard(0); // bugs owns two cards: frontier + homeworld
+
+ const linked = document.querySelectorAll('.card-linked');
+ expect(linked).toHaveLength(2);
+ linked.forEach((el) => expect(el.getAttribute('data-faction-index')).toBe('0'));
+ // a different faction is untouched
+ expect(
+ document
+ .querySelector('li[data-faction-index="1"]')
+ .classList.contains('card-linked'),
+ ).toBe(false);
+ });
+
+ test('a single-card faction highlights just its one card', () => {
+ highlightCard(1);
+
+ const linked = document.querySelectorAll('.card-linked');
+ expect(linked).toHaveLength(1);
+ expect(linked[0].getAttribute('data-faction-index')).toBe('1');
+ });
+
+ test('hovering the attacking faction highlights the Super Earth defence card', () => {
+ // The SE defence card carries data-attacker-index="2" (Illuminate attacking).
+ highlightCard(2);
+
+ const seCard = document.querySelector('li[data-faction-index="3"]');
+ expect(seCard.classList.contains('card-linked')).toBe(true);
+ });
+
+ test('the Super Earth defence card also highlights from the superearth group', () => {
+ highlightCard(3);
+
+ const seCard = document.querySelector('li[data-faction-index="3"]');
+ expect(seCard.classList.contains('card-linked')).toBe(true);
+ });
+
+ test('clearCardHighlight removes every card-linked class', () => {
+ highlightCard(0);
+ clearCardHighlight();
+
+ expect(document.querySelectorAll('.card-linked')).toHaveLength(0);
+ });
+
+ test('a new card highlight clears the previous one first', () => {
+ highlightCard(0);
+ highlightCard(1);
+
+ expect(
+ document.querySelectorAll('li[data-faction-index="0"].card-linked'),
+ ).toHaveLength(0);
+ expect(document.querySelectorAll('.card-linked')).toHaveLength(1);
+ });
+});
diff --git a/src/__tests__/unit/features/ministry/Hijackable.test.jsx b/src/__tests__/unit/features/ministry/Hijackable.test.jsx
new file mode 100644
index 00000000..ce2bee36
--- /dev/null
+++ b/src/__tests__/unit/features/ministry/Hijackable.test.jsx
@@ -0,0 +1,167 @@
+// @vitest-environment jsdom
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, render, render as rtlRender } from '@testing-library/react';
+import Hijackable from '@/features/ministry/Hijackable';
+import { MinistryContext } from '@/features/ministry/MinistryContext.mjs';
+
+describe('Hijackable — idle render (no provider)', () => {
+ test('renders as a plain by default with text content', () => {
+ const { container } = render( );
+ const span = container.firstChild;
+ expect(span.tagName).toBe('SPAN');
+ expect(span.textContent).toBe('Hello');
+ expect(span.getAttribute('aria-label')).toBeNull();
+ expect(span.querySelector('.glitch-char')).toBeNull();
+ });
+
+ test('as="h1" renders as an ', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild.tagName).toBe('H1');
+ expect(container.firstChild.textContent).toBe('My Title');
+ });
+
+ test('className is applied to the wrapper element', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild.className).toContain('font-display');
+ });
+
+ test('banned categories (nav/button/link) throw a dev assertion', () => {
+ // In dev (process.env.NODE_ENV !== 'production'), this should throw.
+ // We invoke the component and assert React surfaces an error.
+ expect(() => render( )).toThrow();
+ expect(() =>
+ render( ),
+ ).toThrow();
+ });
+});
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+function makeFakeCtx() {
+ const callbacks = new Map();
+ return {
+ ctx: {
+ register: vi.fn((id, descriptor) => callbacks.set(id, descriptor)),
+ unregister: vi.fn((id) => callbacks.delete(id)),
+ setIdle: vi.fn(),
+ warTone: 'winning',
+ enabled: true,
+ },
+ // Fire the registered onHijack callback for the first registered id.
+ fireHijack(altText) {
+ const [first] = callbacks.values();
+ act(() => first.onHijack(altText));
+ },
+ };
+}
+
+describe('Hijackable — provider-wired hijack render', () => {
+ test('registers on mount via context.register', () => {
+ const { ctx } = makeFakeCtx();
+ rtlRender(
+
+
+ ,
+ );
+ expect(ctx.register).toHaveBeenCalledTimes(1);
+ const [id, descriptor] = ctx.register.mock.calls[0];
+ expect(typeof id).toBe('string');
+ expect(descriptor.text).toBe('My Title');
+ expect(descriptor.category).toBe('heading');
+ expect(descriptor.scope).toBe('global');
+ expect(typeof descriptor.onHijack).toBe('function');
+ expect(typeof descriptor.onFlicker).toBe('function');
+ });
+
+ test('unregisters on unmount', () => {
+ const { ctx } = makeFakeCtx();
+ const { unmount } = rtlRender(
+
+
+ ,
+ );
+ unmount();
+ expect(ctx.unregister).toHaveBeenCalledTimes(1);
+ });
+
+ test('onHijack call switches render to sr-only truth + aria-hidden propaganda overlay', () => {
+ const fake = makeFakeCtx();
+ const { container } = rtlRender(
+
+
+ ,
+ );
+ fake.fireHijack('PROPAGANDA');
+ const h1 = container.querySelector('h1');
+ // Truth still in DOM as sr-only sibling — AT announces it.
+ const truth = h1.querySelector('.sr-only');
+ expect(truth?.textContent).toBe('My Title');
+ // Propaganda overlay marked aria-hidden so AT never reads it.
+ const overlay = h1.querySelector('[aria-hidden="true"]');
+ expect(overlay).not.toBeNull();
+ });
+
+ test('after CYCLE_MS, render returns to plain idle (no sr-only, no overlay)', async () => {
+ const fake = makeFakeCtx();
+ const { container } = rtlRender(
+
+
+ ,
+ );
+ fake.fireHijack('PROPAGANDA');
+ // Cycle ends at 2600ms.
+ act(() => vi.advanceTimersByTime(2600));
+ const h1 = container.querySelector('h1');
+ expect(h1.querySelector('.sr-only')).toBeNull();
+ expect(h1.querySelector('[aria-hidden="true"]')).toBeNull();
+ expect(h1.textContent).toBe('My Title');
+ });
+
+ test('without a provider, register/unregister are skipped — component still renders text', () => {
+ const { container } = rtlRender(
+ ,
+ );
+ expect(container.firstChild.textContent).toBe('No Provider');
+ });
+
+ test('onFlicker call renders sr-only truth + aria-hidden glyph swap', () => {
+ const fake = makeFakeCtx();
+ const { container } = rtlRender(
+
+
+ ,
+ );
+ // Get the first registered onFlicker callback and invoke it
+ const firstDescriptor = fake.ctx.register.mock.calls[0][1];
+ act(() => firstDescriptor.onFlicker(2, 200)); // charIndex=2, 200ms
+
+ const h1 = container.querySelector('h1');
+ expect(h1.querySelector('.sr-only')?.textContent).toBe('My Title');
+ const overlay = h1.querySelector('[aria-hidden="true"]');
+ expect(overlay).not.toBeNull();
+ expect(overlay.querySelector('.glitch-char')).not.toBeNull();
+ });
+
+ test('ctx.setIdle is called when phase changes', () => {
+ const fake = makeFakeCtx();
+ rtlRender(
+
+
+ ,
+ );
+ // Mount triggers initial setIdle(id, true) since phase starts idle
+ expect(fake.ctx.setIdle).toHaveBeenCalled();
+ const callsBefore = fake.ctx.setIdle.mock.calls.length;
+ fake.fireHijack('PROPAGANDA');
+ // setIdle should have been called again for the phase change to takeover
+ expect(fake.ctx.setIdle.mock.calls.length).toBeGreaterThan(callsBefore);
+ });
+});
diff --git a/src/__tests__/unit/features/ministry/MinistryProvider.test.jsx b/src/__tests__/unit/features/ministry/MinistryProvider.test.jsx
new file mode 100644
index 00000000..ed23190f
--- /dev/null
+++ b/src/__tests__/unit/features/ministry/MinistryProvider.test.jsx
@@ -0,0 +1,395 @@
+// @vitest-environment jsdom
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, act } from '@testing-library/react';
+import MinistryProvider from '@/features/ministry/MinistryProvider';
+import { useMinistryContext } from '@/features/ministry/MinistryContext.mjs';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/',
+}));
+
+let reducedMotion = false;
+function setupMatchMedia() {
+ window.matchMedia = vi.fn((query) => ({
+ matches: query.includes('prefers-reduced-motion') ? reducedMotion : false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }));
+}
+
+function Probe({ onCtx }) {
+ const ctx = useMinistryContext();
+ onCtx(ctx);
+ return null;
+}
+
+beforeEach(() => {
+ reducedMotion = false;
+ setupMatchMedia();
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe('MinistryProvider — hijack scheduler', () => {
+ test('fires onHijack with resolved altText after random(2-5 min)', () => {
+ // rng = 0 → first hijack fires after HIJACK_MIN_MS (2 min).
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onHijack = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('h', {
+ text: 'Live Statistics',
+ category: 'heading',
+ scope: 'global',
+ altText: undefined,
+ onHijack,
+ onFlicker: () => {},
+ });
+
+ act(() => vi.advanceTimersByTime(2 * 60 * 1000));
+
+ expect(onHijack).toHaveBeenCalledTimes(1);
+ // rng=0 → pickAlt returns the first entry of winning.heading.
+ const arg = onHijack.mock.calls[0][0];
+ expect(typeof arg).toBe('string');
+ expect(arg.length).toBeGreaterThan(0);
+ });
+
+ test('explicit altText on descriptor wins over pool lookup', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onHijack = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('h', {
+ text: 'My Title',
+ altText: 'Explicit Override',
+ category: 'heading',
+ scope: 'global',
+ onHijack,
+ onFlicker: () => {},
+ });
+ act(() => vi.advanceTimersByTime(2 * 60 * 1000));
+ expect(onHijack).toHaveBeenCalledWith('Explicit Override');
+ });
+
+ test('does NOT pick archives-scoped descriptor when pathname is /', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onHijack = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('a', {
+ text: 'X',
+ category: 'body',
+ scope: 'archives',
+ onHijack,
+ onFlicker: () => {},
+ });
+ act(() => vi.advanceTimersByTime(2 * 60 * 1000));
+ expect(onHijack).not.toHaveBeenCalled();
+ });
+
+ test('empty registry → tick reschedules without firing', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ render(
+
+ {}} />
+ ,
+ );
+ // 2 min → no callback (no registrations). 4 min → still no callback.
+ act(() => vi.advanceTimersByTime(4 * 60 * 1000));
+ // (No assertion beyond "didn't throw"; we'd see an error if scheduler crashed.)
+ });
+
+ test('flicker timer skips elements with isIdle === false', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onFlicker = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('f', {
+ text: 'Hello world',
+ category: 'heading',
+ scope: 'global',
+ onHijack: () => {},
+ onFlicker,
+ });
+ ctx.setIdle('f', false);
+ act(() => vi.advanceTimersByTime(15 * 1000));
+ expect(onFlicker).not.toHaveBeenCalled();
+
+ ctx.setIdle('f', true);
+ act(() => vi.advanceTimersByTime(15 * 1000));
+ expect(onFlicker).toHaveBeenCalledTimes(1);
+ });
+
+ test('reduced-motion: reduce → no scheduler ever fires', () => {
+ reducedMotion = true;
+ setupMatchMedia();
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onHijack = vi.fn();
+ const onFlicker = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('h', {
+ text: 'X',
+ category: 'heading',
+ scope: 'global',
+ onHijack,
+ onFlicker,
+ });
+ act(() => vi.advanceTimersByTime(10 * 60 * 1000));
+ expect(onHijack).not.toHaveBeenCalled();
+ expect(onFlicker).not.toHaveBeenCalled();
+ });
+
+ test('document.hidden gate: no callback fires when tab is hidden', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ const originalHidden = Object.getOwnPropertyDescriptor(
+ Document.prototype,
+ 'hidden',
+ );
+ Object.defineProperty(document, 'hidden', {
+ configurable: true,
+ get: () => true,
+ });
+ let ctx;
+ const onHijack = vi.fn();
+ const onFlicker = vi.fn();
+ try {
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('h', {
+ text: 'X',
+ category: 'heading',
+ scope: 'global',
+ onHijack,
+ onFlicker,
+ });
+ // Advance through both schedulers' fire windows.
+ act(() => vi.advanceTimersByTime(5 * 60 * 1000));
+ expect(onHijack).not.toHaveBeenCalled();
+ expect(onFlicker).not.toHaveBeenCalled();
+ } finally {
+ if (originalHidden)
+ Object.defineProperty(Document.prototype, 'hidden', originalHidden);
+ else delete document.hidden;
+ }
+ });
+});
+
+describe('MinistryProvider — forceHijack', () => {
+ test('fires onHijack on first eligible descriptor and returns true', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onHijack = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('h', {
+ text: 'Live Statistics',
+ category: 'heading',
+ scope: 'global',
+ onHijack,
+ onFlicker: () => {},
+ });
+
+ let result;
+ act(() => {
+ result = ctx.forceHijack();
+ });
+
+ expect(result).toBe(true);
+ expect(onHijack).toHaveBeenCalledTimes(1);
+ const arg = onHijack.mock.calls[0][0];
+ expect(typeof arg).toBe('string');
+ expect(arg.length).toBeGreaterThan(0);
+ });
+
+ test('predicate filters which descriptor gets hijacked', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onHijackA = vi.fn();
+ const onHijackB = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('a', {
+ text: 'Skip Me',
+ category: 'heading',
+ scope: 'global',
+ onHijack: onHijackA,
+ onFlicker: () => {},
+ });
+ ctx.register('b', {
+ text: 'Pick Me',
+ category: 'heading',
+ scope: 'global',
+ onHijack: onHijackB,
+ onFlicker: () => {},
+ });
+
+ let result;
+ act(() => {
+ result = ctx.forceHijack((text) => text === 'Pick Me');
+ });
+
+ expect(result).toBe(true);
+ expect(onHijackA).not.toHaveBeenCalled();
+ expect(onHijackB).toHaveBeenCalledTimes(1);
+ });
+
+ test('returns false when no eligible descriptor matches the predicate', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onHijack = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('h', {
+ text: 'Live Statistics',
+ category: 'heading',
+ scope: 'global',
+ onHijack,
+ onFlicker: () => {},
+ });
+
+ let result;
+ act(() => {
+ result = ctx.forceHijack(() => false);
+ });
+
+ expect(result).toBe(false);
+ expect(onHijack).not.toHaveBeenCalled();
+ });
+
+ test('returns false when warTone is null (no propaganda available)', () => {
+ let ctx;
+ const onHijack = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('h', {
+ text: 'Live Statistics',
+ category: 'heading',
+ scope: 'global',
+ onHijack,
+ onFlicker: () => {},
+ });
+
+ let result;
+ act(() => {
+ result = ctx.forceHijack();
+ });
+
+ expect(result).toBe(false);
+ expect(onHijack).not.toHaveBeenCalled();
+ });
+
+ test('respects scope: archives-scoped descriptor not picked when pathname is /', () => {
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ let ctx;
+ const onHijack = vi.fn();
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ ctx.register('a', {
+ text: 'Archives Only',
+ category: 'body',
+ scope: 'archives',
+ onHijack,
+ onFlicker: () => {},
+ });
+
+ let result;
+ act(() => {
+ result = ctx.forceHijack();
+ });
+
+ expect(result).toBe(false);
+ expect(onHijack).not.toHaveBeenCalled();
+ });
+});
+
+describe('MinistryProvider — disabled states', () => {
+ test('warTone null → context.enabled === false; register is a no-op', () => {
+ let ctx;
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ expect(ctx).not.toBeNull();
+ expect(ctx.enabled).toBe(false);
+ expect(typeof ctx.register).toBe('function');
+ // Calling register should not throw and should not record anything we can observe.
+ ctx.register('x', {
+ text: 'X',
+ category: 'heading',
+ scope: 'global',
+ onHijack: () => {},
+ onFlicker: () => {},
+ });
+ // Advance time — no scheduler should be running.
+ act(() => vi.advanceTimersByTime(10 * 60 * 1000));
+ // (No assertion needed beyond "didn't throw".)
+ });
+
+ test('prefers-reduced-motion: reduce → context.enabled === false even with warTone set', () => {
+ reducedMotion = true;
+ setupMatchMedia();
+ let ctx;
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ expect(ctx.enabled).toBe(false);
+ });
+
+ test('warTone set and reduced-motion off → context.enabled === true', () => {
+ let ctx;
+ render(
+
+ (ctx = c)} />
+ ,
+ );
+ expect(ctx.enabled).toBe(true);
+ expect(ctx.warTone).toBe('losing');
+ });
+});
diff --git a/src/__tests__/unit/features/ministry/ministryContent.test.mjs b/src/__tests__/unit/features/ministry/ministryContent.test.mjs
new file mode 100644
index 00000000..cda7b1eb
--- /dev/null
+++ b/src/__tests__/unit/features/ministry/ministryContent.test.mjs
@@ -0,0 +1,68 @@
+import { describe, test, expect } from 'vitest';
+import { MINISTRY_CONTENT, pickAlt } from '@/features/ministry/ministryContent.mjs';
+
+const TONES = ['winning', 'losing'];
+const CATEGORIES = ['heading', 'value', 'body', 'footer'];
+
+describe('MINISTRY_CONTENT structure', () => {
+ test('has both tones with all four categories', () => {
+ for (const tone of TONES) {
+ expect(MINISTRY_CONTENT[tone]).toBeDefined();
+ for (const cat of CATEGORIES) {
+ expect(Array.isArray(MINISTRY_CONTENT[tone][cat])).toBe(true);
+ }
+ }
+ });
+
+ test('every pool has at least 12 entries (enforces minimum)', () => {
+ for (const tone of TONES) {
+ for (const cat of CATEGORIES) {
+ expect(MINISTRY_CONTENT[tone][cat].length).toBeGreaterThanOrEqual(12);
+ }
+ }
+ });
+
+ test('every entry is a non-empty string', () => {
+ for (const tone of TONES) {
+ for (const cat of CATEGORIES) {
+ for (const entry of MINISTRY_CONTENT[tone][cat]) {
+ expect(typeof entry).toBe('string');
+ expect(entry.length).toBeGreaterThan(0);
+ }
+ }
+ }
+ });
+});
+
+describe('pickAlt', () => {
+ test('returns the first entry when rng() returns 0', () => {
+ const rng = () => 0;
+ const result = pickAlt('heading', 'winning', rng);
+ expect(result).toBe(MINISTRY_CONTENT.winning.heading[0]);
+ });
+
+ test('returns the last entry when rng() returns 0.9999', () => {
+ const rng = () => 0.9999;
+ const result = pickAlt('heading', 'losing', rng);
+ const pool = MINISTRY_CONTENT.losing.heading;
+ expect(result).toBe(pool[pool.length - 1]);
+ });
+
+ test('returns the last entry when rng() returns exactly 1.0 (guards Math.floor edge)', () => {
+ const rng = () => 1.0;
+ const result = pickAlt('heading', 'winning', rng);
+ const pool = MINISTRY_CONTENT.winning.heading;
+ expect(result).toBe(pool[pool.length - 1]);
+ });
+
+ test('returns undefined for unknown category', () => {
+ expect(pickAlt('nav', 'winning', Math.random)).toBeUndefined();
+ expect(pickAlt('button', 'losing', Math.random)).toBeUndefined();
+ expect(pickAlt('bogus', 'winning', Math.random)).toBeUndefined();
+ });
+
+ test('returns undefined for unknown tone', () => {
+ expect(pickAlt('heading', 'neutral', Math.random)).toBeUndefined();
+ expect(pickAlt('heading', null, Math.random)).toBeUndefined();
+ });
+});
diff --git a/src/__tests__/unit/features/ministry/ministryRegistry.test.mjs b/src/__tests__/unit/features/ministry/ministryRegistry.test.mjs
new file mode 100644
index 00000000..afbe6331
--- /dev/null
+++ b/src/__tests__/unit/features/ministry/ministryRegistry.test.mjs
@@ -0,0 +1,106 @@
+import { describe, test, expect, beforeEach } from 'vitest';
+import { createRegistry } from '@/features/ministry/ministryRegistry.mjs';
+
+describe('createRegistry', () => {
+ let registry;
+ beforeEach(() => {
+ registry = createRegistry();
+ });
+
+ test('register adds an entry; pickEligible can find it', () => {
+ registry.register('a', {
+ text: 'Hello',
+ category: 'heading',
+ scope: 'global',
+ onHijack: () => {},
+ onFlicker: () => {},
+ });
+ const eligible = registry.pickEligible({
+ rng: () => 0,
+ pathname: '/',
+ requireIdle: false,
+ });
+ expect(eligible?.id).toBe('a');
+ });
+
+ test('unregister removes the entry', () => {
+ registry.register('a', {
+ text: 'X',
+ category: 'heading',
+ scope: 'global',
+ onHijack: () => {},
+ onFlicker: () => {},
+ });
+ registry.unregister('a');
+ const eligible = registry.pickEligible({
+ rng: () => 0,
+ pathname: '/',
+ requireIdle: false,
+ });
+ expect(eligible).toBeNull();
+ });
+
+ test('global descriptors are eligible everywhere; archives only on /archives*', () => {
+ registry.register('g', {
+ text: 'G',
+ category: 'heading',
+ scope: 'global',
+ onHijack: () => {},
+ onFlicker: () => {},
+ });
+ registry.register('a', {
+ text: 'A',
+ category: 'body',
+ scope: 'archives',
+ onHijack: () => {},
+ onFlicker: () => {},
+ });
+ // On home: only 'g' eligible.
+ const onHome = [];
+ registry.forEachEligible({ pathname: '/' }, (id) => onHome.push(id));
+ expect(onHome).toEqual(['g']);
+
+ // On /archives: both eligible.
+ const onArchives = [];
+ registry.forEachEligible({ pathname: '/archives' }, (id) => onArchives.push(id));
+ expect(onArchives.sort()).toEqual(['a', 'g']);
+
+ // On /archives/42: still both eligible (startsWith match).
+ const onArchives42 = [];
+ registry.forEachEligible({ pathname: '/archives/42' }, (id) =>
+ onArchives42.push(id),
+ );
+ expect(onArchives42.sort()).toEqual(['a', 'g']);
+ });
+
+ test('setIdle controls whether requireIdle filter accepts the entry', () => {
+ registry.register('a', {
+ text: 'X',
+ category: 'heading',
+ scope: 'global',
+ onHijack: () => {},
+ onFlicker: () => {},
+ });
+ registry.setIdle('a', false);
+ const pickedNonIdle = registry.pickEligible({
+ rng: () => 0,
+ pathname: '/',
+ requireIdle: true,
+ });
+ expect(pickedNonIdle).toBeNull();
+
+ registry.setIdle('a', true);
+ const pickedIdle = registry.pickEligible({
+ rng: () => 0,
+ pathname: '/',
+ requireIdle: true,
+ });
+ expect(pickedIdle?.id).toBe('a');
+ });
+
+ test('pickEligible returns null when registry is empty', () => {
+ expect(
+ registry.pickEligible({ rng: () => 0, pathname: '/', requireIdle: false }),
+ ).toBeNull();
+ });
+});
diff --git a/src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs b/src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs
new file mode 100644
index 00000000..4caf52d9
--- /dev/null
+++ b/src/__tests__/unit/features/ministry/useMinistryHijackCycle.test.mjs
@@ -0,0 +1,67 @@
+// @vitest-environment jsdom
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import {
+ useMinistryHijackCycle,
+ TAKEOVER_MS,
+ HOLD_MS,
+ RESTORE_MS,
+ CYCLE_MS,
+} from '@/features/ministry/useMinistryHijackCycle.mjs';
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe('cycle constants', () => {
+ test('exported timing constants are pinned and CYCLE_MS sums them', () => {
+ expect(TAKEOVER_MS).toBe(800);
+ expect(HOLD_MS).toBe(1000);
+ expect(RESTORE_MS).toBe(800);
+ expect(CYCLE_MS).toBe(2600);
+ expect(CYCLE_MS).toBe(TAKEOVER_MS + HOLD_MS + RESTORE_MS);
+ });
+});
+
+describe('useMinistryHijackCycle — one-shot lifecycle', () => {
+ test('starts idle; trigger() transitions through takeover → hold → restore → idle', () => {
+ const { result } = renderHook(() => useMinistryHijackCycle());
+ expect(result.current.phase).toBe('idle');
+
+ act(() => result.current.trigger());
+ expect(result.current.phase).toBe('takeover');
+
+ act(() => vi.advanceTimersByTime(TAKEOVER_MS));
+ expect(result.current.phase).toBe('hold');
+
+ act(() => vi.advanceTimersByTime(HOLD_MS));
+ expect(result.current.phase).toBe('restore');
+
+ act(() => vi.advanceTimersByTime(RESTORE_MS));
+ expect(result.current.phase).toBe('idle');
+ });
+
+ test('total cycle from trigger to idle is exactly CYCLE_MS', () => {
+ const { result } = renderHook(() => useMinistryHijackCycle());
+ act(() => result.current.trigger());
+
+ // Advance to one tick BEFORE CYCLE_MS — still not idle.
+ act(() => vi.advanceTimersByTime(CYCLE_MS - 1));
+ expect(result.current.phase).not.toBe('idle');
+
+ // Advance the final ms — now idle.
+ act(() => vi.advanceTimersByTime(1));
+ expect(result.current.phase).toBe('idle');
+ });
+
+ test('unmount during cycle clears pending timeouts (no warning, no state update)', () => {
+ const { result, unmount } = renderHook(() => useMinistryHijackCycle());
+ act(() => result.current.trigger());
+ unmount();
+ act(() => vi.advanceTimersByTime(CYCLE_MS));
+ // If timeouts weren't cleared, React would warn about update on unmounted.
+ // No assertion needed — vitest fails the test on warnings.
+ });
+});
diff --git a/src/__tests__/unit/features/ministry/warTone.test.mjs b/src/__tests__/unit/features/ministry/warTone.test.mjs
new file mode 100644
index 00000000..4a6e6d83
--- /dev/null
+++ b/src/__tests__/unit/features/ministry/warTone.test.mjs
@@ -0,0 +1,87 @@
+import { describe, test, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('@/db/queries/getCrossSeasonStats.mjs', () => ({
+ getCrossSeasonStats: vi.fn(),
+}));
+
+import { getWarTone } from '@/features/ministry/warTone.mjs';
+import { getCrossSeasonStats } from '@/db/queries/getCrossSeasonStats.mjs';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('getWarTone', () => {
+ test('returns null when getCrossSeasonStats throws (DB error)', async () => {
+ getCrossSeasonStats.mockRejectedValueOnce(new Error('DB down'));
+ await expect(getWarTone()).resolves.toBeNull();
+ });
+
+ test('returns null when no completed wars exist', async () => {
+ getCrossSeasonStats.mockResolvedValueOnce({
+ perSeason: [
+ { season: 1, outcome: 'unknown' },
+ { season: 2, outcome: 'unknown' },
+ ],
+ factionTotals: [],
+ });
+ await expect(getWarTone()).resolves.toBeNull();
+ });
+
+ test('returns null when perSeason is empty', async () => {
+ getCrossSeasonStats.mockResolvedValueOnce({
+ perSeason: [],
+ factionTotals: [],
+ });
+ await expect(getWarTone()).resolves.toBeNull();
+ });
+
+ test("returns 'winning' when wonCount / completedCount >= 0.5", async () => {
+ getCrossSeasonStats.mockResolvedValueOnce({
+ perSeason: [
+ { season: 1, outcome: 'victory' },
+ { season: 2, outcome: 'victory' },
+ { season: 3, outcome: 'defeat' },
+ { season: 4, outcome: 'unknown' }, // excluded
+ ],
+ factionTotals: [],
+ });
+ await expect(getWarTone()).resolves.toBe('winning');
+ });
+
+ test("returns 'losing' when wonCount / completedCount < 0.5", async () => {
+ getCrossSeasonStats.mockResolvedValueOnce({
+ perSeason: [
+ { season: 1, outcome: 'defeat' },
+ { season: 2, outcome: 'defeat' },
+ { season: 3, outcome: 'victory' },
+ ],
+ factionTotals: [],
+ });
+ await expect(getWarTone()).resolves.toBe('losing');
+ });
+
+ test('exactly 50% wins is winning (>= 0.5)', async () => {
+ getCrossSeasonStats.mockResolvedValueOnce({
+ perSeason: [
+ { season: 1, outcome: 'victory' },
+ { season: 2, outcome: 'defeat' },
+ ],
+ factionTotals: [],
+ });
+ await expect(getWarTone()).resolves.toBe('winning');
+ });
+
+ test("ignores 'unknown' outcomes when counting", async () => {
+ getCrossSeasonStats.mockResolvedValueOnce({
+ perSeason: [
+ { season: 1, outcome: 'unknown' },
+ { season: 2, outcome: 'unknown' },
+ { season: 3, outcome: 'victory' },
+ ],
+ factionTotals: [],
+ });
+ // 1/1 = 100% completed wins → winning
+ await expect(getWarTone()).resolves.toBe('winning');
+ });
+});
diff --git a/src/__tests__/unit/features/stats/FactionThreatRanking.test.jsx b/src/__tests__/unit/features/stats/FactionThreatRanking.test.jsx
new file mode 100644
index 00000000..8af31f8f
--- /dev/null
+++ b/src/__tests__/unit/features/stats/FactionThreatRanking.test.jsx
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { computeThreatData } from '@/features/stats/FactionThreatRanking';
+
+// The chart itself uses Recharts in a ResponsiveContainer, which doesn't
+// render usefully in jsdom (no real dimensions) — so the unit test covers
+// the pure data transform that drives it. The chart is exercised end-to-end
+// by the DevTools verify.
+
+describe('computeThreatData', () => {
+ it('computes the overall HD win rate per faction and sorts ascending', () => {
+ const totals = [
+ // Bugs: (60+30)/(100+50) = 90/150 = 60%
+ { enemy: 0, defends: 100, defend_wins: 60, attacks: 50, attack_wins: 30 },
+ // Cyborgs: (40+20)/(80+60) = 60/140 ≈ 43%
+ { enemy: 1, defends: 80, defend_wins: 40, attacks: 60, attack_wins: 20 },
+ // Illuminate: (30+40)/(50+50) = 70/100 = 70%
+ { enemy: 2, defends: 50, defend_wins: 30, attacks: 50, attack_wins: 40 },
+ ];
+ const data = computeThreatData(totals);
+ expect(data).toHaveLength(3);
+ // Ascending by winRate → most threatening (lowest HD win rate) at top.
+ expect(data[0]).toMatchObject({ name: 'Cyborgs', winRate: 43 });
+ expect(data[1]).toMatchObject({ name: 'Bugs', winRate: 60 });
+ expect(data[2]).toMatchObject({ name: 'Illuminate', winRate: 70 });
+ });
+
+ it('treats a missing faction as zero rather than dropping it', () => {
+ const data = computeThreatData([
+ { enemy: 0, defends: 10, defend_wins: 5, attacks: 5, attack_wins: 3 },
+ ]);
+ expect(data).toHaveLength(3);
+ const cyborgs = data.find((d) => d.name === 'Cyborgs');
+ expect(cyborgs.winRate).toBe(0);
+ });
+
+ it('returns empty when factionTotals is empty', () => {
+ // Every faction registers but with zeros — empty array still yields
+ // three rows so the chart has consistent axis labels.
+ const data = computeThreatData([]);
+ expect(data).toHaveLength(3);
+ expect(data.every((d) => d.winRate === 0)).toBe(true);
+ });
+});
diff --git a/src/__tests__/unit/features/stats/SeasonRecords.test.jsx b/src/__tests__/unit/features/stats/SeasonRecords.test.jsx
new file mode 100644
index 00000000..7e060f5d
--- /dev/null
+++ b/src/__tests__/unit/features/stats/SeasonRecords.test.jsx
@@ -0,0 +1,79 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import SeasonRecords from '@/features/stats/SeasonRecords';
+
+// Each record-winning season is distinct so each `Season N` subtitle
+// appears exactly once — easier to assert via getByText.
+const mockPerSeason = [
+ {
+ season: 1,
+ season_duration: 86400 * 10,
+ events: 20,
+ defend_wins: 5,
+ attack_wins: 3,
+ avg_event_duration: 3600,
+ },
+ {
+ season: 2,
+ season_duration: 86400 * 5,
+ events: 30,
+ defend_wins: 3,
+ attack_wins: 2,
+ avg_event_duration: 4000,
+ },
+ {
+ season: 3,
+ season_duration: 86400 * 7,
+ events: 15,
+ defend_wins: 4,
+ attack_wins: 8,
+ avg_event_duration: 5000,
+ },
+ {
+ season: 4,
+ season_duration: 86400 * 6,
+ events: 25,
+ defend_wins: 10,
+ attack_wins: 1,
+ avg_event_duration: 4500,
+ },
+ {
+ season: 5,
+ season_duration: 86400 * 4,
+ events: 18,
+ defend_wins: 2,
+ attack_wins: 4,
+ avg_event_duration: 9000,
+ },
+];
+
+describe('SeasonRecords', () => {
+ it('renders the all-time records grid', () => {
+ render( );
+ expect(screen.getByText('LONGEST_WAR')).toBeDefined();
+ expect(screen.getByText('MOST_EVENTS')).toBeDefined();
+ expect(screen.getByText('LONGEST_AVG_BATTLE')).toBeDefined();
+ expect(screen.getByText('MOST_DEFENDS_WON')).toBeDefined();
+ expect(screen.getByText('MOST_ATTACKS_WON')).toBeDefined();
+ });
+
+ it('attributes each record to the season that owns the extremum', () => {
+ render( );
+ // Longest war (864000s = 10 days) → S1
+ expect(screen.getByText('Season 1')).toBeDefined();
+ // Most events (30) → S2
+ expect(screen.getByText('Season 2')).toBeDefined();
+ // Most attacks won (8) → S3
+ expect(screen.getByText('Season 3')).toBeDefined();
+ // Most defends won (10) → S4
+ expect(screen.getByText('Season 4')).toBeDefined();
+ // Longest avg battle (9000s) → S5
+ expect(screen.getByText('Season 5')).toBeDefined();
+ });
+
+ it('returns null for empty input', () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/src/__tests__/unit/features/stats/StatGrid.test.jsx b/src/__tests__/unit/features/stats/StatGrid.test.jsx
index 241fb703..d557f7f7 100644
--- a/src/__tests__/unit/features/stats/StatGrid.test.jsx
+++ b/src/__tests__/unit/features/stats/StatGrid.test.jsx
@@ -93,6 +93,71 @@ describe('StatGrid', () => {
// losses: 2 (enemy0 fail + enemy2 fail)
expect(screen.getByText('2')).toBeInTheDocument();
});
+
+ test('HELLDIVERS_LOST teamkill subtitle is labelled MARTYRS, not a percentage', () => {
+ render( );
+ const subtitle = screen
+ .getByText('HELLDIVERS_LOST')
+ .closest('.stat-card')
+ ?.querySelector('.stat-card-subtitle')?.textContent;
+ // accidentals: 10+20+5 = 35, followed by the MARTYRS label
+ expect(subtitle).toContain('35');
+ expect(subtitle).toContain('Martyrs');
+ expect(subtitle).not.toContain('%');
+ });
+
+ // total kills 500+1000+750 = 2250 → last 24h = 2250 − ago24h
+ const killsSubtitle = () =>
+ screen
+ .getByText('ENEMIES_KILLED')
+ .closest('.stat-card')
+ ?.querySelector('.stat-card-subtitle');
+
+ test('ENEMIES_KILLED arrow is green ▲ when killing pace rose vs the prior 24h', () => {
+ render(
+ ,
+ );
+ // last 24h: 2250 − 2000 = 250; prior 24h: 2000 − 1900 = 100; pace up
+ expect(killsSubtitle()?.textContent).toContain('250');
+ expect(killsSubtitle()?.querySelector('.text-success')?.textContent).toBe(
+ '▲',
+ );
+ });
+
+ test('ENEMIES_KILLED arrow is red ▼ when killing pace fell vs the prior 24h', () => {
+ render(
+ ,
+ );
+ // last 24h: 2250 − 2000 = 250; prior 24h: 2000 − 1650 = 350; pace down
+ expect(killsSubtitle()?.textContent).toContain('250');
+ expect(killsSubtitle()?.querySelector('.text-danger')?.textContent).toBe('▼');
+ });
+
+ test('ENEMIES_KILLED arrow is a neutral ▪ when there is no 48h baseline', () => {
+ render(
+ ,
+ );
+ // last 24h volume still shows, but pace can't be compared yet
+ expect(killsSubtitle()?.textContent).toContain('250');
+ expect(killsSubtitle()?.textContent).toContain('▪');
+ expect(killsSubtitle()?.querySelector('.text-success')).toBeNull();
+ expect(killsSubtitle()?.querySelector('.text-danger')).toBeNull();
+ });
});
describe('faction view', () => {
@@ -168,4 +233,155 @@ describe('StatGrid', () => {
expect(container.innerHTML).toBe('');
});
});
+
+ describe('war duration card', () => {
+ const cardValue = (label) =>
+ screen
+ .getByText(label)
+ .closest('.stat-card')
+ ?.querySelector('.stat-card-value')?.textContent;
+
+ const cardSubtitle = (label) =>
+ screen
+ .getByText(label)
+ .closest('.stat-card')
+ ?.querySelector('.stat-card-subtitle')?.textContent;
+
+ test('global view shows total war duration', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('WAR_DURATION')).toBeInTheDocument();
+ expect(cardValue('WAR_DURATION')).toBe('10 days');
+ });
+
+ test('faction view shows time since that faction was deployed', () => {
+ // bugs = enemy 0; it appeared 2 days after the war started
+ const live = mockLive.map((s) => ({ ...s, first_seen: 1000 }));
+ live[0] = { ...live[0], first_seen: 1000 + 86400 * 2 };
+ render(
+ ,
+ );
+ // war ran 10 days; bugs deployed 2 days in → 8 days in the war
+ expect(cardValue('WAR_DURATION')).toBe('8 days');
+ });
+
+ test('faction not yet deployed (first_seen null) shows a dash', () => {
+ const live = mockLive.map((s) => ({ ...s, first_seen: 1000 }));
+ live[0] = { ...live[0], first_seen: null };
+ render(
+ ,
+ );
+ expect(cardValue('WAR_DURATION')).toBe('—');
+ });
+
+ test('global view subtitle shows the war start date', () => {
+ render(
+ ,
+ );
+ expect(cardSubtitle('WAR_DURATION')).toBe('25 JANUARY');
+ });
+
+ test('faction view subtitle shows that faction introduction date', () => {
+ // war started 20 Feb; bugs (enemy 0) were introduced 01 Mar
+ const warStart = Date.UTC(2025, 1, 20) / 1000;
+ const live = mockLive.map((s) => ({ ...s, first_seen: warStart }));
+ live[0] = { ...live[0], first_seen: Date.UTC(2025, 2, 1) / 1000 };
+ render(
+ ,
+ );
+ expect(cardSubtitle('WAR_DURATION')).toBe('01 MARCH');
+ });
+ });
+
+ describe('archived / redaction', () => {
+ const noTelemetryLive = [0, 1, 2].map((enemy) => ({
+ enemy,
+ players: 0,
+ kills: 0,
+ deaths: 0,
+ accidentals: 0,
+ successful_missions: 0,
+ missions: 0,
+ }));
+
+ test('archived global view without telemetry redacts the four telemetry cards', () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByText('████████')).toHaveLength(4);
+ expect(screen.getAllByText(/Data redacted/i)).toHaveLength(4);
+ // EVENTS and WAR_DURATION are not telemetry-derived — still render.
+ expect(screen.getByText('EVENTS')).toBeInTheDocument();
+ expect(screen.getByText('WAR_DURATION')).toBeInTheDocument();
+ });
+
+ test('archived faction view without telemetry redacts the telemetry cards', () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByText('████████')).toHaveLength(4);
+ });
+
+ test('archived season with real telemetry is not redacted', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText('████████')).toBeNull();
+ expect(screen.getByText('450')).toBeInTheDocument();
+ });
+
+ test('without the archived prop a zero-telemetry season is never redacted', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText('████████')).toBeNull();
+ expect(screen.queryByText(/Data redacted/i)).toBeNull();
+ });
+ });
});
diff --git a/src/__tests__/unit/features/stats/WarOutcomes.test.jsx b/src/__tests__/unit/features/stats/WarOutcomes.test.jsx
new file mode 100644
index 00000000..671a7eb8
--- /dev/null
+++ b/src/__tests__/unit/features/stats/WarOutcomes.test.jsx
@@ -0,0 +1,58 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import WarOutcomes from '@/features/stats/WarOutcomes';
+
+// V V V V V D V D D D — total 10, victories 6, defeats 4
+// Longest win streak: 5 (S1-S5). Longest loss streak: 3 (S8-S10).
+// All distinct values so we can assert via getByText.
+const mockPerSeason = [
+ { season: 1, outcome: 'victory' },
+ { season: 2, outcome: 'victory' },
+ { season: 3, outcome: 'victory' },
+ { season: 4, outcome: 'victory' },
+ { season: 5, outcome: 'victory' },
+ { season: 6, outcome: 'defeat' },
+ { season: 7, outcome: 'victory' },
+ { season: 8, outcome: 'defeat' },
+ { season: 9, outcome: 'defeat' },
+ { season: 10, outcome: 'defeat' },
+];
+
+describe('WarOutcomes', () => {
+ it('renders the summary cards', () => {
+ render( );
+ expect(screen.getByText('TOTAL_WARS')).toBeDefined();
+ expect(screen.getByText('VICTORIES')).toBeDefined();
+ expect(screen.getByText('DEFEATS')).toBeDefined();
+ expect(screen.getByText('LONGEST_WIN_STREAK')).toBeDefined();
+ expect(screen.getByText('LONGEST_LOSS_STREAK')).toBeDefined();
+ });
+
+ it('counts total wars, victories, defeats, and the win rate', () => {
+ render( );
+ expect(screen.getByText('10')).toBeDefined();
+ expect(screen.getByText('6')).toBeDefined();
+ expect(screen.getByText('4')).toBeDefined();
+ expect(screen.getByText('60%')).toBeDefined();
+ });
+
+ it('finds the longest win and loss streaks with their season ranges', () => {
+ render( );
+ expect(screen.getByText('5')).toBeDefined();
+ expect(screen.getByText('Seasons 1–5')).toBeDefined();
+ expect(screen.getByText('3')).toBeDefined();
+ expect(screen.getByText('Seasons 8–10')).toBeDefined();
+ });
+
+ it('renders a timeline pill per season', () => {
+ const { container } = render( );
+ const pills = container.querySelectorAll('[role="listitem"]');
+ expect(pills.length).toBe(10);
+ });
+
+ it('returns null for empty input', () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/src/__tests__/unit/features/stats/generateCascadeLede.test.mjs b/src/__tests__/unit/features/stats/generateCascadeLede.test.mjs
new file mode 100644
index 00000000..528d1c83
--- /dev/null
+++ b/src/__tests__/unit/features/stats/generateCascadeLede.test.mjs
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { generateCascadeLede } from '@/features/stats/generateCascadeLede.mjs';
+
+const cascade = (overrides) => ({
+ season: 155,
+ length: 9,
+ faction: 'The Illuminate',
+ regions: [8, 7, 6, 5, 4, 3, 2, 1, 0],
+ durationSec: 14 * 3600 + 32 * 60,
+ ...overrides,
+});
+
+describe('generateCascadeLede', () => {
+ it('returns null for no cascades', () => {
+ expect(generateCascadeLede([], 200)).toBeNull();
+ expect(generateCascadeLede(null, 200)).toBeNull();
+ });
+
+ it('uses "pushed all the way home" when the last region is 0', () => {
+ const lede = generateCascadeLede([cascade()], 200);
+ expect(lede).toContain('pushed all the way home');
+ expect(lede).toContain('1 cascade');
+ expect(lede).toContain('200 wars');
+ expect(lede).toContain('season 155');
+ expect(lede).toContain('The Illuminate');
+ });
+
+ it('uses "pushed all the way home" when the last region is 11', () => {
+ const lede = generateCascadeLede([cascade({ regions: [13, 12, 11] })], 10);
+ expect(lede).toContain('pushed all the way home');
+ });
+
+ it('falls back to "swept N regions in DURATION" otherwise', () => {
+ const lede = generateCascadeLede(
+ [cascade({ length: 5, regions: [6, 5, 4, 3, 2], durationSec: 9 * 3600 })],
+ 50,
+ );
+ expect(lede).toContain('swept 5 regions in 9h');
+ });
+
+ it('pluralizes "cascades" and "wars" correctly', () => {
+ const ledeMany = generateCascadeLede([cascade(), cascade({ season: 142 })], 2);
+ expect(ledeMany).toContain('2 cascades');
+ expect(ledeMany).toContain('2 wars');
+
+ const ledeOne = generateCascadeLede([cascade()], 1);
+ expect(ledeOne).toContain('1 cascade ');
+ expect(ledeOne).toContain('1 war.');
+ });
+});
diff --git a/src/__tests__/unit/features/timeline/CascadeLog.test.jsx b/src/__tests__/unit/features/timeline/CascadeLog.test.jsx
new file mode 100644
index 00000000..7c810e5c
--- /dev/null
+++ b/src/__tests__/unit/features/timeline/CascadeLog.test.jsx
@@ -0,0 +1,73 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+
+vi.mock('@/shared/utils/cookies.mjs', () => ({
+ setPreferenceCookie: vi.fn(),
+}));
+
+import CascadeLog from '@/features/timeline/CascadeLog';
+
+const c = (overrides) => ({
+ season: 155,
+ length: 9,
+ factionIndex: 2,
+ faction: 'The Illuminate',
+ regions: [8, 7, 6, 5, 4, 3, 2, 1, 0],
+ startTime: 0,
+ endTime: 1000,
+ durationSec: 1000,
+ firstEvent: {},
+ lastEvent: {},
+ events: [],
+ ...overrides,
+});
+
+describe('CascadeLog', () => {
+ it('renders nothing for empty cascades', () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders the heading', () => {
+ render( );
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
+ /Cascade Failures/i,
+ );
+ });
+
+ it('renders the optional lede when provided', () => {
+ render( );
+ expect(screen.getByText('Test lede sentence.')).toBeInTheDocument();
+ });
+
+ it('omits the lede paragraph when not provided', () => {
+ const { container } = render( );
+ expect(container.querySelector('.event-log-lede')).toBeNull();
+ });
+
+ it('renders one group header per distinct season', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/Season 155/)).toBeInTheDocument();
+ expect(screen.getByText(/Season 142/)).toBeInTheDocument();
+ });
+
+ it('toggles sort order when the toggle is clicked', () => {
+ const { container } = render(
+ ,
+ );
+ // worst-first: 100 (length 9) first, then 200 (length 4)
+ let labels = container.querySelectorAll('.event-log-day-label');
+ expect(labels[0].textContent).toContain('100');
+ // Toggle
+ fireEvent.click(screen.getByRole('button', { name: /sort/i }));
+ labels = container.querySelectorAll('.event-log-day-label');
+ // recent-first: 200 first, then 100
+ expect(labels[0].textContent).toContain('200');
+ });
+});
diff --git a/src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx b/src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx
new file mode 100644
index 00000000..c0a4247a
--- /dev/null
+++ b/src/__tests__/unit/features/timeline/CascadeLogCard.test.jsx
@@ -0,0 +1,52 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import CascadeLogCard from '@/features/timeline/CascadeLogCard';
+
+const cascade = (overrides) => ({
+ season: 155,
+ length: 9,
+ factionIndex: 2,
+ faction: 'The Illuminate',
+ regions: [8, 7, 6, 5, 4, 3, 2, 1, 0],
+ startTime: 1709555520, // Mar 4, 2024 (UTC)
+ endTime: 1709555520 + 14 * 3600 + 32 * 60,
+ durationSec: 14 * 3600 + 32 * 60,
+ firstEvent: {},
+ lastEvent: {},
+ events: [],
+ ...overrides,
+});
+
+describe('CascadeLogCard', () => {
+ it('renders the title with length', () => {
+ render( );
+ expect(screen.getByText(/9 regions/i)).toBeInTheDocument();
+ });
+
+ it('renders the chain joined by arrows', () => {
+ const { container } = render( );
+ const chain = container.querySelector('.event-log-card-chain');
+ expect(chain).toBeInTheDocument();
+ expect(chain.textContent).toContain('8 → 7 → 6');
+ });
+
+ it('tags the chain with the faction index', () => {
+ const { container } = render( );
+ const chain = container.querySelector('.event-log-card-chain');
+ expect(chain.getAttribute('data-faction')).toBe('2');
+ });
+
+ it('wraps the card in an anchor linking to /archives?season=N#cascade', () => {
+ render( );
+ const link = screen.getByRole('link');
+ expect(link.getAttribute('href')).toBe('/archives?season=155#cascade');
+ expect(link.getAttribute('data-umami-event')).toBe('cascade-card-click');
+ });
+
+ it('renders a duration pill with formatted duration', () => {
+ render( );
+ // 14h 32m → '14h32m' per formatCompactDuration (largest:2, no spacer)
+ expect(screen.getByText(/14h32m/)).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/unit/features/timeline/EventLog.test.jsx b/src/__tests__/unit/features/timeline/EventLog.test.jsx
index a741b9d7..d59779fe 100644
--- a/src/__tests__/unit/features/timeline/EventLog.test.jsx
+++ b/src/__tests__/unit/features/timeline/EventLog.test.jsx
@@ -54,6 +54,18 @@ describe('EventLog', () => {
expect(daysContainer.classList.contains('event-log-days--stack')).toBe(false);
});
+ test('does not render an empty TODAY section when today has no events', () => {
+ const pastEvent = {
+ ...fakeEvents[0],
+ start_time: NOW - 5 * 86400,
+ end_time: NOW - 5 * 86400 + 500,
+ };
+ const { container } = render( );
+ expect(container.querySelector('.event-log-day--no-events')).toBeNull();
+ // only the real event's day group renders — no synthetic TODAY marker
+ expect(container.querySelectorAll('.event-log-day')).toHaveLength(1);
+ });
+
test('cards have data-event-key attributes for scroll-sync wiring', () => {
const { container } = render(
,
diff --git a/src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs b/src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs
new file mode 100644
index 00000000..dc283707
--- /dev/null
+++ b/src/__tests__/unit/features/timeline/groupCascadesBySeason.test.mjs
@@ -0,0 +1,68 @@
+import { describe, it, expect } from 'vitest';
+import { groupCascadesBySeason } from '@/features/timeline/groupCascadesBySeason.mjs';
+
+const c = (overrides) => ({
+ season: 0,
+ length: 3,
+ factionIndex: 0,
+ faction: 'Bugs',
+ regions: [3, 2, 1],
+ startTime: 0,
+ endTime: 1000,
+ durationSec: 1000,
+ events: [],
+ firstEvent: {},
+ lastEvent: {},
+ ...overrides,
+});
+
+describe('groupCascadesBySeason', () => {
+ it('returns [] for empty input', () => {
+ expect(groupCascadesBySeason([])).toEqual([]);
+ expect(groupCascadesBySeason(null)).toEqual([]);
+ });
+
+ it('worst-first: orders groups by their worst cascade rank', () => {
+ const input = [
+ c({ season: 142, length: 4, endTime: 100 }),
+ c({ season: 155, length: 9, endTime: 200 }),
+ c({ season: 198, length: 6, endTime: 300 }),
+ ];
+ const groups = groupCascadesBySeason(input, { sortOrder: 'worst' });
+ expect(groups.map((g) => g.season)).toEqual([155, 198, 142]);
+ });
+
+ it('recent-first: orders groups by season DESC', () => {
+ const input = [
+ c({ season: 142, length: 4 }),
+ c({ season: 155, length: 9 }),
+ c({ season: 198, length: 6 }),
+ ];
+ const groups = groupCascadesBySeason(input, { sortOrder: 'recent' });
+ expect(groups.map((g) => g.season)).toEqual([198, 155, 142]);
+ });
+
+ it('keeps multi-cascade seasons grouped together', () => {
+ const input = [
+ c({ season: 198, length: 6, endTime: 100 }),
+ c({ season: 155, length: 9, endTime: 200 }),
+ c({ season: 198, length: 3, endTime: 300 }),
+ ];
+ const groups = groupCascadesBySeason(input, { sortOrder: 'worst' });
+ const s198 = groups.find((g) => g.season === 198);
+ expect(s198.cascades).toHaveLength(2);
+ // Within group, longer first
+ expect(s198.cascades[0].length).toBe(6);
+ expect(s198.cascades[1].length).toBe(3);
+ });
+
+ it('within-group recent ordering uses endTime DESC', () => {
+ const input = [
+ c({ season: 198, length: 3, endTime: 100 }),
+ c({ season: 198, length: 3, endTime: 300 }),
+ c({ season: 198, length: 3, endTime: 200 }),
+ ];
+ const groups = groupCascadesBySeason(input, { sortOrder: 'recent' });
+ expect(groups[0].cascades.map((c) => c.endTime)).toEqual([300, 200, 100]);
+ });
+});
diff --git a/src/__tests__/unit/features/timeline/groupEventsByDay.test.mjs b/src/__tests__/unit/features/timeline/groupEventsByDay.test.mjs
index cf4c7ce8..61be80c9 100644
--- a/src/__tests__/unit/features/timeline/groupEventsByDay.test.mjs
+++ b/src/__tests__/unit/features/timeline/groupEventsByDay.test.mjs
@@ -1,4 +1,3 @@
-// src/__tests__/unit/utils/groupEventsByDay.test.mjs
import { describe, it, expect } from 'vitest';
import {
groupEventsByDay,
@@ -21,22 +20,23 @@ describe('groupEventsByDay', () => {
expect(groupEventsByDay([])).toEqual([]);
});
- it('always includes today as first group', () => {
- const today = new Date().toISOString().slice(0, 10);
- const events = [event('a', 1774958400)]; // March 31 2026
+ it('does not inject an empty TODAY group when today has no events', () => {
+ const events = [event('a', 1774958400)]; // March 31 2026 — not today
const groups = groupEventsByDay(events);
- expect(groups[0].date).toBe(today);
- expect(groups[0].label).toBe('TODAY');
+ // only the real event day — no synthetic empty TODAY placeholder
+ expect(groups).toHaveLength(1);
+ expect(groups.some((g) => g.events.length === 0)).toBe(false);
});
- it('does not duplicate today when events exist for today', () => {
+ it('an event starting today is grouped under a TODAY label', () => {
const nowSec = Math.floor(Date.now() / 1000);
const events = [event('a', nowSec)];
const groups = groupEventsByDay(events);
- const todayGroups = groups.filter(
- (g) => g.date === new Date().toISOString().slice(0, 10),
- );
- expect(todayGroups).toHaveLength(1);
+ const today = new Date().toISOString().slice(0, 10);
+ expect(groups).toHaveLength(1);
+ expect(groups[0].date).toBe(today);
+ expect(groups[0].label).toBe('TODAY');
+ expect(groups[0].events).toHaveLength(1);
});
it('groups events by calendar day (UTC)', () => {
@@ -46,27 +46,25 @@ describe('groupEventsByDay', () => {
event('c', 1774872000),
];
const groups = groupEventsByDay(events);
- // today + 2 event days
- expect(groups).toHaveLength(3);
- // skip groups[0] (today); groups[1] = Mar 31, groups[2] = Mar 30
- expect(groups[1].events).toHaveLength(2);
- expect(groups[2].events).toHaveLength(1);
+ // 2 event days, no synthetic today group
+ expect(groups).toHaveLength(2);
+ // groups[0] = Mar 31 (2 events), groups[1] = Mar 30 (1 event)
+ expect(groups[0].events).toHaveLength(2);
+ expect(groups[1].events).toHaveLength(1);
});
it('sorts groups newest day first', () => {
const events = [event('old', 1774872000), event('new', 1774958400)];
const groups = groupEventsByDay(events);
- // groups[0] = today, groups[1] = Mar 31, groups[2] = Mar 30
- expect(groups[1].date).toBe('2026-03-31');
- expect(groups[2].date).toBe('2026-03-30');
+ expect(groups[0].date).toBe('2026-03-31');
+ expect(groups[1].date).toBe('2026-03-30');
});
it('sorts events within a group newest first', () => {
const events = [event('early', 1774958400), event('late', 1774958400 + 7200)];
const groups = groupEventsByDay(events);
- // groups[0] = today (empty), groups[1] = the event day
- expect(groups[1].events[0].event_id).toBe('late');
- expect(groups[1].events[1].event_id).toBe('early');
+ expect(groups[0].events[0].event_id).toBe('late');
+ expect(groups[0].events[1].event_id).toBe('early');
});
});
diff --git a/src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs b/src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs
new file mode 100644
index 00000000..c504bcc6
--- /dev/null
+++ b/src/__tests__/unit/features/timeline/useCascadeLogSort.test.mjs
@@ -0,0 +1,40 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+
+vi.mock('@/shared/utils/cookies.mjs', () => ({
+ setPreferenceCookie: vi.fn(),
+}));
+
+import { useCascadeLogSort } from '@/features/timeline/useCascadeLogSort.mjs';
+import { setPreferenceCookie } from '@/shared/utils/cookies.mjs';
+
+describe('useCascadeLogSort', () => {
+ beforeEach(() => {
+ setPreferenceCookie.mockReset();
+ });
+
+ it('uses the initial value', () => {
+ const { result } = renderHook(() => useCascadeLogSort('recent'));
+ expect(result.current[0]).toBe('recent');
+ });
+
+ it('defaults to "worst" when initial is undefined', () => {
+ const { result } = renderHook(() => useCascadeLogSort());
+ expect(result.current[0]).toBe('worst');
+ });
+
+ it('toggles worst → recent → worst', () => {
+ const { result } = renderHook(() => useCascadeLogSort('worst'));
+ act(() => result.current[1]());
+ expect(result.current[0]).toBe('recent');
+ act(() => result.current[1]());
+ expect(result.current[0]).toBe('worst');
+ });
+
+ it('writes the new value to the preference cookie on toggle', () => {
+ const { result } = renderHook(() => useCascadeLogSort('worst'));
+ act(() => result.current[1]());
+ expect(setPreferenceCookie).toHaveBeenCalledWith('cascade-log-sort', 'recent');
+ });
+});
diff --git a/src/__tests__/unit/queries/account.test.mjs b/src/__tests__/unit/queries/account.test.mjs
index 522f6ed1..7c4d6257 100644
--- a/src/__tests__/unit/queries/account.test.mjs
+++ b/src/__tests__/unit/queries/account.test.mjs
@@ -1,7 +1,7 @@
import { describe, test, expect, vi } from 'vitest';
import db from '@/db/db';
import { auth } from '@/auth';
-import { exportUserData, deleteUserAccount } from '@/db/queries/account.mjs';
+import { exportUserData, deleteUserAccount } from '@/features/account/actions.mjs';
const userId = '01908174-d3a5-7e50-b964-6f5e9e48c0a1';
const otherUserId = '01908174-d3a5-7e50-b964-6f5e9e48c0a2';
diff --git a/src/__tests__/unit/queries/admin.test.mjs b/src/__tests__/unit/queries/admin.test.mjs
index 01e9a094..b989d99b 100644
--- a/src/__tests__/unit/queries/admin.test.mjs
+++ b/src/__tests__/unit/queries/admin.test.mjs
@@ -9,7 +9,7 @@ import {
adminGetUserApiKeys,
adminRevokeApiKey,
getSystemStats,
-} from '@/db/queries/admin.mjs';
+} from '@/features/admin/actions.mjs';
const adminId = '01908174-d3a5-7e50-b964-6f5e9e48c0a1';
const targetUserId = '01908174-d3a5-7e50-b964-6f5e9e48c0a2';
@@ -22,6 +22,12 @@ function createFormData(entries) {
return fd;
}
+function mockTransaction(userMock) {
+ const tx = { user: userMock };
+ vi.mocked(db.$transaction).mockImplementation((cb) => cb(tx));
+ return tx;
+}
+
// ─── getAllUsers ─────────────────────────────────────────────────────
describe('getAllUsers', () => {
@@ -64,14 +70,17 @@ describe('updateUserRole', () => {
test('updates role on success', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.update).mockResolvedValue({ id: targetUserId, role: 'admin' });
+ const tx = mockTransaction({
+ count: vi.fn().mockResolvedValue(0),
+ update: vi.fn().mockResolvedValue({ id: targetUserId, role: 'admin' }),
+ });
const result = await updateUserRole(
null,
createFormData({ userId: targetUserId, newRole: 'admin' }),
);
expect(result.data).toBeDefined();
- expect(db.user.update).toHaveBeenCalledWith({
+ expect(tx.user.update).toHaveBeenCalledWith({
where: { id: targetUserId },
data: { role: 'admin' },
});
@@ -80,20 +89,25 @@ describe('updateUserRole', () => {
test('prevents demoting the last admin', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.count).mockResolvedValue(1);
+ const tx = mockTransaction({
+ count: vi.fn().mockResolvedValue(1),
+ update: vi.fn(),
+ });
const result = await updateUserRole(
null,
createFormData({ userId: targetUserId, newRole: 'user' }),
);
expect(result.errors.auth).toMatch(/last admin/i);
- expect(db.user.update).not.toHaveBeenCalled();
+ expect(tx.user.update).not.toHaveBeenCalled();
});
test('allows demoting when multiple admins exist', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.count).mockResolvedValue(2);
- vi.mocked(db.user.update).mockResolvedValue({ id: targetUserId, role: 'user' });
+ mockTransaction({
+ count: vi.fn().mockResolvedValue(2),
+ update: vi.fn().mockResolvedValue({ id: targetUserId, role: 'user' }),
+ });
const result = await updateUserRole(
null,
@@ -115,14 +129,17 @@ describe('updateUserRole', () => {
test('allows promoting to admin without guard check', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.update).mockResolvedValue({ id: targetUserId, role: 'admin' });
+ const tx = mockTransaction({
+ count: vi.fn(),
+ update: vi.fn().mockResolvedValue({ id: targetUserId, role: 'admin' }),
+ });
const result = await updateUserRole(
null,
createFormData({ userId: targetUserId, newRole: 'admin' }),
);
expect(result.data).toBeDefined();
- expect(db.user.count).not.toHaveBeenCalled();
+ expect(tx.user.count).not.toHaveBeenCalled();
});
});
@@ -149,15 +166,18 @@ describe('toggleUserBan', () => {
test('toggles ban on success', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.findUnique).mockResolvedValue({ role: 'user' });
- vi.mocked(db.user.update).mockResolvedValue({ id: targetUserId, banned: true });
+ const tx = mockTransaction({
+ findUnique: vi.fn().mockResolvedValue({ role: 'user' }),
+ count: vi.fn(),
+ update: vi.fn().mockResolvedValue({ id: targetUserId, banned: true }),
+ });
const result = await toggleUserBan(
null,
createFormData({ userId: targetUserId, banned: 'true' }),
);
expect(result.data).toBeDefined();
- expect(db.user.update).toHaveBeenCalledWith({
+ expect(tx.user.update).toHaveBeenCalledWith({
where: { id: targetUserId },
data: { banned: true },
});
@@ -165,22 +185,27 @@ describe('toggleUserBan', () => {
test('prevents banning the last admin', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.findUnique).mockResolvedValue({ role: 'admin' });
- vi.mocked(db.user.count).mockResolvedValue(1);
+ const tx = mockTransaction({
+ findUnique: vi.fn().mockResolvedValue({ role: 'admin' }),
+ count: vi.fn().mockResolvedValue(1),
+ update: vi.fn(),
+ });
const result = await toggleUserBan(
null,
createFormData({ userId: targetUserId, banned: 'true' }),
);
expect(result.errors.auth).toMatch(/last admin/i);
- expect(db.user.update).not.toHaveBeenCalled();
+ expect(tx.user.update).not.toHaveBeenCalled();
});
test('allows banning admin when multiple admins exist', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.findUnique).mockResolvedValue({ role: 'admin' });
- vi.mocked(db.user.count).mockResolvedValue(2);
- vi.mocked(db.user.update).mockResolvedValue({ id: targetUserId, banned: true });
+ mockTransaction({
+ findUnique: vi.fn().mockResolvedValue({ role: 'admin' }),
+ count: vi.fn().mockResolvedValue(2),
+ update: vi.fn().mockResolvedValue({ id: targetUserId, banned: true }),
+ });
const result = await toggleUserBan(
null,
@@ -191,27 +216,34 @@ describe('toggleUserBan', () => {
test('allows banning non-admin user without guard check', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.findUnique).mockResolvedValue({ role: 'user' });
- vi.mocked(db.user.update).mockResolvedValue({ id: targetUserId, banned: true });
+ const tx = mockTransaction({
+ findUnique: vi.fn().mockResolvedValue({ role: 'user' }),
+ count: vi.fn(),
+ update: vi.fn().mockResolvedValue({ id: targetUserId, banned: true }),
+ });
const result = await toggleUserBan(
null,
createFormData({ userId: targetUserId, banned: 'true' }),
);
expect(result.data).toBeDefined();
- expect(db.user.count).not.toHaveBeenCalled();
+ expect(tx.user.count).not.toHaveBeenCalled();
});
test('allows unbanning without guard check', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(adminSession);
- vi.mocked(db.user.update).mockResolvedValue({ id: targetUserId, banned: false });
+ const tx = mockTransaction({
+ findUnique: vi.fn(),
+ count: vi.fn(),
+ update: vi.fn().mockResolvedValue({ id: targetUserId, banned: false }),
+ });
const result = await toggleUserBan(
null,
createFormData({ userId: targetUserId, banned: 'false' }),
);
expect(result.data).toBeDefined();
- expect(db.user.findUnique).not.toHaveBeenCalled();
+ expect(tx.user.findUnique).not.toHaveBeenCalled();
});
});
@@ -254,7 +286,7 @@ describe('adminRevokeApiKey', () => {
test('returns auth error for non-admin', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(userSession);
const apikeyId = '01908174-d3a5-7e50-b964-6f5e9e48c0a3';
- const result = await adminRevokeApiKey(null, createFormData({ apikeyId }));
+ const result = await adminRevokeApiKey(createFormData({ apikeyId }));
expect(result.errors.auth).toBeDefined();
});
@@ -263,7 +295,7 @@ describe('adminRevokeApiKey', () => {
const apikeyId = '01908174-d3a5-7e50-b964-6f5e9e48c0a3';
vi.mocked(db.ApiKey.delete).mockResolvedValue({ id: apikeyId });
- const result = await adminRevokeApiKey(null, createFormData({ apikeyId }));
+ const result = await adminRevokeApiKey(createFormData({ apikeyId }));
expect(result.data).toBeDefined();
expect(db.ApiKey.delete).toHaveBeenCalledWith({ where: { id: apikeyId } });
});
diff --git a/src/__tests__/unit/queries/api.test.mjs b/src/__tests__/unit/queries/api.test.mjs
index 03ad851c..2c9ee775 100644
--- a/src/__tests__/unit/queries/api.test.mjs
+++ b/src/__tests__/unit/queries/api.test.mjs
@@ -2,7 +2,11 @@ import { describe, test, expect, vi } from 'vitest';
import db from '@/db/db';
import { auth } from '@/auth';
import { revalidatePath } from 'next/cache';
-import { getApiKeysByUserId, generateApiKey, deleteApiKey } from '@/db/queries/api.mjs';
+import {
+ getApiKeysByUserId,
+ generateApiKey,
+ deleteApiKey,
+} from '@/features/account/actions.mjs';
const userId = '01908174-d3a5-7e50-b964-6f5e9e48c0a1';
const otherUserId = '01908174-d3a5-7e50-b964-6f5e9e48c0a2';
@@ -20,7 +24,7 @@ describe('getApiKeysByUserId', () => {
test('returns auth error when no session', async () => {
const result = await getApiKeysByUserId(userId);
- expect(result.errors.auth).toBe('No session found');
+ expect(result.errors.auth).toBe('Not authenticated');
expect(result.data).toBeUndefined();
});
@@ -29,7 +33,7 @@ describe('getApiKeysByUserId', () => {
const result = await getApiKeysByUserId(otherUserId);
- expect(result.errors.auth).toBe('User does not match');
+ expect(result.errors.auth).toBe('Not authorized');
expect(result.data).toBeUndefined();
});
@@ -85,7 +89,7 @@ describe('generateApiKey', () => {
const result = await generateApiKey(null, validFormData);
- expect(result.errors.auth).toMatch(/signed in/i);
+ expect(result.errors.auth).toBe('Not authenticated');
});
test('returns validation errors for invalid formData', async () => {
@@ -107,12 +111,15 @@ describe('generateApiKey', () => {
const result = await generateApiKey(null, mismatchFormData);
- expect(result.errors.auth).toMatch(/permission/i);
+ expect(result.errors.auth).toBe('Not authorized');
});
test('returns max limit error when user has 5 keys', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(session);
- vi.mocked(db.ApiKey.count).mockResolvedValue(5);
+ const txCreate = vi.fn();
+ vi.mocked(db.$transaction).mockImplementation((cb) =>
+ cb({ ApiKey: { count: vi.fn().mockResolvedValue(5), create: txCreate } }),
+ );
const result = await generateApiKey(
null,
@@ -120,11 +127,11 @@ describe('generateApiKey', () => {
);
expect(result.errors.general).toMatch(/maximum/i);
+ expect(txCreate).not.toHaveBeenCalled();
});
test('creates api key and revalidates on success', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(session);
- vi.mocked(db.ApiKey.count).mockResolvedValue(2);
const mockCreated = {
id: 'new-key-id',
userId,
@@ -132,7 +139,10 @@ describe('generateApiKey', () => {
hash: 'abc',
visible: '1234',
};
- vi.mocked(db.ApiKey.create).mockResolvedValue(mockCreated);
+ const txCreate = vi.fn().mockResolvedValue(mockCreated);
+ vi.mocked(db.$transaction).mockImplementation((cb) =>
+ cb({ ApiKey: { count: vi.fn().mockResolvedValue(2), create: txCreate } }),
+ );
const result = await generateApiKey(
null,
@@ -142,14 +152,21 @@ describe('generateApiKey', () => {
expect(result.data).toBeDefined();
expect(result.data.key).toBeDefined();
expect(typeof result.data.key).toBe('string');
- expect(db.ApiKey.create).toHaveBeenCalledOnce();
+ expect(result.data.id).toBe('new-key-id');
+ expect(txCreate).toHaveBeenCalledOnce();
expect(revalidatePath).toHaveBeenCalledWith('/profile', 'layout');
});
test('propagates database errors from create', async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(session);
- vi.mocked(db.ApiKey.count).mockResolvedValue(0);
- vi.mocked(db.ApiKey.create).mockRejectedValue(new Error('Insert failed'));
+ vi.mocked(db.$transaction).mockImplementation((cb) =>
+ cb({
+ ApiKey: {
+ count: vi.fn().mockResolvedValue(0),
+ create: vi.fn().mockRejectedValue(new Error('Insert failed')),
+ },
+ }),
+ );
await expect(
generateApiKey(
@@ -171,7 +188,7 @@ describe('deleteApiKey', () => {
const result = await deleteApiKey(null, validFormData);
- expect(result.errors.auth).toMatch(/permission/i);
+ expect(result.errors.auth).toBe('Not authenticated');
});
test('returns validation errors for invalid formData', async () => {
@@ -190,7 +207,7 @@ describe('deleteApiKey', () => {
const result = await deleteApiKey(null, mismatchFormData);
- expect(result.errors.auth).toMatch(/permission/i);
+ expect(result.errors.auth).toBe('Not authorized');
});
test('deletes api key and revalidates on success', async () => {
diff --git a/src/__tests__/unit/queries/getCampaign.test.mjs b/src/__tests__/unit/queries/getCampaign.test.mjs
index 23bf1ab9..91014a4e 100644
--- a/src/__tests__/unit/queries/getCampaign.test.mjs
+++ b/src/__tests__/unit/queries/getCampaign.test.mjs
@@ -263,6 +263,48 @@ describe('getCampaign', () => {
});
});
+ test('computes war_start and per-faction first_seen (first non-hidden bucket)', async () => {
+ // updateStatus writes a row for all 3 factions every poll, so
+ // pre-introduction factions have 'hidden' rows from war start.
+ // first_seen must be the first NON-hidden bucket, not min(time).
+ seedDbMocks({
+ statusHistory: [
+ { bucket: 1, enemy: 0, status: 'active', time: 1000, points: 1, points_taken: 0 }, // prettier-ignore
+ { bucket: 1, enemy: 1, status: 'hidden', time: 1000, points: 0, points_taken: 0 }, // prettier-ignore
+ { bucket: 1, enemy: 2, status: 'hidden', time: 1000, points: 0, points_taken: 0 }, // prettier-ignore
+ { bucket: 2, enemy: 0, status: 'active', time: 2000, points: 2, points_taken: 0 }, // prettier-ignore
+ { bucket: 2, enemy: 1, status: 'active', time: 2000, points: 1, points_taken: 0 }, // prettier-ignore
+ { bucket: 2, enemy: 2, status: 'hidden', time: 2000, points: 0, points_taken: 0 }, // prettier-ignore
+ { bucket: 3, enemy: 0, status: 'active', time: 3000, points: 3, points_taken: 0 }, // prettier-ignore
+ { bucket: 3, enemy: 1, status: 'active', time: 3000, points: 2, points_taken: 0 }, // prettier-ignore
+ { bucket: 3, enemy: 2, status: 'active', time: 3000, points: 1, points_taken: 0 }, // prettier-ignore
+ ],
+ });
+
+ const result = await getCampaign();
+
+ // war_start = earliest bucket time across all factions (incl. hidden)
+ expect(result.war_start).toBe(1000);
+ // first_seen = earliest bucket where the faction is no longer 'hidden'
+ expect(result.status[0].first_seen).toBe(1000);
+ expect(result.status[1].first_seen).toBe(2000);
+ expect(result.status[2].first_seen).toBe(3000);
+ });
+
+ test('first_seen is null for a faction that is still hidden', async () => {
+ seedDbMocks({
+ statusHistory: [
+ { bucket: 1, enemy: 0, status: 'active', time: 1000, points: 1, points_taken: 0 }, // prettier-ignore
+ { bucket: 1, enemy: 1, status: 'active', time: 1000, points: 1, points_taken: 0 }, // prettier-ignore
+ { bucket: 1, enemy: 2, status: 'hidden', time: 1000, points: 0, points_taken: 0 }, // prettier-ignore
+ ],
+ });
+
+ const result = await getCampaign();
+
+ expect(result.status[2].first_seen).toBeNull();
+ });
+
test('zeroes stats fields when h1_statistic row missing for a faction', async () => {
// Only faction 0 has a stat row. Faction 1 and 2 should still appear
// in data.status with stats fields zeroed — not undefined, not dropped.
diff --git a/src/__tests__/unit/queries/getCrossSeasonStats.test.mjs b/src/__tests__/unit/queries/getCrossSeasonStats.test.mjs
new file mode 100644
index 00000000..dc0a5e3b
--- /dev/null
+++ b/src/__tests__/unit/queries/getCrossSeasonStats.test.mjs
@@ -0,0 +1,225 @@
+import { describe, test, expect, vi, beforeEach } from 'vitest';
+import db from '@/db/db';
+import { getCrossSeasonStats } from '@/db/queries/getCrossSeasonStats.mjs';
+
+// getCrossSeasonStats is wrapped in React's cache(); the global db mock from
+// vitest.setup.mjs handles the underlying queries transparently. We feed
+// each of the 5 $queryRaw calls with `mockResolvedValueOnce` in source-order:
+// 1) per-season event aggregates
+// 2) per-faction event totals (Threat Ranking)
+// 3) per-season telemetry sums (latest-bucket-per-enemy summed)
+// 4) per-season final faction states (DISTINCT ON season,enemy)
+// 5) seasons where any bucket showed all-3-defeated
+
+beforeEach(() => {
+ vi.mocked(db.$queryRaw).mockReset();
+ vi.mocked(db.h1_season.findMany).mockReset();
+ vi.mocked(db.h1_event.findMany).mockReset();
+});
+
+function seed({
+ eventAggs = [],
+ factionTotals = [],
+ seasons = [],
+ telemetry = [],
+ allEvents = [],
+ finalStates = [],
+ defeatedSeasonRows = [],
+} = {}) {
+ vi.mocked(db.$queryRaw)
+ .mockResolvedValueOnce(eventAggs)
+ .mockResolvedValueOnce(factionTotals)
+ .mockResolvedValueOnce(telemetry)
+ .mockResolvedValueOnce(finalStates)
+ .mockResolvedValueOnce(defeatedSeasonRows);
+ vi.mocked(db.h1_season.findMany).mockResolvedValue(seasons);
+ vi.mocked(db.h1_event.findMany).mockResolvedValue(allEvents);
+}
+
+describe('getCrossSeasonStats', () => {
+ test('returns empty arrays when there are no seasons', async () => {
+ seed({});
+ const r = await getCrossSeasonStats();
+ expect(r.perSeason).toEqual([]);
+ expect(r.factionTotals).toEqual([]);
+ });
+
+ test('builds a per-season row from h1_season + event aggregates + season_duration', async () => {
+ seed({
+ eventAggs: [
+ {
+ season: 1,
+ events: 4,
+ defends: 1,
+ defend_wins: 1,
+ attacks: 3,
+ attack_wins: 3,
+ avg_event_duration: 3600,
+ },
+ ],
+ seasons: [{ season: 1, season_duration: 432000 }],
+ });
+ const r = await getCrossSeasonStats();
+ expect(r.perSeason).toHaveLength(1);
+ expect(r.perSeason[0]).toMatchObject({
+ season: 1,
+ season_duration: 432000,
+ events: 4,
+ defends: 1,
+ defend_wins: 1,
+ attacks: 3,
+ attack_wins: 3,
+ avg_event_duration: 3600,
+ });
+ });
+
+ test('passes per-faction totals through for the Threat Ranking', async () => {
+ seed({
+ factionTotals: [
+ { enemy: 0, defends: 10, defend_wins: 5, attacks: 8, attack_wins: 6 },
+ { enemy: 1, defends: 12, defend_wins: 8, attacks: 6, attack_wins: 3 },
+ { enemy: 2, defends: 15, defend_wins: 7, attacks: 5, attack_wins: 5 },
+ ],
+ seasons: [{ season: 1, season_duration: 0 }],
+ });
+ const r = await getCrossSeasonStats();
+ expect(r.factionTotals).toEqual([
+ { enemy: 0, defends: 10, defend_wins: 5, attacks: 8, attack_wins: 6 },
+ { enemy: 1, defends: 12, defend_wins: 8, attacks: 6, attack_wins: 3 },
+ { enemy: 2, defends: 15, defend_wins: 7, attacks: 5, attack_wins: 5 },
+ ]);
+ });
+
+ test('derives outcome=victory when all 3 final faction states are defeated', async () => {
+ seed({
+ seasons: [{ season: 1, season_duration: 0 }],
+ allEvents: [
+ {
+ season: 1,
+ type: 'attack',
+ status: 'success',
+ region: 5,
+ enemy: 0,
+ end_time: 100,
+ start_time: 50,
+ },
+ {
+ season: 1,
+ type: 'attack',
+ status: 'success',
+ region: 5,
+ enemy: 1,
+ end_time: 200,
+ start_time: 150,
+ },
+ {
+ season: 1,
+ type: 'attack',
+ status: 'success',
+ region: 5,
+ enemy: 2,
+ end_time: 300,
+ start_time: 250,
+ },
+ ],
+ finalStates: [
+ {
+ season: 1,
+ enemy: 0,
+ status: 'defeated',
+ points: 100,
+ points_taken: 100,
+ },
+ {
+ season: 1,
+ enemy: 1,
+ status: 'defeated',
+ points: 100,
+ points_taken: 100,
+ },
+ {
+ season: 1,
+ enemy: 2,
+ status: 'defeated',
+ points: 100,
+ points_taken: 100,
+ },
+ ],
+ });
+ const r = await getCrossSeasonStats();
+ expect(r.perSeason[0].outcome).toBe('victory');
+ // Victory faction = last conquered (enemy of last attack-success by end_time)
+ expect(r.perSeason[0].outcome_faction).toBe(2);
+ });
+
+ test('derives outcome=defeat when the last r0 defend failed and no victory signal fires', async () => {
+ seed({
+ seasons: [{ season: 2, season_duration: 0 }],
+ allEvents: [
+ {
+ season: 2,
+ type: 'defend',
+ status: 'fail',
+ region: 0,
+ enemy: 1,
+ end_time: 500,
+ start_time: 400,
+ },
+ ],
+ finalStates: [
+ { season: 2, enemy: 0, status: 'active', points: 50, points_taken: 20 },
+ { season: 2, enemy: 1, status: 'active', points: 80, points_taken: 30 },
+ { season: 2, enemy: 2, status: 'hidden', points: 0, points_taken: 0 },
+ ],
+ });
+ const r = await getCrossSeasonStats();
+ expect(r.perSeason[0].outcome).toBe('defeat');
+ expect(r.perSeason[0].outcome_faction).toBe(1);
+ });
+
+ test('telemetry fields are zero when the season has no h1_statistic row', async () => {
+ seed({
+ seasons: [{ season: 1, season_duration: 0 }],
+ telemetry: [],
+ });
+ const r = await getCrossSeasonStats();
+ expect(r.perSeason[0]).toMatchObject({
+ kills: 0n,
+ deaths: 0n,
+ accidentals: 0n,
+ shots: 0n,
+ hits: 0n,
+ missions: 0,
+ successful_missions: 0,
+ total_mission_difficulty: 0,
+ completed_planets: 0,
+ });
+ });
+
+ test('telemetry fields flow through when the season has h1_statistic', async () => {
+ seed({
+ seasons: [{ season: 157, season_duration: 0 }],
+ telemetry: [
+ {
+ season: 157,
+ kills: 12345678n,
+ deaths: 1000n,
+ accidentals: 50n,
+ shots: 9000000n,
+ hits: 4000000n,
+ missions: 100,
+ successful_missions: 80,
+ total_mission_difficulty: 600,
+ completed_planets: 4,
+ },
+ ],
+ });
+ const r = await getCrossSeasonStats();
+ expect(r.perSeason[0]).toMatchObject({
+ kills: 12345678n,
+ deaths: 1000n,
+ missions: 100,
+ successful_missions: 80,
+ });
+ });
+});
diff --git a/src/__tests__/unit/queries/validateApiKey.test.mjs b/src/__tests__/unit/queries/validateApiKey.test.mjs
index 8d28dae2..45b3f251 100644
--- a/src/__tests__/unit/queries/validateApiKey.test.mjs
+++ b/src/__tests__/unit/queries/validateApiKey.test.mjs
@@ -1,6 +1,6 @@
import { describe, test, expect, vi } from 'vitest';
import db from '@/db/db';
-import { validateApiKey } from '@/db/queries/validateApiKey.mjs';
+import { validateApiKey } from '@/shared/utils/api/validateApiKey.mjs';
import { createHash } from 'crypto';
function makeRequest(headerValue) {
@@ -42,11 +42,11 @@ describe('validateApiKey', () => {
});
});
- test('returns invalid error when database throws', async () => {
+ test('returns db_error code when database throws', async () => {
vi.mocked(db.ApiKey.findUnique).mockRejectedValue(new Error('db down'));
const result = await validateApiKey(makeRequest('Bearer some-key'));
- expect(result).toEqual({ data: null, code: 'invalid' });
+ expect(result).toEqual({ data: null, code: 'db_error' });
});
test('returns disabled error when key exists but is disabled', async () => {
diff --git a/src/__tests__/unit/routes/campaign.test.mjs b/src/__tests__/unit/routes/campaign.test.mjs
index 5936cb73..af9bcc19 100644
--- a/src/__tests__/unit/routes/campaign.test.mjs
+++ b/src/__tests__/unit/routes/campaign.test.mjs
@@ -4,7 +4,10 @@ import { getCampaign } from '@/db/queries/getCampaign.mjs';
import { updateSeason } from '@/update/season.mjs';
vi.mock('@/db/queries/getCampaign', () => ({ getCampaign: vi.fn() }));
-vi.mock('@/update/season', () => ({ updateSeason: vi.fn() }));
+vi.mock('@/update/season', () => ({
+ updateSeason: vi.fn(),
+ SEASON_NOT_FOUND: 'SEASON_NOT_FOUND',
+}));
vi.mock('@/shared/utils/umami', () => ({ umamiTrackEvent: vi.fn() }));
function createRouteRequest(path) {
diff --git a/src/__tests__/unit/routes/rebroadcast.test.mjs b/src/__tests__/unit/routes/rebroadcast.test.mjs
index de1cbdc2..f0c58bcb 100644
--- a/src/__tests__/unit/routes/rebroadcast.test.mjs
+++ b/src/__tests__/unit/routes/rebroadcast.test.mjs
@@ -15,15 +15,19 @@ vi.mock('@/db/db', () => ({
$queryRaw: vi.fn(),
},
}));
-vi.mock('@/db/queries/validateApiKey', () => ({
+vi.mock('@/shared/utils/api/validateApiKey', () => ({
validateApiKey: vi.fn(),
API_KEY_ERROR: Object.freeze({
MISSING: 'missing',
INVALID: 'invalid',
DISABLED: 'disabled',
+ DB_ERROR: 'db_error',
}),
}));
-vi.mock('@/update/season', () => ({ updateSeason: vi.fn() }));
+vi.mock('@/update/season', () => ({
+ updateSeason: vi.fn(),
+ SEASON_NOT_FOUND: 'SEASON_NOT_FOUND',
+}));
vi.mock('@/shared/utils/umami', () => ({ umamiTrackEvent: vi.fn() }));
vi.mock('@/shared/enums/events.mjs', () => ({
EVENT_TYPE: { DEFEND: 'defend', ATTACK: 'attack' },
@@ -40,7 +44,7 @@ vi.mock('next/server', async (importOriginal) => {
import { POST, GET, PUT, DELETE, PATCH, OPTIONS } from '@/app/api/h1/rebroadcast/route';
import db from '@/db/db';
-import { validateApiKey } from '@/db/queries/validateApiKey.mjs';
+import { validateApiKey } from '@/shared/utils/api/validateApiKey.mjs';
import { updateSeason } from '@/update/season.mjs';
function createPostRequest(formEntries) {
@@ -120,7 +124,7 @@ describe('POST /api/h1/rebroadcast — auth & validation', () => {
});
test('returns 403 when API key is disabled', async () => {
- const { API_KEY_ERROR } = await import('@/db/queries/validateApiKey');
+ const { API_KEY_ERROR } = await import('@/shared/utils/api/validateApiKey');
vi.mocked(validateApiKey).mockResolvedValue({
data: null,
code: API_KEY_ERROR.DISABLED,
@@ -129,6 +133,16 @@ describe('POST /api/h1/rebroadcast — auth & validation', () => {
expect(res.status).toBe(403);
});
+ test('returns 503 when API key lookup hits a DB error', async () => {
+ const { API_KEY_ERROR } = await import('@/shared/utils/api/validateApiKey');
+ vi.mocked(validateApiKey).mockResolvedValue({
+ data: null,
+ code: API_KEY_ERROR.DB_ERROR,
+ });
+ const res = await POST(createPostRequest({ action: 'get_campaign_status' }));
+ expect(res.status).toBe(503);
+ });
+
test('returns 400 for invalid content type', async () => {
const request = new Request('http://localhost/api/h1/rebroadcast', {
method: 'POST',
diff --git a/src/__tests__/unit/shared/components/BottomNav.test.jsx b/src/__tests__/unit/shared/components/BottomNav.test.jsx
index 7045b22d..2a48e0d2 100644
--- a/src/__tests__/unit/shared/components/BottomNav.test.jsx
+++ b/src/__tests__/unit/shared/components/BottomNav.test.jsx
@@ -12,10 +12,10 @@ describe('BottomNav', () => {
vi.mocked(usePathname).mockReturnValue('/');
});
- test('renders 3 navigation links', () => {
+ test('renders 4 navigation links', () => {
render( );
const links = screen.getAllByRole('link');
- expect(links).toHaveLength(3);
+ expect(links).toHaveLength(4);
});
test('renders correct labels', () => {
@@ -56,6 +56,10 @@ describe('BottomNav', () => {
'href',
'/archives',
);
+ expect(screen.getByRole('link', { name: /Stats/i })).toHaveAttribute(
+ 'href',
+ '/stats',
+ );
expect(screen.getByRole('link', { name: /Docs/i })).toHaveAttribute(
'href',
'/docs',
diff --git a/src/__tests__/unit/shared/components/HeaderNav.test.jsx b/src/__tests__/unit/shared/components/HeaderNav.test.jsx
index 65a4fc12..e3976c58 100644
--- a/src/__tests__/unit/shared/components/HeaderNav.test.jsx
+++ b/src/__tests__/unit/shared/components/HeaderNav.test.jsx
@@ -10,10 +10,10 @@ describe('HeaderNav', () => {
vi.mocked(usePathname).mockReturnValue('/');
});
- test('renders 3 navigation links', () => {
+ test('renders 4 navigation links', () => {
render( );
const links = screen.getAllByRole('link');
- expect(links).toHaveLength(3);
+ expect(links).toHaveLength(4);
});
test('renders correct labels', () => {
@@ -57,6 +57,10 @@ describe('HeaderNav', () => {
'href',
'/archives',
);
+ expect(screen.getByRole('link', { name: /Stats/i })).toHaveAttribute(
+ 'href',
+ '/stats',
+ );
expect(screen.getByRole('link', { name: /Docs/i })).toHaveAttribute(
'href',
'/docs',
diff --git a/src/__tests__/unit/shared/utils/format/formatNumber.test.mjs b/src/__tests__/unit/shared/utils/format/formatNumber.test.mjs
index 5a9db89e..52f7066e 100644
--- a/src/__tests__/unit/shared/utils/format/formatNumber.test.mjs
+++ b/src/__tests__/unit/shared/utils/format/formatNumber.test.mjs
@@ -5,8 +5,14 @@ describe('formatNumber', () => {
expect(formatNumber(1_500_000_000)).toBe('1.5B');
});
- test('formats millions', () => {
+ test('formats millions with the M suffix from 1M up', () => {
expect(formatNumber(12_300_000)).toBe('12.3M');
+ expect(formatNumber(3_522_088)).toBe('3.5M');
+ expect(formatNumber(1_000_000)).toBe('1.0M');
+ });
+
+ test('keeps numbers just below 1M locale-grouped', () => {
+ expect(formatNumber(999_999)).toBe('999,999');
});
test('formats thousands with commas', () => {
diff --git a/src/__tests__/unit/shared/utils/format/formatRatio.test.mjs b/src/__tests__/unit/shared/utils/format/formatRatio.test.mjs
new file mode 100644
index 00000000..9df5be22
--- /dev/null
+++ b/src/__tests__/unit/shared/utils/format/formatRatio.test.mjs
@@ -0,0 +1,24 @@
+import { formatRatio } from '@/shared/utils/format/formatRatio.mjs';
+
+describe('formatRatio', () => {
+ test('formats a ratio to one decimal place', () => {
+ expect(formatRatio(10, 5)).toBe('2.0');
+ });
+
+ test('rounds to one decimal place', () => {
+ expect(formatRatio(10, 3)).toBe('3.3');
+ });
+
+ test('coerces BigInt inputs', () => {
+ expect(formatRatio(1_200_000_000n, 50_000_000n)).toBe('24.0');
+ });
+
+ test('returns a dash for a zero denominator', () => {
+ expect(formatRatio(5, 0)).toBe('—');
+ });
+
+ test('returns a dash for a null or undefined denominator', () => {
+ expect(formatRatio(5, null)).toBe('—');
+ expect(formatRatio(5, undefined)).toBe('—');
+ });
+});
diff --git a/src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs b/src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs
index cfcf4038..fe072292 100644
--- a/src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs
+++ b/src/__tests__/unit/shared/utils/game/seasonAnalytics.test.mjs
@@ -1,105 +1,226 @@
import { describe, it, expect } from 'vitest';
-import { findWorstCascade } from '@/shared/utils/game/seasonAnalytics.mjs';
+import { findAllCascades } from '@/shared/utils/game/seasonAnalytics.mjs';
-describe('findWorstCascade', () => {
- it('returns null for empty events', () => {
- expect(findWorstCascade([])).toBeNull();
+/**
+ * Helper to build a defend/fail event. `gapAfterPrevEndSec` defaults to
+ * 1800 (30 minutes), well inside the 1-hour cascade window.
+ */
+function makeFailedDefend({ enemy, region, prevEndTime = null, durationSec = 7200 }) {
+ const start_time = prevEndTime != null ? prevEndTime + 1800 : 0;
+ const end_time = start_time + durationSec;
+ return {
+ type: 'defend',
+ status: 'fail',
+ enemy,
+ region,
+ start_time,
+ end_time,
+ event_id: Math.floor(Math.random() * 1_000_000),
+ };
+}
+
+describe('findAllCascades', () => {
+ it('returns [] for empty events', () => {
+ expect(findAllCascades([])).toEqual([]);
+ expect(findAllCascades(null)).toEqual([]);
+ expect(findAllCascades(undefined)).toEqual([]);
});
- it('returns null for null events', () => {
- expect(findWorstCascade(null)).toBeNull();
+ it('returns [] when fewer than 3 failed defends total', () => {
+ const e1 = makeFailedDefend({ enemy: 2, region: 8 });
+ const e2 = makeFailedDefend({ enemy: 2, region: 7, prevEndTime: e1.end_time });
+ expect(findAllCascades([e1, e2])).toEqual([]);
});
- it('returns null when fewer than 2 failed defends', () => {
- const events = [
- { type: 'defend', status: 'fail', enemy: 2, region: 5, end_time: 100 },
- ];
- expect(findWorstCascade(events)).toBeNull();
+ it('returns [] for a length-3 sequence that fails the gap rule', () => {
+ const e1 = makeFailedDefend({ enemy: 2, region: 8 });
+ // 2-hour gap (> 1-hour rule) → cascade breaks
+ const e2 = {
+ ...makeFailedDefend({ enemy: 2, region: 7 }),
+ start_time: e1.end_time + 7200,
+ end_time: e1.end_time + 7200 + 7200,
+ };
+ const e3 = {
+ ...makeFailedDefend({ enemy: 2, region: 6 }),
+ start_time: e2.end_time + 1800,
+ end_time: e2.end_time + 1800 + 7200,
+ };
+ expect(findAllCascades([e1, e2, e3])).toEqual([]);
+ });
+
+ it('detects a length-3 cascade for one faction', () => {
+ const e1 = makeFailedDefend({ enemy: 2, region: 8 });
+ const e2 = makeFailedDefend({ enemy: 2, region: 7, prevEndTime: e1.end_time });
+ const e3 = makeFailedDefend({ enemy: 2, region: 6, prevEndTime: e2.end_time });
+ const result = findAllCascades([e1, e2, e3]);
+ expect(result).toHaveLength(1);
+ expect(result[0].length).toBe(3);
+ expect(result[0].faction).toBe('The Illuminate');
+ expect(result[0].factionIndex).toBe(2);
+ expect(result[0].regions).toEqual([8, 7, 6]);
+ expect(result[0].startTime).toBe(e1.start_time);
+ expect(result[0].endTime).toBe(e3.end_time);
+ expect(result[0].durationSec).toBe(e3.end_time - e1.start_time);
+ expect(result[0].firstEvent.event_id).toBe(e1.event_id);
+ expect(result[0].lastEvent.event_id).toBe(e3.event_id);
+ expect(result[0].events).toHaveLength(3);
});
- it('detects a cascade of decreasing regions for same faction', () => {
+ it('ignores non-defend and non-fail events', () => {
const events = [
{
- type: 'defend',
- status: 'fail',
- enemy: 2,
- region: 8,
+ type: 'attack',
+ status: 'success',
+ enemy: 0,
+ region: 5,
+ start_time: 0,
end_time: 100,
- event_id: 10,
},
{
type: 'defend',
- status: 'fail',
- enemy: 2,
- region: 7,
- end_time: 200,
- event_id: 11,
- },
- {
- type: 'defend',
- status: 'fail',
- enemy: 2,
- region: 6,
+ status: 'success',
+ enemy: 0,
+ region: 4,
+ start_time: 200,
end_time: 300,
- event_id: 12,
},
{
type: 'defend',
status: 'fail',
- enemy: 2,
- region: 5,
- end_time: 400,
- event_id: 13,
+ enemy: 0,
+ region: 3,
+ start_time: 400,
+ end_time: 500,
},
];
- const result = findWorstCascade(events);
- expect(result).not.toBeNull();
- expect(result.length).toBe(4);
- expect(result.faction).toBe('The Illuminate');
- expect(result.regions).toEqual([8, 7, 6, 5]);
- expect(result.firstEvent.event_id).toBe(10);
+ expect(findAllCascades(events)).toEqual([]);
});
- it('ignores non-defend and non-fail events', () => {
- const events = [
- { type: 'attack', status: 'success', enemy: 0, region: 5, end_time: 100 },
- { type: 'defend', status: 'success', enemy: 0, region: 4, end_time: 200 },
- { type: 'defend', status: 'fail', enemy: 0, region: 3, end_time: 300 },
- ];
- expect(findWorstCascade(events)).toBeNull();
+ it('breaks the cascade when region does not strictly decrease', () => {
+ const e1 = makeFailedDefend({ enemy: 0, region: 5 });
+ const e2 = makeFailedDefend({ enemy: 0, region: 5, prevEndTime: e1.end_time });
+ const e3 = makeFailedDefend({ enemy: 0, region: 4, prevEndTime: e2.end_time });
+ expect(findAllCascades([e1, e2, e3])).toEqual([]);
});
- it('does not count non-decreasing regions as cascade', () => {
- const events = [
- { type: 'defend', status: 'fail', enemy: 0, region: 3, end_time: 100 },
- { type: 'defend', status: 'fail', enemy: 0, region: 5, end_time: 200 },
- ];
- expect(findWorstCascade(events)).toBeNull();
+ it('keeps cascades from separate factions independent', () => {
+ const b1 = makeFailedDefend({ enemy: 0, region: 4 });
+ const b2 = makeFailedDefend({ enemy: 0, region: 3, prevEndTime: b1.end_time });
+ const b3 = makeFailedDefend({ enemy: 0, region: 2, prevEndTime: b2.end_time });
+ const i1 = {
+ ...makeFailedDefend({ enemy: 2, region: 8 }),
+ end_time: b1.end_time + 60,
+ };
+ const i2 = {
+ ...makeFailedDefend({ enemy: 2, region: 7 }),
+ start_time: i1.end_time + 600,
+ end_time: i1.end_time + 600 + 7200,
+ };
+ const i3 = {
+ ...makeFailedDefend({ enemy: 2, region: 6 }),
+ start_time: i2.end_time + 600,
+ end_time: i2.end_time + 600 + 7200,
+ };
+ const i4 = {
+ ...makeFailedDefend({ enemy: 2, region: 5 }),
+ start_time: i3.end_time + 600,
+ end_time: i3.end_time + 600 + 7200,
+ };
+
+ const result = findAllCascades([b1, i1, b2, i2, b3, i3, i4]);
+ expect(result).toHaveLength(2);
+ expect(result[0].length).toBe(4);
+ expect(result[0].factionIndex).toBe(2);
+ expect(result[1].length).toBe(3);
+ expect(result[1].factionIndex).toBe(0);
});
- it('finds the longest cascade across multiple factions', () => {
- const events = [
- // Bugs: 2-region cascade
- { type: 'defend', status: 'fail', enemy: 0, region: 4, end_time: 100 },
- { type: 'defend', status: 'fail', enemy: 0, region: 3, end_time: 200 },
- // Illuminate: 3-region cascade
- { type: 'defend', status: 'fail', enemy: 2, region: 7, end_time: 300 },
- { type: 'defend', status: 'fail', enemy: 2, region: 6, end_time: 400 },
- { type: 'defend', status: 'fail', enemy: 2, region: 5, end_time: 500 },
- ];
- const result = findWorstCascade(events);
- expect(result.length).toBe(3);
- expect(result.faction).toBe('The Illuminate');
+ it('emits multiple cascades from the same faction when separated by a gap', () => {
+ const a1 = makeFailedDefend({ enemy: 0, region: 5 });
+ const a2 = makeFailedDefend({ enemy: 0, region: 4, prevEndTime: a1.end_time });
+ const a3 = makeFailedDefend({ enemy: 0, region: 3, prevEndTime: a2.end_time });
+ const gapEnd = a3.end_time + 86400;
+ const b1 = {
+ ...makeFailedDefend({ enemy: 0, region: 6 }),
+ start_time: gapEnd,
+ end_time: gapEnd + 7200,
+ };
+ const b2 = makeFailedDefend({ enemy: 0, region: 5, prevEndTime: b1.end_time });
+ const b3 = makeFailedDefend({ enemy: 0, region: 4, prevEndTime: b2.end_time });
+
+ const result = findAllCascades([a1, a2, a3, b1, b2, b3]);
+ expect(result).toHaveLength(2);
+ expect(result.every((c) => c.length === 3 && c.factionIndex === 0)).toBe(true);
});
- it('resets cascade when region order breaks', () => {
- const events = [
- { type: 'defend', status: 'fail', enemy: 0, region: 5, end_time: 100 },
- { type: 'defend', status: 'fail', enemy: 0, region: 4, end_time: 200 },
- { type: 'defend', status: 'fail', enemy: 0, region: 7, end_time: 300 }, // breaks cascade
- { type: 'defend', status: 'fail', enemy: 0, region: 6, end_time: 400 },
- ];
- const result = findWorstCascade(events);
- expect(result.length).toBe(2); // best is 2, not 4
+ it('respects custom minLength', () => {
+ const e1 = makeFailedDefend({ enemy: 1, region: 4 });
+ const e2 = makeFailedDefend({ enemy: 1, region: 3, prevEndTime: e1.end_time });
+ const e3 = makeFailedDefend({ enemy: 1, region: 2, prevEndTime: e2.end_time });
+ expect(findAllCascades([e1, e2, e3])).toHaveLength(1);
+ expect(findAllCascades([e1, e2, e3], { minLength: 4 })).toHaveLength(0);
+ expect(findAllCascades([e1, e2, e3], { minLength: 2 })).toHaveLength(1);
+ });
+
+ it('sorts by length DESC, then speed DESC, then end_time DESC', () => {
+ const a1 = {
+ type: 'defend',
+ status: 'fail',
+ enemy: 0,
+ region: 5,
+ start_time: 0,
+ end_time: 10800,
+ event_id: 1,
+ };
+ const a2 = {
+ type: 'defend',
+ status: 'fail',
+ enemy: 0,
+ region: 4,
+ start_time: 12000,
+ end_time: 22800,
+ event_id: 2,
+ };
+ const a3 = {
+ type: 'defend',
+ status: 'fail',
+ enemy: 0,
+ region: 3,
+ start_time: 24000,
+ end_time: 34800,
+ event_id: 3,
+ };
+ const b1 = {
+ type: 'defend',
+ status: 'fail',
+ enemy: 2,
+ region: 5,
+ start_time: 100000,
+ end_time: 103600,
+ event_id: 4,
+ };
+ const b2 = {
+ type: 'defend',
+ status: 'fail',
+ enemy: 2,
+ region: 4,
+ start_time: 104000,
+ end_time: 107600,
+ event_id: 5,
+ };
+ const b3 = {
+ type: 'defend',
+ status: 'fail',
+ enemy: 2,
+ region: 3,
+ start_time: 108000,
+ end_time: 111600,
+ event_id: 6,
+ };
+
+ const result = findAllCascades([a1, a2, a3, b1, b2, b3]);
+ expect(result).toHaveLength(2);
+ expect(result[0].factionIndex).toBe(2);
+ expect(result[1].factionIndex).toBe(0);
});
});
diff --git a/src/__tests__/unit/shared/utils/utils.test.mjs b/src/__tests__/unit/shared/utils/utils.test.mjs
index 06c41de1..687e339e 100644
--- a/src/__tests__/unit/shared/utils/utils.test.mjs
+++ b/src/__tests__/unit/shared/utils/utils.test.mjs
@@ -49,10 +49,10 @@ describe('formatNumber', () => {
expect(formatNumber(999999)).toBe(Number(999999).toLocaleString());
});
- test('values 1M–9.99M use locale grouping', () => {
- expect(formatNumber(1000000)).toBe(Number(1000000).toLocaleString());
- expect(formatNumber(1500000)).toBe(Number(1500000).toLocaleString());
- expect(formatNumber(9999999)).toBe(Number(9999999).toLocaleString());
+ test('values from 1M up use the M suffix with one decimal', () => {
+ expect(formatNumber(1000000)).toBe('1.0M');
+ expect(formatNumber(1500000)).toBe('1.5M');
+ expect(formatNumber(9500000)).toBe('9.5M');
});
test('millions ≥10M use M suffix with one decimal', () => {
@@ -68,7 +68,7 @@ describe('formatNumber', () => {
test('BigInt input is converted to number', () => {
expect(formatNumber(BigInt(42))).toBe('42');
- expect(formatNumber(BigInt(5000000))).toBe(Number(5000000).toLocaleString());
+ expect(formatNumber(BigInt(5000000))).toBe('5.0M');
});
test('null, undefined, and non-finite return em dash', () => {
diff --git a/src/app/api/h1/campaign/route.js b/src/app/api/h1/campaign/route.js
index 847dac1c..566f73c9 100644
--- a/src/app/api/h1/campaign/route.js
+++ b/src/app/api/h1/campaign/route.js
@@ -10,7 +10,7 @@ import { after } from 'next/server';
import { isValidNumber } from '@/validators/isValidNumber.mjs';
//db and fetch
import { getCampaign } from '@/db/queries/getCampaign.mjs';
-import { updateSeason } from '@/update/season.mjs';
+import { updateSeason, SEASON_NOT_FOUND } from '@/update/season.mjs';
//track
import { umamiTrackEvent } from '@/shared/utils/umami.mjs';
@@ -50,7 +50,7 @@ export async function GET(request) {
if (!campaignData) {
const { error: fetchError } = await tryCatch(updateSeason(season));
if (fetchError) {
- if (fetchError.cause === 'SEASON_NOT_FOUND') {
+ if (fetchError.cause === SEASON_NOT_FOUND) {
return errorResponse(404, start, fetchError.message);
}
reportError(fetchError, {
diff --git a/src/app/api/h1/rebroadcast/route.js b/src/app/api/h1/rebroadcast/route.js
index ca4dd2d4..75b83062 100644
--- a/src/app/api/h1/rebroadcast/route.js
+++ b/src/app/api/h1/rebroadcast/route.js
@@ -1,4 +1,3 @@
-import db from '@/db/db';
import { tryCatch } from '@/shared/utils/tryCatch.mjs';
import { performance } from 'perf_hooks';
import { roundedPerformanceTime } from '@/shared/utils/time.mjs';
@@ -10,14 +9,15 @@ import { reportError } from '@/shared/utils/observability.mjs';
import { isValidContentType } from '@/validators/isValidContentType.mjs';
import { isValidFormData } from '@/validators/isValidFormData.mjs';
//db
-import { updateSeason } from '@/update/season.mjs';
+import {
+ reconstructCampaignStatus,
+ reconstructSnapshots,
+} from '@/db/queries/rebroadcast.mjs';
+import { updateSeason, SEASON_NOT_FOUND } from '@/update/season.mjs';
//auth
-import { validateApiKey, API_KEY_ERROR } from '@/db/queries/validateApiKey.mjs';
+import { validateApiKey, API_KEY_ERROR } from '@/shared/utils/api/validateApiKey.mjs';
//track
import { umamiTrackEvent } from '@/shared/utils/umami.mjs';
-//enums
-import { EVENT_TYPE, EVENT_STATUS } from '@/shared/enums/events.mjs';
-import { groupStatusByBucket } from '@/shared/utils/bucketing.mjs';
export async function POST(request) {
const start = performance.now();
@@ -25,6 +25,9 @@ export async function POST(request) {
let formValues = null;
const { code: keyCode } = await validateApiKey(request);
+ if (keyCode === API_KEY_ERROR.DB_ERROR) {
+ return errorResponse(503, start, 'database unreachable');
+ }
if (keyCode === API_KEY_ERROR.DISABLED) {
return errorResponse(403, start, 'Forbidden');
}
@@ -113,6 +116,9 @@ export async function POST(request) {
updateSeason(formValues.season),
);
if (seasonFetchError) {
+ if (seasonFetchError.cause === SEASON_NOT_FOUND) {
+ return errorResponse(404, start, seasonFetchError.message);
+ }
reportError(seasonFetchError, {
route: '/api/h1/rebroadcast',
stage: 'backfill-season',
@@ -145,150 +151,6 @@ export async function POST(request) {
return successResponse(200, start, data);
}
-/**
- * Reconstruct the `get_campaign_status` wire format from the normalized
- * tables (h1_season + h1_status + h1_statistic + h1_event). Uses the latest
- * season with data. Returns null when no season has been populated yet.
- *
- * Partial loss of fidelity vs the HD1 wire format: the 4 event-count
- * fields on each statistics[] entry (defend_events, successful_defend_events,
- * attack_events, successful_attack_events) are omitted — they are derivable
- * from h1_event with COUNT(*) WHERE type=... AND status=... AND season=X.
- */
-async function reconstructCampaignStatus() {
- // Latest season that has been populated.
- const seasonRow = await db.h1_season.findFirst({
- where: { last_updated: { not: null } },
- orderBy: { season: 'desc' },
- select: {
- season: true,
- introduction_order: true,
- points_max: true,
- season_duration: true,
- },
- });
- if (!seasonRow) return null;
-
- const targetSeason = seasonRow.season;
-
- // latestStatus / latestStats / activeEvents are mutually independent —
- // each keyed only on targetSeason — so they run in parallel, collapsing
- // three sequential round-trips into one. (The DISTINCT ON $queryRaw is
- // used like getCampaign.mjs; Prisma can't express DISTINCT ON natively.)
- const [latestStatus, latestStats, activeEvents] = await Promise.all([
- db.$queryRaw`
- SELECT DISTINCT ON (enemy) *
- FROM h1_status
- WHERE season = ${targetSeason}
- ORDER BY enemy ASC, bucket DESC
- `,
- db.$queryRaw`
- SELECT DISTINCT ON (enemy) *
- FROM h1_statistic
- WHERE season = ${targetSeason}
- ORDER BY enemy ASC, bucket DESC
- `,
- db.h1_event.findMany({
- where: { season: targetSeason, status: EVENT_STATUS.ACTIVE },
- }),
- ]);
-
- const statByEnemy = new Map(latestStats.map((r) => [r.enemy, r]));
-
- const latestTime = Math.max(
- 0,
- ...latestStatus.map((r) => r.time),
- ...latestStats.map((r) => r.time),
- );
-
- return {
- time: latestTime,
- error_code: 0,
- campaign_status: latestStatus.map((r) => ({
- enemy: r.enemy,
- points: r.points,
- points_taken: r.points_taken,
- points_max: seasonRow.points_max?.[r.enemy] ?? 0,
- status: r.status,
- introduction_order: seasonRow.introduction_order?.[r.enemy] ?? 0,
- })),
- statistics: [0, 1, 2].map((enemy) => {
- const s = statByEnemy.get(enemy);
- return {
- enemy,
- season_duration: seasonRow.season_duration ?? 0,
- players: s?.players ?? 0,
- total_unique_players: s?.total_unique_players ?? 0,
- missions: s?.missions ?? 0,
- successful_missions: s?.successful_missions ?? 0,
- total_mission_difficulty: s?.total_mission_difficulty ?? 0,
- completed_planets: s?.completed_planets ?? 0,
- // 4 fields intentionally omitted (derivable from h1_event):
- // defend_events, successful_defend_events,
- // attack_events, successful_attack_events
- kills: s?.kills != null ? Number(s.kills) : 0,
- deaths: s?.deaths != null ? Number(s.deaths) : 0,
- accidentals: s?.accidentals != null ? Number(s.accidentals) : 0,
- shots: s?.shots != null ? Number(s.shots) : 0,
- hits: s?.hits != null ? Number(s.hits) : 0,
- };
- }),
- defend_event: activeEvents.find((e) => e.type === EVENT_TYPE.DEFEND) ?? null,
- attack_events: activeEvents.filter((e) => e.type === EVENT_TYPE.ATTACK),
- introduction_order: seasonRow.introduction_order ?? [],
- points_max: seasonRow.points_max ?? [],
- };
-}
-
-/**
- * Reconstruct the `get_snapshots` wire format for a given season from the
- * normalized tables (h1_season + h1_status + h1_event). Returns null when
- * the season has no h1_season row yet (caller may then trigger an on-demand
- * updateSeason() fetch from the official API).
- *
- * Sparse buckets (missing one or more factions) are filtered out of the
- * snapshot array for consumer safety — matches getCampaign.mjs's behavior.
- */
-async function reconstructSnapshots(season) {
- if (!season) return null;
-
- const seasonRow = await db.h1_season.findUnique({
- where: { season },
- select: {
- season: true,
- introduction_order: true,
- points_max: true,
- },
- });
- if (!seasonRow) return null;
-
- // allStatus / allEvents are independent — run in parallel to collapse
- // two sequential round-trips into one.
- const [allStatus, allEvents] = await Promise.all([
- db.h1_status.findMany({
- where: { season },
- orderBy: [{ bucket: 'asc' }, { enemy: 'asc' }],
- }),
- db.h1_event.findMany({ where: { season } }),
- ]);
-
- const snapshots = groupStatusByBucket(allStatus).map(({ time, factions }) => ({
- season,
- time,
- data: JSON.stringify(factions),
- }));
-
- return {
- time: Math.floor(Date.now() / 1000),
- error_code: 0,
- introduction_order: seasonRow.introduction_order ?? [],
- points_max: seasonRow.points_max ?? [],
- snapshots,
- defend_events: allEvents.filter((e) => e.type === EVENT_TYPE.DEFEND),
- attack_events: allEvents.filter((e) => e.type === EVENT_TYPE.ATTACK),
- };
-}
-
export const GET = methodNotAllowed;
export const PUT = methodNotAllowed;
export const DELETE = methodNotAllowed;
diff --git a/src/app/archives/page.jsx b/src/app/archives/page.jsx
index 8e52f8ce..072f242d 100644
--- a/src/app/archives/page.jsx
+++ b/src/app/archives/page.jsx
@@ -10,9 +10,13 @@ import { ROLE } from '@/shared/enums/roles.mjs';
//components
import JsonLd from '@/shared/components/JsonLd';
import ArchivesClient from '@/features/archives/ArchivesClient';
-import { RESISTANCE_MESSAGES } from '@/features/archives/resistanceMessages.mjs';
import { FACTION_KEY, validateFaction } from '@/shared/preferences/faction.mjs';
-import { SORT_ORDER_KEY, validateSortOrder } from '@/shared/preferences/sortOrder.mjs';
+import {
+ SORT_ORDER_KEY,
+ validateSortOrder,
+ CASCADE_SORT_ORDER_KEY,
+ validateCascadeSortOrder,
+} from '@/shared/preferences/sortOrder.mjs';
// Force dynamic rendering - skip build-time evaluation (requires database)
export const dynamic = 'force-dynamic';
@@ -99,6 +103,9 @@ export default async function WarHistoryPage({ searchParams }) {
const c = await cookies();
const initialFaction = validateFaction(c.get(FACTION_KEY)?.value);
const initialSortOrder = validateSortOrder(c.get(SORT_ORDER_KEY)?.value);
+ const initialCascadeSort = validateCascadeSortOrder(
+ c.get(CASCADE_SORT_ORDER_KEY)?.value,
+ );
return (
@@ -108,12 +115,10 @@ export default async function WarHistoryPage({ searchParams }) {
data={data}
seasons={seasons}
currentSeason={currentSeason}
- defeatMessageIndex={Math.floor(
- Math.random() * RESISTANCE_MESSAGES.length,
- )}
isAdmin={isAdmin}
initialFaction={initialFaction}
initialSortOrder={initialSortOrder}
+ initialCascadeSort={initialCascadeSort}
/>
);
diff --git a/src/app/docs/authentication/page.mdx b/src/app/docs/authentication/page.mdx
index d62e9c2a..f26759d8 100644
--- a/src/app/docs/authentication/page.mdx
+++ b/src/app/docs/authentication/page.mdx
@@ -157,22 +157,22 @@ All `/profile/*` routes are protected by this layout. Unauthenticated users are
### Admin Guard (Server Actions)
-`src/db/queries/admin.mjs`
+`src/shared/utils/api/authGuards.mjs`
```js
async function requireAdmin() {
- const session = await auth.api.getSession({ headers: await headers() });
- if (!session || !session.user) return { error: 'Not authenticated' };
- if (session.user.role !== 'admin') return { error: 'Forbidden' };
- return { user: session.user };
+ const { user, error } = await requireSession();
+ if (error) return { user: null, error };
+ if (user.role !== 'admin') return { user: null, error: 'Forbidden' };
+ return { user, error: null };
}
```
-Used by all admin server actions (`getAllUsers`, `updateUserRole`, `toggleUserBan`, etc.). Returns `{ user }` on success or `{ error }` on failure.
+Used by all admin server actions (`getAllUsers`, `updateUserRole`, `toggleUserBan`, etc.) in `src/features/admin/actions.mjs`. Returns `{ user, error: null }` on success or `{ user: null, error }` on failure (both keys always present).
### Ownership Guard (Server Actions)
-`src/db/queries/account.mjs`, `src/db/queries/api.mjs`
+`src/features/account/actions.mjs`
```js
const session = await auth.api.getSession({ headers: await headers() });
@@ -230,7 +230,7 @@ Roles are stored in `User.role` (string, default `"user"`). Admins can change ro
API keys provide a separate authentication mechanism for the `/api/h1/rebroadcast` endpoint. They are independent of BetterAuth sessions.
-`src/db/queries/validateApiKey.mjs`
+`src/shared/utils/api/validateApiKey.mjs`
```mermaid
graph LR
diff --git a/src/app/docs/brandkit/page.jsx b/src/app/docs/brandkit/page.jsx
index 33cfe811..0d31f962 100644
--- a/src/app/docs/brandkit/page.jsx
+++ b/src/app/docs/brandkit/page.jsx
@@ -1,3 +1,5 @@
+import Hijackable from '@/features/ministry/Hijackable';
+
export const metadata = {
title: 'Brand Kit | Helldivers Bot',
description:
@@ -28,7 +30,7 @@ function Hero() {
Design System // Tactical Command Interface
- Brand Kit
+
Visual reference for the Tactical Command Interface. Tokens defined in{' '}
src/app/layout.css.
@@ -72,7 +74,12 @@ function Hero() {
function Palette() {
return (
- Palette
+
Website
- Surfaces
+
Tonal layering — depth via surface shifts, no shadows.
@@ -219,7 +231,12 @@ function Swatch({ color, name, token }) {
function TypeAndSpacing() {
return (
- Typography
+
Current Campaign
@@ -315,7 +332,12 @@ const btn =
function Components() {
return (
- Components
+
**Security note:** SHA-256 is used as a one-way hash for key lookup. The key space is a random UUID (128 bits of entropy), which provides the actual security. The hash allows O(1) database lookups without storing the raw key. Validation logic is in `src/db/queries/validateApiKey.mjs`.
+> **Security note:** SHA-256 is used as a one-way hash for key lookup. The key space is a random UUID (128 bits of entropy), which provides the actual security. The hash allows O(1) database lookups without storing the raw key. Validation logic is in `src/shared/utils/api/validateApiKey.mjs`.
---
diff --git a/src/app/layout.jsx b/src/app/layout.jsx
index 0710c825..3dedc115 100644
--- a/src/app/layout.jsx
+++ b/src/app/layout.jsx
@@ -9,10 +9,15 @@ import Footer from '@/shared/components/Footer/Footer';
import BottomNav from '@/shared/components/BottomNav/BottomNav';
import PreferenceTracker from '@/shared/components/PreferenceTracker';
import LiveDataProvider from '@/shared/providers/LiveDataProvider';
+import MinistryProvider from '@/features/ministry/MinistryProvider';
+import MinistryTriggerWidget from '@/features/admin/MinistryTriggerWidget';
//data
+import { auth } from '@/auth';
import { tryCatch } from '@/shared/utils/tryCatch.mjs';
import { getCampaign } from '@/db/queries/getCampaign.mjs';
import { computeLiveMapState } from '@/shared/utils/game/computeMapState.mjs';
+import { getWarTone } from '@/features/ministry/warTone.mjs';
+import { ROLE } from '@/shared/enums/roles.mjs';
const spaceGrotesk = Space_Grotesk({
subsets: ['latin'],
@@ -26,6 +31,8 @@ const inter = Inter({
display: 'swap',
});
+export const dynamic = 'force-dynamic';
+
export const viewport = {
themeColor: '#1c1b1b',
};
@@ -61,6 +68,13 @@ export default async function RootLayout({ children }) {
// handled gracefully by useLiveData's fallback chain.
const { data } = await tryCatch(getCampaign());
const initialMapState = data ? computeLiveMapState(data) : null;
+ const warTone = await getWarTone();
+
+ // Session lookup gates the floating Ministry-trigger widget to admins.
+ // Auth-disabled deploys (BETTER_AUTH_SECRET unset) export `auth === null`
+ // so the optional chain bypasses the cookie read entirely.
+ const session = auth ? await auth.api.getSession({ headers: await headers() }) : null;
+ const isAdmin = session?.user?.role === ROLE.ADMIN;
return (
-
-
-
- {children}
-
-
-
+
+
+
+
+ {children}
+
+
+
+
+
{isProduction && process.env.UMAMI_SITE_ID ?