mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

render.test.ts

Language TypeScript14.0 KB367 lines

apps/dashboard/test/render.test.ts

  1. /**
  2. * These tests guard the two rules the whole dashboard rests on. Both are the
  3. * kind that fail silently and look fine: the page renders, the numbers are
  4. * plausible, and it is lying.
  5. *
  6. * 1. A number nobody can see renders as "not tracked", never as 0.
  7. * 2. With no earlier run there is no delta, not an arrow pointing at zero.
  8. *
  9. * Everything else here is a smoke test: all six surfaces present, no unescaped
  10. * data, and a dead source rendered as dead rather than as empty.
  11. */
  12. import { describe, expect, it } from 'vitest'
  13. import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'
  14. import { headline, type Snapshot } from '../src/collect.js'
  15. import { render } from '../src/render.js'
  16. import { delta, previousRun, rowFrom, type HistoryRow } from '../src/history.js'
  17. import { buildViewModel } from '../src/view-model.js'
  18. import type { Briefings } from '../src/briefings.js'
  19. import { viewerFor } from '../src/viewer.js'
  20. const build = (snap: Snapshot = FIXTURE_SNAPSHOT, history: HistoryRow[] = []) =>
  21. render(snap, headline(snap), history, '2026-08-12')
  22. describe('untracked is never zero', () => {
  23. it('renders "not tracked" for people no source covers', () => {
  24. const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12')
  25. // Ines and Theo hold no Plinth assignment, so their order count is absent.
  26. for (const p of vm.people) {
  27. if (p.plinthOurMove === null) {
  28. expect(p.detail).toContain('orders not tracked')
  29. expect(p.detail).not.toMatch(/\b0 orders\b/)
  30. }
  31. }
  32. })
  33. it('never emits a bare 0 where a person has no covering source at all', () => {
  34. const orphan: Snapshot = {
  35. ...FIXTURE_SNAPSHOT,
  36. tasks: { ok: true, data: [], error: null },
  37. orderLines: { ok: true, data: [], error: null },
  38. }
  39. const vm = buildViewModel(orphan, headline(orphan), [], '2026-08-12')
  40. const untracked = vm.people.filter((p) => p.untracked)
  41. for (const p of untracked) expect(p.load).toBeNull()
  42. })
  43. })
  44. describe('the page orients a first-time reader', () => {
  45. // The board must be readable by someone who does not already know what they
  46. // are looking at. These pin the orientation aids so a refactor cannot
  47. // quietly drop them.
  48. const out = build()
  49. it('has a skip link and a main landmark for keyboard and screen-reader users', () => {
  50. expect(out).toContain('class="skip-link" href="#main"')
  51. expect(out).toContain('<main id="main">')
  52. })
  53. it('explains the color scheme on the board tab', () => {
  54. // Colors here are identity, not severity, and the page says so.
  55. expect(out).toContain('How to read this board')
  56. expect(out).toMatch(/our move: work we owe a step on/)
  57. expect(out).toMatch(/not tracked: no source covers it/)
  58. })
  59. it('walks the tablist with arrow keys', () => {
  60. expect(out).toContain("e.key === 'ArrowRight'")
  61. })
  62. it('keeps the tab bar sticky and motion optional', () => {
  63. expect(out).toMatch(/\.tabs\{[^}]*position:sticky/)
  64. expect(out).toContain('@media (prefers-reduced-motion:reduce)')
  65. })
  66. })
  67. describe('dark theme survives every header', () => {
  68. it('never sets text color from the raw ink token', () => {
  69. // --meridian-ink is a raw hex that deliberately does not flip on dark; it is
  70. // for things that stay ink in both themes. Used as a text color it renders
  71. // black-on-dark; text reads --text-body, always.
  72. expect(build()).not.toContain('color:var(--meridian-ink)')
  73. })
  74. })
  75. describe('deltas', () => {
  76. it('is null when there is no earlier run', () => {
  77. expect(previousRun([], '2026-08-12')).toBeNull()
  78. expect(delta(37, null, 'designQueue')).toBeNull()
  79. })
  80. it('ignores runs from the same day, so a rebuild is not "yesterday"', () => {
  81. const today = rowFrom(headline(FIXTURE_SNAPSHOT), new Date('2026-08-12T11:00:00Z'))
  82. expect(previousRun([today], today.day)).toBeNull()
  83. })
  84. it('reports direction from the number, not from whether it is good news', () => {
  85. const prev: HistoryRow = {
  86. day: '2026-08-11',
  87. at: '2026-08-11T11:00:00Z',
  88. ourMove: 150,
  89. designQueue: 31,
  90. waitingOnClient: 42,
  91. atVendor: 88,
  92. needsReply: 1,
  93. openLeads: 45,
  94. }
  95. expect(delta(146, prev, 'ourMove')).toEqual({ value: -4, direction: 'down', since: '2026-08-11' })
  96. expect(delta(37, prev, 'designQueue')).toEqual({ value: 6, direction: 'up', since: '2026-08-11' })
  97. expect(delta(1, prev, 'needsReply')?.direction).toBe('flat')
  98. })
  99. it('says so on the page when no comparison exists', () => {
  100. expect(build()).toContain('no prior run')
  101. })
  102. })
  103. describe('a failed source renders as failed, not as zero', () => {
  104. const dead: Snapshot = {
  105. ...FIXTURE_SNAPSHOT,
  106. orderLines: { ok: false, data: [], error: 'Plinth 503' },
  107. }
  108. it('names the failure', () => {
  109. const html = build(dead)
  110. expect(html).toContain('Plinth 503')
  111. expect(html).toContain('missing from this page, not zero')
  112. })
  113. it('marks the source down rather than live', () => {
  114. const vm = buildViewModel(dead, headline(dead), [], '2026-08-12')
  115. expect(vm.sources.find((s) => s.name === 'Plinth')?.tone).toBe('failed')
  116. })
  117. it('does not render an empty chart as if it were data', () => {
  118. expect(build(dead)).toContain('Plinth did not answer')
  119. })
  120. /**
  121. * The one that matters most. A failure banner above a confident 0 in 38px type
  122. * loses to the 0: someone scanning on a phone reads the number, not the
  123. * banner. Every tile fed by a dead source must be a dash.
  124. */
  125. it('shows a dash, never a zero, on tiles whose source is down', () => {
  126. const html = build(dead)
  127. const tiles = html.match(/<div class="tile-value"[^>]*>[^<]*<\/div>/g) ?? []
  128. expect(tiles.length).toBeGreaterThan(0)
  129. // Plinth is down. Of the four headline tiles, only "Client replied" is fed by
  130. // leads alone, so the other three show a dash, and the context strip's
  131. // Plinth-fed numerals dash out too rather than showing a zero.
  132. expect(tiles.filter((t) => t.includes('&ndash;')).length).toBeGreaterThanOrEqual(3)
  133. expect(html).toMatch(/ctx-cell[\s\S]*?<span class="n">–<\/span>/)
  134. expect(html).toContain('source unavailable, not zero')
  135. })
  136. it('the prose briefing leads with the outage rather than with a zero', () => {
  137. const allDead: Snapshot = {
  138. ...dead,
  139. leads: { ok: false, data: [], error: 'Bitrix 500' },
  140. }
  141. const html = build(allDead)
  142. expect(html).toContain('This morning&rsquo;s counts are unavailable')
  143. expect(html).not.toContain('0 things need a move from Meridian today')
  144. })
  145. it('a broken run contributes no delta at all', () => {
  146. const prev: HistoryRow = {
  147. day: '2026-08-11',
  148. at: '2026-08-11T11:00:00Z',
  149. ourMove: 146,
  150. designQueue: 37,
  151. waitingOnClient: 42,
  152. atVendor: 88,
  153. needsReply: 1,
  154. openLeads: 45,
  155. }
  156. // main.ts passes [] when a source failed; assert the render agrees that a
  157. // 146-to-0 swing is the outage and not news.
  158. const html = render(dead, headline(dead), [], '2026-08-12')
  159. expect(html).not.toContain(`vs ${prev.day}`)
  160. })
  161. })
  162. describe('the page itself', () => {
  163. const html = build()
  164. it('carries every surface, one per tab', () => {
  165. // There is no separate 'briefing-data' tab: the Briefing tab carries a
  166. // prose/numbers segmented control rather than the same facts told twice.
  167. for (const id of ['board', 'my-day', 'queue', 'briefing', 'elsewhere', 'health']) {
  168. expect(html).toContain(`id="surface-${id}"`)
  169. }
  170. })
  171. it('anchors user notes on queue rows and on My Day orders and tasks', () => {
  172. // Shared notes attach to orders and tasks. The page carries only the anchors (the notes themselves are injected by the
  173. // Worker at serve time), so what must hold here is that every order row and
  174. // every Bitrix-task row is addressable. Todos and tracker rows are not.
  175. expect(html).toMatch(/data-note-key="order:MD26\d+"/)
  176. const myDay = build(FIXTURE_SNAPSHOT).slice(0)
  177. const perViewer = render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12', null, viewerFor('hello@meridiandisplay.example'))
  178. expect(perViewer).toMatch(/data-note-key="task:[A-Za-z0-9_-]+"/)
  179. expect(myDay).not.toContain('data-note-key="todo:')
  180. })
  181. it('is self-contained: no external stylesheet, script or image', () => {
  182. expect(html).not.toMatch(/<link[^>]+stylesheet/i)
  183. expect(html).not.toMatch(/<script[^>]+src=/i)
  184. expect(html).not.toMatch(/<img\b/i)
  185. })
  186. it('escapes data rather than trusting it', () => {
  187. const nasty: Snapshot = {
  188. ...FIXTURE_SNAPSHOT,
  189. leads: {
  190. ok: true,
  191. data: [
  192. {
  193. id: 'x',
  194. title: '<script>alert(1)</script>',
  195. company: null,
  196. statusId: 'UC_Y4AE5V',
  197. createdAt: new Date().toISOString(),
  198. updatedAt: new Date().toISOString(),
  199. assignedById: '1',
  200. amount: 0,
  201. noise: false,
  202. noiseReason: null,
  203. },
  204. ],
  205. error: null,
  206. },
  207. }
  208. const out = build(nasty)
  209. expect(out).not.toContain('<script>alert(1)</script>')
  210. expect(out).toContain('&lt;script&gt;alert(1)&lt;/script&gt;')
  211. })
  212. it('states the rule that keeps it honest', () => {
  213. expect(html).toContain('Only &ldquo;our move&rdquo; is workload')
  214. })
  215. })
  216. /**
  217. * The written briefing card gates on the `company` key with the leadership view
  218. * nested inside it, so a set of briefings without `company` renders as "Not
  219. * generated for this sync". And a brief cut off at the output limit must be
  220. * labelled, not presented as finished.
  221. *
  222. * Both are the same failure in different clothes: broken looking like fine.
  223. */
  224. describe('the written briefing', () => {
  225. const snap = FIXTURE_SNAPSHOT
  226. const brief = (over: Partial<Briefings> = {}): Briefings => ({
  227. generatedAt: '2026-08-25T18:40:14+00:00',
  228. day: '2026-08-12',
  229. model: 'claude-opus-5',
  230. payloadGeneratedAt: snap.syncedAt,
  231. mode: 'live',
  232. briefings: {
  233. company: 'The company brief.',
  234. 'company-allhands': 'The all-hands brief.',
  235. exec: 'The leadership brief.',
  236. },
  237. ...over,
  238. })
  239. // Nora is the only exec reader; Sam sees Leadership; Marco sees neither.
  240. const nora = viewerFor('nora.l@meridiandisplay.example')
  241. const sam = viewerFor('hello@meridiandisplay.example')
  242. const page = (b: Briefings | null, v = nora) =>
  243. render(snap, headline(snap), [], '2026-08-12', b, v)
  244. it('renders the company brief and the leadership view together', () => {
  245. const out = page(brief())
  246. expect(out).toContain('The company brief.')
  247. expect(out).toContain('Leadership view')
  248. expect(out).toContain('The leadership brief.')
  249. expect(out).not.toContain('Not generated for this sync')
  250. })
  251. it('says the briefing is absent rather than rendering a blank card', () => {
  252. const out = page(null)
  253. expect(out).toContain('Not generated for this sync')
  254. })
  255. it('labels a truncated briefing instead of presenting it as finished', () => {
  256. const cut = brief({
  257. briefings: { company: 'Half a sen', exec: 'The leadership brief.' },
  258. truncated: ['company'],
  259. })
  260. const out = page(cut, sam)
  261. expect(out).toContain('Half a sen')
  262. expect(out).toMatch(/cut off/i)
  263. })
  264. it('labels a truncated leadership view specifically', () => {
  265. const out = page(brief({ truncated: ['exec'] }))
  266. expect(out).toMatch(/cut off/i)
  267. })
  268. it('does not label a briefing that finished', () => {
  269. expect(page(brief())).not.toMatch(/cut off/i)
  270. })
  271. it('treats an absent truncated list as nothing known to be truncated', () => {
  272. // A briefings.json written before the field existed must not read as
  273. // "everything is fine" OR as "everything is broken"; it reads as no claim.
  274. const legacy = brief()
  275. delete legacy.truncated
  276. expect(page(legacy)).not.toMatch(/cut off/i)
  277. })
  278. /**
  279. * The pairing caveat has two modes, because the board re-syncs every 15
  280. * minutes while the prose is a once-a-morning job.
  281. *
  282. * Prose from earlier today is the normal state and asks for nothing. Prose
  283. * from an earlier day means the morning brief did not run, and that one asks
  284. * for a re-run. These assert which mode fired by whether the page asks the
  285. * reader to do something, not by the sentence: tests assert behaviour rather
  286. * than wording.
  287. */
  288. // Fixed rather than `new Date()`: the same-day and earlier-day branches are
  289. // decided by America/Chicago calendar day, so a relative timestamp would make
  290. // this test's meaning depend on the hour it ran.
  291. const atNoon = { ...snap, syncedAt: '2026-09-02T18:00:00.000Z' }
  292. const pageAt = (b: Briefings) => render(atNoon, headline(atNoon), [], '2026-09-02', b, nora)
  293. it('does not ask for a re-run when the prose is from earlier the same day', () => {
  294. // 06:02 Chicago prose against 13:00 Chicago numbers. Expected, by design:
  295. // a warning that fires every quarter hour is one nobody reads.
  296. const morning = brief({ payloadGeneratedAt: '2026-09-02T11:02:00.000Z' })
  297. const out = pageAt(morning)
  298. expect(out).not.toMatch(/pnpm morning/)
  299. expect(out).toMatch(/refresh every 15 minutes/)
  300. })
  301. it('asks for a re-run when the prose is from an earlier day', () => {
  302. const yesterday = brief({ payloadGeneratedAt: '2026-09-01T11:02:00.000Z' })
  303. expect(pageAt(yesterday)).toMatch(/pnpm morning/)
  304. })
  305. it('says nothing at all when the prose and the numbers are the same sync', () => {
  306. expect(pageAt(brief({ payloadGeneratedAt: atNoon.syncedAt }))).not.toMatch(
  307. /refresh every 15 minutes|pnpm morning/,
  308. )
  309. })
  310. it('shows both caveats when a briefing is stale AND truncated', () => {
  311. // They are different problems and a reader needs to know which applies.
  312. const both = brief({
  313. payloadGeneratedAt: '2026-09-01T11:02:00.000Z',
  314. truncated: ['company'],
  315. })
  316. const out = pageAt(both)
  317. expect(out).toMatch(/pnpm morning/)
  318. expect(out).toMatch(/cut off/i)
  319. })
  320. it('escapes a briefing rather than trusting the model not to emit markup', () => {
  321. const nasty = brief({ briefings: { company: '<img src=x onerror=alert(1)>' } })
  322. const out = page(nasty)
  323. expect(out).not.toContain('<img src=x')
  324. expect(out).toContain('&lt;img src=x')
  325. })
  326. })