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:
co-authored by
Claude Opus 4.8
parent
a7a7870a76
commit
b57880d630
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user