From 8e953398fd8a8fb8f2cb35c6a0ea2d1d565ab3d0 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Fri, 19 Jun 2026 19:44:36 -0700 Subject: [PATCH] feat(dashboard): pure narration-script builder + unit tests Add narrate.mjs: a pure, I/O-free builder that turns report/git/metric data into ordered spoken segments (each tagged with the on-screen media to show). Covered by 39 passing node --test cases (helpers + full narration assembly + empty-data tolerance). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/lib/dashboard/dashboard.test.mjs | 43 ++++++++++ tests/lib/dashboard/narrate.mjs | 109 +++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/lib/dashboard/narrate.mjs diff --git a/tests/lib/dashboard/dashboard.test.mjs b/tests/lib/dashboard/dashboard.test.mjs index 9b573baa..7c352929 100644 --- a/tests/lib/dashboard/dashboard.test.mjs +++ b/tests/lib/dashboard/dashboard.test.mjs @@ -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', () => { @@ -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'); +}); diff --git a/tests/lib/dashboard/narrate.mjs b/tests/lib/dashboard/narrate.mjs new file mode 100644 index 00000000..5a263e07 --- /dev/null +++ b/tests/lib/dashboard/narrate.mjs @@ -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 }, + }; +}