feat: Slice 5 — convention hub (live now / up next, deep-links)

Add the convention hub landing screen: a pure schedule engine
(analyzeSchedule) classifies each slot as live/next/done/upcoming from an
injected now; <con-hub> renders the slate + summary and emits open-scenario;
a scenario registry resolves a slot's scenarioId to its module; gm-shell
routes hub<->session (hiding/restoring the Rail) with a home rail chip.

Ships continuum-2026.js with the real slate (only AFTERIMAGE ported;
others scenarioId:null, start-times TBC) and a fully-timed example-con.js
template/fixture. 122 tests pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-07-20 21:35:47 +01:00
co-authored by Claude Opus 4.8
parent a7a7870a76
commit b57880d630
15 changed files with 446 additions and 4 deletions
+6 -2
View File
@@ -57,6 +57,10 @@ Components are dumb renderers that emit events; the shell wires them together.
| `src/npc/` | NPC generator (`generator.js`) + genre table packs (`packs/noir.js`) |
| `src/art/` | Pencil-art library search (`search.js`) + tagged manifest |
| `src/clues/safety-net.js` | Clue safety-net engine (essential-gap + solvability) |
| `src/con/schedule.js` | Pure convention-schedule analysis (live / up-next / done / upcoming) |
| `src/components/con-hub.js` | `<con-hub>` — the convention landing screen; deep-links into a scenario |
| `src/scenarios/index.js` | Scenario registry (`getScenario(id)`) the hub deep-links through |
| `src/con/` | Convention data (`continuum-2026.js`; `example-con.js` is a fully-timed template) |
| `src/components/director-rail.js` | `<director-rail>` — the always-on pacing bar (🎲 👤 ✏️ 🔍 chips) |
| `src/components/dice-tray.js` | `<dice-tray>` — pack selector + die picker + roll + verdict |
| `src/components/npc-tray.js` · `art-tray.js` · `prop-viewer.js` · `clue-net.js` | The tray tools |
@@ -92,8 +96,8 @@ compress if you reach a beat behind schedule.
2. **Slice 2** — NPC generator (offline genre tables) + art generator (offline pencil-art library). ✅
3. **Slice 3** — dice rule-packs (CoC d100, VANITY d6-pool, Panic & Glory, Dee Sanction) + tray pack selector. ✅
4. **Slice 4** — the clue **safety-net** (essential-clue gap tracker + fallbacks). ✅
5. **Slice 5** — the convention **hub** (all slots, live "up next / live now").
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; scenario porting from the Continuum docs; markdown → scenario-data generator; native iPad wrapper.
5. **Slice 5** — the convention **hub** (all slots, live "live now / up next / done", deep-links into each scenario). ✅
6. **Later** — remaining tray tools (break timer, parking-lot note, wake-lock); online art "Generate"; scenario porting from the Continuum docs; real slot start-times for the hub; markdown → scenario-data generator; native iPad wrapper.
## Docs
@@ -0,0 +1,49 @@
# The Director — Slice 5 Implementation Plan
**Goal:** Add the convention **hub** — a landing screen listing every slot of a convention with
live **"live now / up next / done / upcoming"** status, that deep-links into each ported scenario
and lets the GM return to the hub from a running session.
**Architecture:** Same pattern as the other slices — a pure engine (`analyzeSchedule`) that classifies
slots from a wall-clock `now`, a `<con-hub>` light-DOM component that renders the slot list + summary
and emits `open-scenario`, a scenario **registry** (`src/scenarios/index.js`) resolving a slot's
`scenarioId` to its module, and shell routing that switches between the hub and a session (hiding the
Rail/stage in hub mode) plus a 🏠 rail chip to return. No fabricated content: only AFTERIMAGE is
ported, so `continuum-2026.js` marks the other four slots `scenarioId: null` ("Not yet ported") with
`startsAt: null` (times TBC, not invented); a clearly-labelled `example-con.js` provides fully-timed
slots as the demo/test fixture.
**Tech Stack:** JavaScript (ES modules, no TypeScript), Web Components (light DOM), Vite, Vitest +
jsdom. No new dependencies. Fully offline.
## Global Constraints
- **Offline-first, no runtime framework, light DOM, no TypeScript.**
- **Con model:** `{ meta: { id, title }, slots: [ { id, scenarioId, title, slot, system?, players?, playMinutes?, startsAt? } ] }`.
`slot` is the display label; `startsAt` (ISO) drives timing; `scenarioId` deep-links via the registry
(null ⇒ not yet ported). A null/absent `startsAt` ⇒ unscheduled: listed, never live/next.
- **Determinism:** `analyzeSchedule(slots, nowMs)` is pure; `<con-hub>` takes an injected `now()` like the shell.
- **Escaping:** all author text (titles, slot labels, systems) via `escapeHtml`.
- **Rail invariant preserved:** the hub is a *pre-session* screen; the Rail is hidden in hub mode and
restored (never removed) when a session is open.
## Tasks (all complete)
1. **Schedule engine** — `src/con/schedule.js` (`analyzeSchedule`, `slotStartMs`) + `tests/con/schedule.test.js` (6).
2. **Scenario registry** — `src/scenarios/index.js` (`SCENARIOS`, `getScenario`) + `tests/scenarios/registry.test.js` (2).
3. **`<con-hub>` component** — `src/components/con-hub.js` + `tests/components/con-hub.test.js` (6).
4. **Con data** — `src/con/continuum-2026.js` (real slate, honest placeholders) + `src/con/example-con.js` (template/fixture).
5. **Shell + rail wiring** — `gm-shell.js` (`loadCon`/`showHub`/`showSession`/`onOpenScenario`, `open-hub`),
`director-rail.js` (🏠 chip), `styles.css`, boot in `main.js` → `loadCon(continuum2026)` +
`tests/components/gm-shell-hub.test.js` (4).
## Validation
- `npm test` → 122 passing (34 files); build succeeds (35 modules). Manual: hub renders the Continuum
slate, "Open" deep-links AFTERIMAGE into a live Rail session, 🏠 returns to the hub. Console clean.
## Deferred (backlog, not this slice)
- Real slot **start times** for Continuum 2026 (add each `startsAt` to light up live/up-next).
- Porting the four remaining scenarios (Day One, Vain Crown, Another Fine Mess, Prince's Bride) into
scenario modules + registering them (then flip each slot's `scenarioId`).
+66
View File
@@ -0,0 +1,66 @@
import { analyzeSchedule } from '../con/schedule.js';
import { escapeHtml } from '../core/escape-html.js';
const STATUS_LABEL = { live: 'Live now', next: 'Up next', done: 'Done', upcoming: 'Upcoming' };
export class ConHub extends HTMLElement {
constructor() {
super();
this._con = null;
this.now = () => Date.now();
}
connectedCallback() { this.render(); }
set con(v) { this._con = v || null; this.render(); }
get con() { return this._con; }
render() {
if (!this._con) {
this.innerHTML = `<div class="hub hub--empty" data-role="empty">No convention loaded</div>`;
return;
}
const con = this._con;
const a = analyzeSchedule(con.slots || [], this.now());
const summary = a.liveNow
? `<span class="hub-live">● Live now — ${escapeHtml(a.liveNow.title)}</span>`
: (a.upNext
? `<span class="hub-next">Up next — ${escapeHtml(a.upNext.title)}</span>`
: `<span class="hub-idle">No sessions scheduled</span>`);
const cards = a.slots.map((s) => {
const canOpen = !!s.scenarioId;
const meta = [s.slot, s.system, s.players ? `${s.players}p` : null]
.filter(Boolean).map(escapeHtml).join(' · ');
const action = canOpen
? `<button class="hub-open" data-role="open" data-scenario-id="${escapeHtml(s.scenarioId)}">Open</button>`
: `<span class="hub-soon" data-role="not-ready">Not yet ported</span>`;
return `
<div class="hub-card status-${s.status}" data-slot-id="${escapeHtml(s.id)}" data-status="${s.status}">
<div class="hub-card-main">
<div class="hub-card-title">${escapeHtml(s.title)} <span class="hub-badge badge-${s.status}">${STATUS_LABEL[s.status]}</span></div>
<div class="hub-card-meta">${meta}</div>
</div>
${action}
</div>`;
}).join('');
this.innerHTML = `
<div class="hub">
<header class="hub-head">
<h1 class="hub-title">${escapeHtml(con.meta?.title || 'Convention')}</h1>
<div class="hub-summary" data-role="summary">${summary}</div>
</header>
<div class="hub-list">${cards}</div>
</div>`;
this.querySelectorAll('[data-role=open]').forEach((btn) => {
btn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-scenario', {
detail: { scenarioId: btn.dataset.scenarioId }, bubbles: true,
}));
});
});
}
}
customElements.define('con-hub', ConHub);
+4
View File
@@ -26,6 +26,7 @@ export class DirectorRail extends HTMLElement {
<span class="v"><span data-role="here">${here}</span>${drift ? ` <span class="drift ${driftClass}">${drift}</span>` : ''}</span></div>
<div class="cell trig"><span class="k">Next hard trigger</span><span class="v" data-role="next">${nhText}</span></div>
<button class="reached" data-role="reached">✓ Reached it</button>
<button class="tray-btn" data-role="open-hub" aria-label="Hub">🏠</button>
<button class="tray-btn" data-role="open-dice" aria-label="Dice">🎲</button>
<button class="tray-btn" data-role="open-npc" aria-label="NPC">👤</button>
<button class="tray-btn" data-role="open-art" aria-label="Art">✏️</button>
@@ -35,6 +36,9 @@ export class DirectorRail extends HTMLElement {
this.querySelector('[data-role=reached]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('reached', { bubbles: true }));
});
this.querySelector('[data-role=open-hub]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-hub', { bubbles: true }));
});
this.querySelector('[data-role=open-dice]').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true }));
});
+33
View File
@@ -7,12 +7,15 @@ import './npc-tray.js';
import './art-tray.js';
import './prop-viewer.js';
import './clue-net.js';
import './con-hub.js';
import { getScenario } from '../scenarios/index.js';
export class GmShell extends HTMLElement {
constructor() {
super();
this.now = () => Date.now();
this.scenario = null;
this.con = null;
this.store = null;
this.clockState = initialClock();
this.stamps = {};
@@ -23,6 +26,7 @@ export class GmShell extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<con-hub hidden></con-hub>
<director-rail></director-rail>
<main class="stage">
<dice-tray hidden></dice-tray>
@@ -36,6 +40,35 @@ export class GmShell extends HTMLElement {
this.addEventListener('keep-npc', (e) => this.onKeepNpc(e));
this.addEventListener('show-prop', (e) => this.onShowProp(e));
this.addEventListener('toggle-clue', (e) => this.onToggleClue(e));
this.addEventListener('open-scenario', (e) => this.onOpenScenario(e));
this.addEventListener('open-hub', () => this.showHub());
}
loadCon(con) {
this.con = con;
const hub = this.querySelector('con-hub');
hub.now = this.now;
hub.con = con;
this.showHub();
}
showHub() {
this.querySelector('con-hub').hidden = false;
this.querySelector('director-rail').hidden = true;
this.querySelector('.stage').hidden = true;
}
showSession() {
this.querySelector('con-hub').hidden = true;
this.querySelector('director-rail').hidden = false;
this.querySelector('.stage').hidden = false;
}
onOpenScenario(e) {
const scenario = getScenario(e.detail?.scenarioId);
if (!scenario) return;
this.loadScenario(scenario);
this.showSession();
}
loadScenario(scenario) {
+16
View File
@@ -0,0 +1,16 @@
// Continuum 2026 — the real convention this build is first deployed for.
// Only AFTERIMAGE is ported into a scenario module today (scenarioId set); the
// other four slots are known slate metadata with scenarioId: null until they are
// ported (see the "scenario porting" backlog item). Slot start times are left
// null (TBC) rather than invented — add each slot's real `startsAt` (ISO local
// time) to light up the live / up-next timing on the hub.
export default {
meta: { id: 'continuum-2026', title: 'Continuum 2026' },
slots: [
{ id: 'c26-fri-1', scenarioId: null, title: 'Day One', slot: 'Fri · Slot 1', startsAt: null },
{ id: 'c26-fri-2', scenarioId: 'afterimage', title: 'AFTERIMAGE', slot: 'Fri · Slot 2', system: 'year-zero', players: 4, playMinutes: 210, startsAt: null },
{ id: 'c26-sat-4', scenarioId: null, title: 'Vain Crown', slot: 'Sat · Slot 4', startsAt: null },
{ id: 'c26-sun-6', scenarioId: null, title: 'Another Fine Mess', slot: 'Sun · Slot 6', system: 'coc-d100', startsAt: null },
{ id: 'c26-sun-7', scenarioId: null, title: "Prince's Bride", slot: 'Sun · Slot 7', system: 'dee-sanction', startsAt: null },
],
};
+13
View File
@@ -0,0 +1,13 @@
// TEMPLATE / EXAMPLE — not a real convention. Demonstrates the con-schedule
// model with fully-timed slots so live / up-next / done statuses are visible.
// A slot's `scenarioId` deep-links into a registered scenario module
// (src/scenarios/index.js); `slot` is the display label, `startsAt` (ISO) drives
// the live/next timing. Copy this shape into a real con file.
export default {
meta: { id: 'example-con', title: 'Example Convention' },
slots: [
{ id: 'ex-fri-1', scenarioId: 'afterimage', title: 'AFTERIMAGE', slot: 'Fri · Slot 1 · 10:00', system: 'year-zero', players: 4, playMinutes: 210, startsAt: '2026-08-14T10:00:00' },
{ id: 'ex-fri-2', scenarioId: 'example-clues', title: 'Example — Clue Net', slot: 'Fri · Slot 2 · 14:30', system: 'year-zero', players: 4, playMinutes: 210, startsAt: '2026-08-14T14:30:00' },
{ id: 'ex-sat-1', scenarioId: null, title: 'Unported Demo Slot', slot: 'Sat · Slot 1 · 10:00', playMinutes: 210, startsAt: '2026-08-15T10:00:00' },
],
};
+39
View File
@@ -0,0 +1,39 @@
// Pure convention-schedule analysis. Given slots and a wall-clock `nowMs`,
// classify each slot as done | live | next | upcoming and surface the live/up-next.
// Slots carry `startsAt` (ISO string) for timing logic; a null/absent start is
// treated as unscheduled (sorted last, never live/next) so the hub still lists it.
export function slotStartMs(slot) {
if (typeof slot.startMs === 'number') return slot.startMs;
if (slot.startsAt) {
const t = Date.parse(slot.startsAt);
if (!Number.isNaN(t)) return t;
}
return Infinity;
}
export function analyzeSchedule(slots = [], nowMs = 0) {
const enriched = slots
.map((s) => {
const startMs = slotStartMs(s);
const endMs = startMs === Infinity ? Infinity : startMs + (s.playMinutes ?? 0) * 60000;
return { ...s, startMs, endMs };
})
.sort((a, b) => a.startMs - b.startMs);
let nextAssigned = false;
const outSlots = enriched.map((s) => {
let status;
if (s.startMs === Infinity) status = 'upcoming';
else if (nowMs >= s.endMs) status = 'done';
else if (nowMs >= s.startMs) status = 'live';
else if (!nextAssigned) { status = 'next'; nextAssigned = true; }
else status = 'upcoming';
return { ...s, status };
});
return {
slots: outSlots,
liveNow: outSlots.find((s) => s.status === 'live') || null,
upNext: outSlots.find((s) => s.status === 'next') || null,
};
}
+2 -2
View File
@@ -1,13 +1,13 @@
// Boot entry.
import './components/gm-shell.js';
import afterimage from './scenarios/afterimage.js';
import continuum2026 from './con/continuum-2026.js';
export const APP = 'the-director';
if (typeof document !== 'undefined' && document.querySelector('gm-shell')) {
customElements.whenDefined('gm-shell').then(() => {
const shell = document.querySelector('gm-shell');
shell.loadScenario(afterimage);
shell.loadCon(continuum2026);
});
}
+13
View File
@@ -0,0 +1,13 @@
// Scenario registry — maps a scenario id to its module so the con hub can
// deep-link a slot into the shell. Register each new scenario module here.
import afterimage from './afterimage.js';
import exampleWithClues from './example-with-clues.js';
export const SCENARIOS = {
[afterimage.meta.id]: afterimage,
[exampleWithClues.meta.id]: exampleWithClues,
};
export function getScenario(id) {
return SCENARIOS[id] || null;
}
+29
View File
@@ -78,3 +78,32 @@ clue-net[hidden] { display: none; }
.clue-act { font-size: 10px; color: #7f8ea0; }
.clue-fallback { margin-top: 4px; font-size: 12px; color: #ff9c9c; }
.clue-empty { color: #6b7a8d; font-size: 12px; }
/* Slice 5 — convention hub */
con-hub[hidden] { display: none; }
director-rail[hidden] { display: none; }
.stage[hidden] { display: none; }
.hub { padding: 20px 16px 32px; max-width: 720px; margin: 0 auto; }
.hub--empty { color: #6b7a8d; font-size: 13px; padding: 24px 16px; }
.hub-head { margin-bottom: 16px; }
.hub-title { font-size: 22px; font-weight: 800; margin: 0 0 6px; letter-spacing: .01em; }
.hub-summary { font-size: 13px; font-weight: 700; }
.hub-summary .hub-live { color: #8ff0b6; }
.hub-summary .hub-next { color: #ffd479; }
.hub-summary .hub-idle { color: #7f8ea0; }
.hub-list { display: flex; flex-direction: column; gap: 10px; }
.hub-card { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid #263041;
border-radius: 12px; background: #12171f; }
.hub-card.status-live { border-color: #2f7d54; background: #12211a; }
.hub-card.status-next { border-color: #4a3a1f; }
.hub-card.status-done { opacity: .55; }
.hub-card-main { flex: 1; min-width: 0; }
.hub-card-title { font-size: 16px; font-weight: 700; color: #e7eef6; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.hub-card-meta { font-size: 12px; color: #8fa1b4; margin-top: 3px; }
.hub-badge { font-size: 9px; text-transform: uppercase; letter-spacing: .08em; border-radius: 12px; padding: 2px 8px;
background: #1b2431; color: #9fb0c2; border: 1px solid #2a3542; }
.hub-badge.badge-live { background: #17251c; color: #8ff0b6; border-color: #2f7d54; }
.hub-badge.badge-next { background: #2a2013; color: #ffd479; border-color: #4a3a1f; }
.hub-open { border: none; padding: 10px 20px; border-radius: 9px; background: #1c8a5a; color: #eafff4;
font-weight: 700; font-size: 14px; flex: 0 0 auto; }
.hub-soon { font-size: 11px; color: #6b7a8d; flex: 0 0 auto; }
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/con-hub.js';
const CON = {
meta: { id: 'demo', title: 'Demo Con' },
slots: [
{ id: 's1', scenarioId: 'a', title: 'Past Game', slot: 'Fri · S1', system: 'year-zero', players: 4, playMinutes: 60, startsAt: '2026-07-24T09:00:00Z' },
{ id: 's2', scenarioId: 'b', title: 'Now Game', slot: 'Fri · S2', system: 'coc-d100', players: 5, playMinutes: 60, startsAt: '2026-07-24T12:00:00Z' },
{ id: 's3', scenarioId: null, title: 'Unported', slot: 'Sat · S4', playMinutes: 60, startsAt: '2026-07-25T09:00:00Z' },
],
};
describe('<con-hub>', () => {
let el;
beforeEach(() => {
document.body.innerHTML = '';
el = document.createElement('con-hub');
el.now = () => Date.parse('2026-07-24T12:30:00Z');
document.body.appendChild(el);
});
it('shows an empty state with no con', () => {
expect(el.querySelector('[data-role=empty]')).not.toBe(null);
});
it('renders a card per slot with its status', () => {
el.con = CON;
expect(el.querySelectorAll('[data-slot-id]').length).toBe(3);
expect(el.querySelector('[data-slot-id=s1]').dataset.status).toBe('done');
expect(el.querySelector('[data-slot-id=s2]').dataset.status).toBe('live');
expect(el.querySelector('[data-slot-id=s3]').dataset.status).toBe('next');
});
it('summarises the live session', () => {
el.con = CON;
expect(el.querySelector('[data-role=summary]').textContent).toContain('Now Game');
});
it('emits open-scenario for a ported slot', () => {
el.con = CON;
let detail = null;
el.addEventListener('open-scenario', (e) => { detail = e.detail; });
el.querySelector('[data-slot-id=s2] [data-role=open]').click();
expect(detail).toEqual({ scenarioId: 'b' });
});
it('shows "not yet ported" and no open button for an unported slot', () => {
el.con = CON;
expect(el.querySelector('[data-slot-id=s3] [data-role=not-ready]')).not.toBe(null);
expect(el.querySelector('[data-slot-id=s3] [data-role=open]')).toBe(null);
});
it('escapes author text', () => {
el.con = { meta: { title: 'x' }, slots: [{ id: 'x', scenarioId: null, title: 'evil <b>', slot: 'a" <i>', playMinutes: 60 }] };
expect(el.querySelector('[data-slot-id=x]').innerHTML).not.toContain('<b>');
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach } from 'vitest';
import '../../src/components/gm-shell.js';
import continuum from '../../src/con/continuum-2026.js';
describe('<gm-shell> convention hub', () => {
let el;
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '';
el = document.createElement('gm-shell');
el.now = () => 0;
document.body.appendChild(el);
el.loadCon(continuum);
});
it('boots into the hub with the rail and stage hidden', () => {
expect(el.querySelector('con-hub').hidden).toBe(false);
expect(el.querySelector('director-rail').hidden).toBe(true);
expect(el.querySelector('.stage').hidden).toBe(true);
expect(el.querySelector('con-hub').con).toBe(continuum);
});
it('deep-links a ported slot into a session and hides the hub', () => {
el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'afterimage' }, bubbles: true }));
expect(el.scenario.meta.id).toBe('afterimage');
expect(el.querySelector('con-hub').hidden).toBe(true);
expect(el.querySelector('director-rail').hidden).toBe(false);
expect(el.querySelector('.stage').hidden).toBe(false);
expect(el.querySelector('director-rail [data-role=here]').textContent).toContain('The pier');
});
it('ignores open-scenario for an unknown / unported slot', () => {
el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'not-ported' }, bubbles: true }));
expect(el.scenario).toBe(null);
expect(el.querySelector('con-hub').hidden).toBe(false);
});
it('the rail hub chip emits open-hub, returning to the hub', () => {
el.dispatchEvent(new CustomEvent('open-scenario', { detail: { scenarioId: 'afterimage' }, bubbles: true }));
const rail = el.querySelector('director-rail');
let fired = 0;
el.addEventListener('open-hub', () => fired++);
rail.querySelector('[data-role=open-hub]').click();
expect(fired).toBe(1);
expect(el.querySelector('con-hub').hidden).toBe(false);
expect(rail.hidden).toBe(true);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { analyzeSchedule, slotStartMs } from '../../src/con/schedule.js';
const at = (iso) => Date.parse(iso);
const SLOTS = [
{ id: 's1', title: 'Morning', playMinutes: 60, startsAt: '2026-07-24T09:00:00Z' },
{ id: 's2', title: 'Midday', playMinutes: 60, startsAt: '2026-07-24T12:00:00Z' },
{ id: 's3', title: 'Afternoon', playMinutes: 60, startsAt: '2026-07-24T15:00:00Z' },
{ id: 's4', title: 'Untimed', playMinutes: 60 },
];
describe('analyzeSchedule', () => {
it('classifies done / live / next / upcoming around now', () => {
const r = analyzeSchedule(SLOTS, at('2026-07-24T12:30:00Z'));
const byId = Object.fromEntries(r.slots.map((s) => [s.id, s.status]));
expect(byId.s1).toBe('done'); // ended 10:00
expect(byId.s2).toBe('live'); // 12:00–13:00
expect(byId.s3).toBe('next'); // first future timed slot
expect(byId.s4).toBe('upcoming'); // untimed, never next
expect(r.liveNow.id).toBe('s2');
expect(r.upNext.id).toBe('s3');
});
it('before the con: nothing live, earliest is next', () => {
const r = analyzeSchedule(SLOTS, at('2026-07-24T06:00:00Z'));
expect(r.liveNow).toBe(null);
expect(r.upNext.id).toBe('s1');
expect(r.slots.find((s) => s.id === 's2').status).toBe('upcoming');
});
it('after the con: everything done except the untimed slot', () => {
const r = analyzeSchedule(SLOTS, at('2026-07-25T00:00:00Z'));
expect(r.liveNow).toBe(null);
expect(r.upNext).toBe(null);
expect(r.slots.find((s) => s.id === 's1').status).toBe('done');
expect(r.slots.find((s) => s.id === 's4').status).toBe('upcoming');
});
it('sorts slots by start time, untimed last', () => {
const r = analyzeSchedule(SLOTS, 0);
expect(r.slots.map((s) => s.id)).toEqual(['s1', 's2', 's3', 's4']);
});
it('treats an empty schedule as idle', () => {
const r = analyzeSchedule([], 0);
expect(r.slots).toEqual([]);
expect(r.liveNow).toBe(null);
expect(r.upNext).toBe(null);
});
it('slotStartMs falls back to Infinity when unscheduled', () => {
expect(slotStartMs({})).toBe(Infinity);
expect(slotStartMs({ startMs: 5 })).toBe(5);
expect(slotStartMs({ startsAt: '2026-07-24T09:00:00Z' })).toBe(at('2026-07-24T09:00:00Z'));
});
});
+14
View File
@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { SCENARIOS, getScenario } from '../../src/scenarios/index.js';
import afterimage from '../../src/scenarios/afterimage.js';
describe('scenario registry', () => {
it('resolves a registered scenario by id', () => {
expect(getScenario('afterimage')).toBe(afterimage);
expect(SCENARIOS.afterimage).toBe(afterimage);
});
it('returns null for an unknown id', () => {
expect(getScenario('nope')).toBe(null);
});
});