Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions tests/lib/dashboard/dashboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { niceMax, bounds, lineChart, statusBar, sparkline } from './charts.mjs';
import { isUnifiedReport, runIdFor, summarize, mediaCount, safeId } from './ingest.mjs';
import { reportMetrics, scaleSweepMetrics, largeGraphMetrics, physicsMetrics, vlmMetrics, pointKey } from './metrics.mjs';
import { mergeRuns, mergeMetrics, liveMetrics, readJsonl, appendJsonl, snapshotRun, pruneSnapshots, pickLatest } from './history.mjs';
import { buildNarration, cleanTitle, plural, spokenUrl } from './narrate.mjs';

// ── format ────────────────────────────────────────────────────────────────
test('rollupStatus: worst-of precedence', () => {
Expand Down Expand Up @@ -263,3 +264,45 @@ test('snapshotRun flags truncation above maxBytes', () => {
assert.ok(existsSync(join(res.dest, 'report.json')));
assert.ok(!existsSync(join(res.dest, 'assets')));
});

// ── narration ───────────────────────────────────────────────────────────────
test('cleanTitle strips conventional-commit prefix + PR tail + backticks', () => {
assert.equal(cleanTitle('fix(graph): unique test id per mount (#139)'), 'unique test id per mount');
assert.equal(cleanTitle('feat(test): live dashboard `npm run dashboard`'), 'live dashboard npm run dashboard');
assert.equal(cleanTitle(''), '');
});
test('plural + spokenUrl helpers', () => {
assert.equal(plural(1, 'check'), '1 check');
assert.equal(plural(5, 'check'), '5 checks');
assert.equal(spokenUrl('https://graphdone-cloud.pages.dev/'), 'graphdone-cloud.pages.dev');
assert.equal(spokenUrl(''), 'the application');
});
test('buildNarration assembles ordered segments with media refs', () => {
const n = buildNarration({
dateISO: '2026-06-19T00:00:00Z',
voiceName: 'en_US-lessac-high',
health: { cases: 181, sequences: 26, passed: 164, failed: 0, warned: 6, passRate: 90.6, target: 'https://graphdone-cloud.pages.dev' },
shipped: [{ title: 'fix(graph): unique test id (#139)' }, { title: 'feat(test): live dashboard (#135)' }],
perf: [{ metric: 'graph.idleFps', label: 'idle frame rate', value: 58, unit: 'fps', better: 'higher' }],
tour: [{ name: 'graph-overview', note: 'Graph loads and settles · 1200x760', kind: 'video', runId: 'live-full-report/x', href: 'assets/tour-graph-overview.mp4' }],
});
const ids = n.segments.map((s) => s.id);
assert.ok(ids.includes('intro') && ids.includes('health') && ids.includes('shipped') && ids.includes('performance') && ids.includes('outro'));
assert.ok(ids.some((i) => i.startsWith('tour-')));
assert.equal(n.voice, 'en_US-lessac-high');
const health = n.segments.find((s) => s.id === 'health');
assert.match(health.text, /181 checks/);
assert.match(health.text, /90\.6 percent/);
assert.equal(health.media.kind, 'chart');
const tour = n.segments.find((s) => s.id === 'tour-graph-overview');
assert.equal(tour.media.kind, 'video');
assert.equal(tour.media.href, 'assets/tour-graph-overview.mp4');
// every segment has non-empty spoken text
assert.ok(n.segments.every((s) => s.text.length > 0));
});
test('buildNarration tolerates empty data (intro + outro only)', () => {
const n = buildNarration({});
assert.ok(n.segments.length >= 2);
assert.equal(n.segments[0].id, 'intro');
assert.equal(n.segments[n.segments.length - 1].id, 'outro');
});
109 changes: 109 additions & 0 deletions tests/lib/dashboard/narrate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Pure narration-script builder: turns real report/git/metric data into ordered
* spoken segments (each with the on-screen media to show while it plays). No I/O,
* no piper — the driver (scripts/narrate-report.mjs) renders these to audio.
* Segment.media tells the dashboard what to display: a trend chart, a video, an
* image, or nothing.
*/
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

export function spokenDate(iso) {
const d = iso ? new Date(iso) : null;
if (!d || isNaN(d.getTime())) return 'today';
return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}

export function plural(n, word) {
return `${n} ${word}${n === 1 ? '' : 's'}`;
}

export function spokenUrl(url) {
if (!url) return 'the application';
return String(url).replace(/^https?:\/\//, '').replace(/\/$/, '');
}

const UNIT_WORD = { fps: 'frames per second', ms: 'milliseconds', s: 'seconds', px: 'pixels', '%': 'percent', count: '', kb: 'kilobytes', score: 'out of one' };

// Strip a conventional-commit prefix + PR number tail → a spoken-readable clause.
export function cleanTitle(t) {
return String(t || '')
.replace(/^[a-z]+(\([^)]*\))?:\s*/i, '')
.replace(/\s*\(#\d+\)\s*$/, '')
.replace(/`/g, '')
.trim();
}

function spokenMetric(m) {
const unit = UNIT_WORD[m.unit] ?? m.unit ?? '';
const v = Math.round(m.value * 10) / 10;
return `${m.label} ${v}${unit ? ' ' + unit : ''}`.trim();
}

/**
* input: {
* dateISO, voiceName,
* health: { cases, sequences, passed, failed, warned, passRate, target },
* shipped: [{ title }],
* perf: [{ label, value, unit, better }],
* tour: [{ name, note, kind, runId, href }], // kind: 'video'|'image'
* }
* returns { generatedAt: null, voice, segments: [{ id, title, text, media }], meta }
*/
export function buildNarration(input = {}) {
const { dateISO = '', voiceName = '', health = {}, shipped = [], perf = [], tour = [] } = input;
const segments = [];
const push = (id, title, text, media = { kind: 'none' }) => {
const t = String(text || '').replace(/\s+/g, ' ').trim();
if (t) segments.push({ id, title, text: t, media });
};

push('intro', 'Introduction',
`GraphDone progress report for ${spokenDate(dateISO)}. This is an automated, narrated walkthrough of the system's current health, the work we've shipped recently, and a tour of what a user can do.`,
{ kind: 'chart', ref: 'suite.passRate' });

if (health.cases) {
const warnPart = health.warned ? `, alongside ${plural(health.warned, 'warning')} that are accepted by design` : '';
push('health', 'Test health',
`The unified test suite is reporting ${plural(health.cases, 'check')} across ${plural(health.sequences, 'area')}. ${health.passRate} percent are passing, with ${plural(health.failed, 'failure')}${warnPart}. The target under test is ${spokenUrl(health.target)}. Test health is tracked over time, so every run either confirms progress or surfaces a regression immediately.`,
{ kind: 'chart', ref: 'suite.passRate' });
}

const ships = shipped.map((s) => cleanTitle(s.title)).filter(Boolean).slice(0, 7);
if (ships.length) {
push('shipped', 'Recently shipped',
`Here is what we've shipped recently. ${ships.map((t) => t.replace(/\.$/, '')).join('. ')}. Each of these landed only after passing the smoke gate that proves the application works from a real user's point of view.`,
{ kind: 'chart', ref: 'suite.cases' });
}

const perfReal = perf.filter((p) => p && isFinite(p.value));
if (perfReal.length) {
push('performance', 'Performance',
`On performance: ${perfReal.slice(0, 5).map(spokenMetric).join(', ')}. These numbers are charted across runs, so we can see at a glance whether the system is getting faster or slower as it grows.`,
{ kind: 'chart', ref: perfReal.find((p) => p.unit === 'fps')?.metric || perfReal[0].metric });
}

const clips = tour.slice(0, 8);
if (clips.length) {
push('tour-intro', 'Feature tour',
`Now, a short tour of the live experience. Each clip you'll see was recorded against the running site.`,
clips[0] ? { kind: clips[0].kind, runId: clips[0].runId, href: clips[0].href } : { kind: 'none' });
for (const c of clips) {
const name = cleanTitle(c.name).replace(/-/g, ' ');
const note = String(c.note || '').split('·')[0].trim();
push(`tour-${c.name}`, name,
`${name}. ${note}`,
{ kind: c.kind || 'video', runId: c.runId, href: c.href });
}
}

push('outro', 'Summary',
`That's the current state of GraphDone. This report is generated on demand and narrated by Piper text to speech. The dashboard updates itself live as new test runs land, so this narration always reflects the latest verified progress.`,
{ kind: 'chart', ref: 'suite.passRate' });

return {
generatedAt: null,
voice: voiceName,
segments,
meta: { shippedCount: ships.length, perfCount: perfReal.length, tourCount: clips.length, tourTruncated: tour.length > clips.length },
};
}
Loading