render.test.ts
Language TypeScript14.0 KB367 lines
/*** These tests guard the two rules the whole dashboard rests on. Both are the* kind that fail silently and look fine: the page renders, the numbers are* plausible, and it is lying.** 1. A number nobody can see renders as "not tracked", never as 0.* 2. With no earlier run there is no delta, not an arrow pointing at zero.** Everything else here is a smoke test: all six surfaces present, no unescaped* data, and a dead source rendered as dead rather than as empty.*/import { describe, expect, it } from 'vitest'import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'import { headline, type Snapshot } from '../src/collect.js'import { render } from '../src/render.js'import { delta, previousRun, rowFrom, type HistoryRow } from '../src/history.js'import { buildViewModel } from '../src/view-model.js'import type { Briefings } from '../src/briefings.js'import { viewerFor } from '../src/viewer.js'const build = (snap: Snapshot = FIXTURE_SNAPSHOT, history: HistoryRow[] = []) =>render(snap, headline(snap), history, '2026-08-12')describe('untracked is never zero', () => {it('renders "not tracked" for people no source covers', () => {const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12')// Ines and Theo hold no Plinth assignment, so their order count is absent.for (const p of vm.people) {if (p.plinthOurMove === null) {expect(p.detail).toContain('orders not tracked')expect(p.detail).not.toMatch(/\b0 orders\b/)}}})it('never emits a bare 0 where a person has no covering source at all', () => {const orphan: Snapshot = {...FIXTURE_SNAPSHOT,tasks: { ok: true, data: [], error: null },orderLines: { ok: true, data: [], error: null },}const vm = buildViewModel(orphan, headline(orphan), [], '2026-08-12')const untracked = vm.people.filter((p) => p.untracked)for (const p of untracked) expect(p.load).toBeNull()})})describe('the page orients a first-time reader', () => {// The board must be readable by someone who does not already know what they// are looking at. These pin the orientation aids so a refactor cannot// quietly drop them.const out = build()it('has a skip link and a main landmark for keyboard and screen-reader users', () => {expect(out).toContain('class="skip-link" href="#main"')expect(out).toContain('<main id="main">')})it('explains the color scheme on the board tab', () => {// Colors here are identity, not severity, and the page says so.expect(out).toContain('How to read this board')expect(out).toMatch(/our move: work we owe a step on/)expect(out).toMatch(/not tracked: no source covers it/)})it('walks the tablist with arrow keys', () => {expect(out).toContain("e.key === 'ArrowRight'")})it('keeps the tab bar sticky and motion optional', () => {expect(out).toMatch(/\.tabs\{[^}]*position:sticky/)expect(out).toContain('@media (prefers-reduced-motion:reduce)')})})describe('dark theme survives every header', () => {it('never sets text color from the raw ink token', () => {// --meridian-ink is a raw hex that deliberately does not flip on dark; it is// for things that stay ink in both themes. Used as a text color it renders// black-on-dark; text reads --text-body, always.expect(build()).not.toContain('color:var(--meridian-ink)')})})describe('deltas', () => {it('is null when there is no earlier run', () => {expect(previousRun([], '2026-08-12')).toBeNull()expect(delta(37, null, 'designQueue')).toBeNull()})it('ignores runs from the same day, so a rebuild is not "yesterday"', () => {const today = rowFrom(headline(FIXTURE_SNAPSHOT), new Date('2026-08-12T11:00:00Z'))expect(previousRun([today], today.day)).toBeNull()})it('reports direction from the number, not from whether it is good news', () => {const prev: HistoryRow = {day: '2026-08-11',at: '2026-08-11T11:00:00Z',ourMove: 150,designQueue: 31,waitingOnClient: 42,atVendor: 88,needsReply: 1,openLeads: 45,}expect(delta(146, prev, 'ourMove')).toEqual({ value: -4, direction: 'down', since: '2026-08-11' })expect(delta(37, prev, 'designQueue')).toEqual({ value: 6, direction: 'up', since: '2026-08-11' })expect(delta(1, prev, 'needsReply')?.direction).toBe('flat')})it('says so on the page when no comparison exists', () => {expect(build()).toContain('no prior run')})})describe('a failed source renders as failed, not as zero', () => {const dead: Snapshot = {...FIXTURE_SNAPSHOT,orderLines: { ok: false, data: [], error: 'Plinth 503' },}it('names the failure', () => {const html = build(dead)expect(html).toContain('Plinth 503')expect(html).toContain('missing from this page, not zero')})it('marks the source down rather than live', () => {const vm = buildViewModel(dead, headline(dead), [], '2026-08-12')expect(vm.sources.find((s) => s.name === 'Plinth')?.tone).toBe('failed')})it('does not render an empty chart as if it were data', () => {expect(build(dead)).toContain('Plinth did not answer')})/*** The one that matters most. A failure banner above a confident 0 in 38px type* loses to the 0: someone scanning on a phone reads the number, not the* banner. Every tile fed by a dead source must be a dash.*/it('shows a dash, never a zero, on tiles whose source is down', () => {const html = build(dead)const tiles = html.match(/<div class="tile-value"[^>]*>[^<]*<\/div>/g) ?? []expect(tiles.length).toBeGreaterThan(0)// Plinth is down. Of the four headline tiles, only "Client replied" is fed by// leads alone, so the other three show a dash, and the context strip's// Plinth-fed numerals dash out too rather than showing a zero.expect(tiles.filter((t) => t.includes('–')).length).toBeGreaterThanOrEqual(3)expect(html).toMatch(/ctx-cell[\s\S]*?<span class="n">–<\/span>/)expect(html).toContain('source unavailable, not zero')})it('the prose briefing leads with the outage rather than with a zero', () => {const allDead: Snapshot = {...dead,leads: { ok: false, data: [], error: 'Bitrix 500' },}const html = build(allDead)expect(html).toContain('This morning’s counts are unavailable')expect(html).not.toContain('0 things need a move from Meridian today')})it('a broken run contributes no delta at all', () => {const prev: HistoryRow = {day: '2026-08-11',at: '2026-08-11T11:00:00Z',ourMove: 146,designQueue: 37,waitingOnClient: 42,atVendor: 88,needsReply: 1,openLeads: 45,}// main.ts passes [] when a source failed; assert the render agrees that a// 146-to-0 swing is the outage and not news.const html = render(dead, headline(dead), [], '2026-08-12')expect(html).not.toContain(`vs ${prev.day}`)})})describe('the page itself', () => {const html = build()it('carries every surface, one per tab', () => {// There is no separate 'briefing-data' tab: the Briefing tab carries a// prose/numbers segmented control rather than the same facts told twice.for (const id of ['board', 'my-day', 'queue', 'briefing', 'elsewhere', 'health']) {expect(html).toContain(`id="surface-${id}"`)}})it('anchors user notes on queue rows and on My Day orders and tasks', () => {// Shared notes attach to orders and tasks. The page carries only the anchors (the notes themselves are injected by the// Worker at serve time), so what must hold here is that every order row and// every Bitrix-task row is addressable. Todos and tracker rows are not.expect(html).toMatch(/data-note-key="order:MD26\d+"/)const myDay = build(FIXTURE_SNAPSHOT).slice(0)const perViewer = render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12', null, viewerFor('hello@meridiandisplay.example'))expect(perViewer).toMatch(/data-note-key="task:[A-Za-z0-9_-]+"/)expect(myDay).not.toContain('data-note-key="todo:')})it('is self-contained: no external stylesheet, script or image', () => {expect(html).not.toMatch(/<link[^>]+stylesheet/i)expect(html).not.toMatch(/<script[^>]+src=/i)expect(html).not.toMatch(/<img\b/i)})it('escapes data rather than trusting it', () => {const nasty: Snapshot = {...FIXTURE_SNAPSHOT,leads: {ok: true,data: [{id: 'x',title: '<script>alert(1)</script>',company: null,statusId: 'UC_Y4AE5V',createdAt: new Date().toISOString(),updatedAt: new Date().toISOString(),assignedById: '1',amount: 0,noise: false,noiseReason: null,},],error: null,},}const out = build(nasty)expect(out).not.toContain('<script>alert(1)</script>')expect(out).toContain('<script>alert(1)</script>')})it('states the rule that keeps it honest', () => {expect(html).toContain('Only “our move” is workload')})})/*** The written briefing card gates on the `company` key with the leadership view* nested inside it, so a set of briefings without `company` renders as "Not* generated for this sync". And a brief cut off at the output limit must be* labelled, not presented as finished.** Both are the same failure in different clothes: broken looking like fine.*/describe('the written briefing', () => {const snap = FIXTURE_SNAPSHOTconst brief = (over: Partial<Briefings> = {}): Briefings => ({generatedAt: '2026-08-25T18:40:14+00:00',day: '2026-08-12',model: 'claude-opus-5',payloadGeneratedAt: snap.syncedAt,mode: 'live',briefings: {company: 'The company brief.','company-allhands': 'The all-hands brief.',exec: 'The leadership brief.',},...over,})// Nora is the only exec reader; Sam sees Leadership; Marco sees neither.const nora = viewerFor('nora.l@meridiandisplay.example')const sam = viewerFor('hello@meridiandisplay.example')const page = (b: Briefings | null, v = nora) =>render(snap, headline(snap), [], '2026-08-12', b, v)it('renders the company brief and the leadership view together', () => {const out = page(brief())expect(out).toContain('The company brief.')expect(out).toContain('Leadership view')expect(out).toContain('The leadership brief.')expect(out).not.toContain('Not generated for this sync')})it('says the briefing is absent rather than rendering a blank card', () => {const out = page(null)expect(out).toContain('Not generated for this sync')})it('labels a truncated briefing instead of presenting it as finished', () => {const cut = brief({briefings: { company: 'Half a sen', exec: 'The leadership brief.' },truncated: ['company'],})const out = page(cut, sam)expect(out).toContain('Half a sen')expect(out).toMatch(/cut off/i)})it('labels a truncated leadership view specifically', () => {const out = page(brief({ truncated: ['exec'] }))expect(out).toMatch(/cut off/i)})it('does not label a briefing that finished', () => {expect(page(brief())).not.toMatch(/cut off/i)})it('treats an absent truncated list as nothing known to be truncated', () => {// A briefings.json written before the field existed must not read as// "everything is fine" OR as "everything is broken"; it reads as no claim.const legacy = brief()delete legacy.truncatedexpect(page(legacy)).not.toMatch(/cut off/i)})/*** The pairing caveat has two modes, because the board re-syncs every 15* minutes while the prose is a once-a-morning job.** Prose from earlier today is the normal state and asks for nothing. Prose* from an earlier day means the morning brief did not run, and that one asks* for a re-run. These assert which mode fired by whether the page asks the* reader to do something, not by the sentence: tests assert behaviour rather* than wording.*/// Fixed rather than `new Date()`: the same-day and earlier-day branches are// decided by America/Chicago calendar day, so a relative timestamp would make// this test's meaning depend on the hour it ran.const atNoon = { ...snap, syncedAt: '2026-09-02T18:00:00.000Z' }const pageAt = (b: Briefings) => render(atNoon, headline(atNoon), [], '2026-09-02', b, nora)it('does not ask for a re-run when the prose is from earlier the same day', () => {// 06:02 Chicago prose against 13:00 Chicago numbers. Expected, by design:// a warning that fires every quarter hour is one nobody reads.const morning = brief({ payloadGeneratedAt: '2026-09-02T11:02:00.000Z' })const out = pageAt(morning)expect(out).not.toMatch(/pnpm morning/)expect(out).toMatch(/refresh every 15 minutes/)})it('asks for a re-run when the prose is from an earlier day', () => {const yesterday = brief({ payloadGeneratedAt: '2026-09-01T11:02:00.000Z' })expect(pageAt(yesterday)).toMatch(/pnpm morning/)})it('says nothing at all when the prose and the numbers are the same sync', () => {expect(pageAt(brief({ payloadGeneratedAt: atNoon.syncedAt }))).not.toMatch(/refresh every 15 minutes|pnpm morning/,)})it('shows both caveats when a briefing is stale AND truncated', () => {// They are different problems and a reader needs to know which applies.const both = brief({payloadGeneratedAt: '2026-09-01T11:02:00.000Z',truncated: ['company'],})const out = pageAt(both)expect(out).toMatch(/pnpm morning/)expect(out).toMatch(/cut off/i)})it('escapes a briefing rather than trusting the model not to emit markup', () => {const nasty = brief({ briefings: { company: '<img src=x onerror=alert(1)>' } })const out = page(nasty)expect(out).not.toContain('<img src=x')expect(out).toContain('<img src=x')})})