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

onboarding-export.test.ts

Language TypeScript20.4 KB524 lines

apps/dashboard/test/onboarding-export.test.ts

  1. /**
  2. * The training recap: what it carries, what it refuses to carry, and the seven
  3. * absences it must keep apart.
  4. *
  5. * The first test is the point of the file. The page carries an "Accomplished:"
  6. * line, a sentence about how somebody's first week went, and that reads exactly
  7. * like the `did` box of her end-of-day note. It is not that. It is the
  8. * trainer's own summary, in a reviewed config file, shown to the same people
  9. * who can already open her plan; her words live in D1 behind CHECKIN_READERS,
  10. * a shorter list than this page's audience. The control is not care, it is the
  11. * import graph: this module can see `config/onboarding.ts` and nothing else.
  12. * Whoever is next asked to "pull in what she actually said" will reach for
  13. * `readableCheckinAuthors` long before they reach for a comment.
  14. *
  15. * What is not asserted: the recap's wording. It is a hand-written document
  16. * that may be rewritten at any time, and tests assert properties, not wording.
  17. */
  18. import { readFile } from 'node:fs/promises'
  19. import { fileURLToPath } from 'node:url'
  20. import { describe, expect, it } from 'vitest'
  21. import { EXPORT_CSS, INBOX_CSS_FOR_TEST, QUEUE_CSS, THEME_CSS } from './css-surface.js'
  22. import {
  23. EXPORT_BRIEF_TOPICS,
  24. EXPORT_DEFAULT_DAYS,
  25. EXPORT_WINDOWS,
  26. addDays,
  27. buildRecap,
  28. parseWindow,
  29. recapHtml,
  30. recapPage,
  31. recapRange,
  32. recapText,
  33. recapTextAll,
  34. sentencesOf,
  35. sliceFor,
  36. } from '../src/onboarding-export.js'
  37. import {
  38. ONBOARDING,
  39. hasOnboarding,
  40. onboardingFor,
  41. type OnboardingDay,
  42. type OnboardingPlan,
  43. } from '../../../config/onboarding.js'
  44. import { MERIDIAN_PEOPLE } from '../../../config/meridian-people.js'
  45. import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'
  46. import { esc } from '../src/html.js'
  47. const TODAY = '2026-09-16'
  48. const plan = ONBOARDING[0]!
  49. const PLAN = (over: Partial<OnboardingPlan> = {}): OnboardingPlan => ({
  50. person: 'Rosa Galván',
  51. role: 'Client Success',
  52. startedOn: '2026-09-14',
  53. active: true,
  54. summary: 'x',
  55. days: [],
  56. resources: [],
  57. sources: [],
  58. recapDoc: { title: 'A Recap', preparedBy: 'Sam Whitaker' },
  59. ...over,
  60. })
  61. /** A training day with topics but no write-up. */
  62. const session = (day: number, date: string, titles: string[] = ['a']): OnboardingDay => ({
  63. day,
  64. date,
  65. title: `Day ${day}`,
  66. sections: titles.map((t) => ({ title: t })),
  67. })
  68. /** The same, written up. */
  69. const written = (
  70. day: number,
  71. date: string,
  72. over: Partial<NonNullable<OnboardingDay['recap']>> = {},
  73. ): OnboardingDay => ({
  74. ...session(day, date),
  75. recap: {
  76. label: 'A Session',
  77. sessions: [{ points: ['did a thing'] }],
  78. accomplished: 'a thing',
  79. ...over,
  80. },
  81. })
  82. const standing = (title = 'Every day'): OnboardingDay => ({
  83. day: null,
  84. date: null,
  85. title,
  86. sections: [{ title: 'x' }],
  87. })
  88. describe('it cannot leak a note, because it cannot see one', () => {
  89. it('imports the onboarding config and nothing that touches the note store', async () => {
  90. const src = await readFile(
  91. fileURLToPath(new URL('../src/onboarding-export.ts', import.meta.url)),
  92. 'utf8',
  93. )
  94. const imports = [...src.matchAll(/^import[\s\S]*?from '([^']+)'/gm)].map((m) => m[1]!)
  95. for (const forbidden of ['checkin', 'checkin-view', 'checkin-store']) {
  96. expect(
  97. imports.filter((i) => i.includes(forbidden)),
  98. `onboarding-export.ts must not import anything named ${forbidden}`,
  99. ).toEqual([])
  100. }
  101. expect(imports.some((i) => i.includes('config/onboarding'))).toBe(true)
  102. })
  103. it('carries the authored recap and no prose from anywhere else', () => {
  104. // Over the days the window selects, not every day on the plan. `TODAY` is
  105. // frozen, so a session recorded after it is correctly future-dated and
  106. // correctly absent.
  107. const win = parseWindow('all', TODAY)
  108. const text = recapTextAll([plan], win)
  109. const shown = sliceFor(plan, win).sessions
  110. expect(shown.length).toBeGreaterThan(0)
  111. for (const d of shown) {
  112. for (const s of d.recap?.sessions ?? []) {
  113. for (const pt of s.points) expect(text, pt).toContain(pt)
  114. }
  115. }
  116. expect(text).not.toMatch(/what I'm unsure about/i)
  117. })
  118. })
  119. describe('the window', () => {
  120. it('reads the listed values, and “all”', () => {
  121. for (const n of EXPORT_WINDOWS) {
  122. const w = parseWindow(String(n), TODAY)
  123. expect(w.days).toBe(n)
  124. expect(w.fellBack).toBe(false)
  125. }
  126. expect(parseWindow('all', TODAY).days).toBeNull()
  127. expect(parseWindow('all', TODAY).from).toBeNull()
  128. })
  129. it('defaults quietly with no input, and LOUDLY with bad input', () => {
  130. expect(parseWindow(null, TODAY)).toMatchObject({ days: EXPORT_DEFAULT_DAYS, fellBack: false })
  131. for (const bad of ['99', 'seven', '-3', '1.5', '0']) {
  132. expect(parseWindow(bad, TODAY), bad).toMatchObject({
  133. days: EXPORT_DEFAULT_DAYS,
  134. fellBack: true,
  135. })
  136. }
  137. })
  138. it('counts a one-day window as today, not as yesterday', () => {
  139. expect(parseWindow('1', TODAY)).toMatchObject({ from: TODAY, to: TODAY })
  140. expect(parseWindow('3', TODAY).from).toBe('2026-09-14')
  141. expect(addDays(TODAY, -2)).toBe('2026-09-14')
  142. expect(addDays('2026-01-01', -1)).toBe('2025-12-31')
  143. })
  144. })
  145. describe('what the window selects', () => {
  146. const p = PLAN({
  147. days: [standing(), written(1, '2026-09-01'), written(2, '2026-09-15'), standing('Reference')],
  148. })
  149. it('takes the dated days inside it, in order', () => {
  150. const s = sliceFor(p, parseWindow('7', TODAY))
  151. expect(s.sessions.map((d) => d.day)).toEqual([2])
  152. expect(s.lastBefore?.day).toBe(1)
  153. expect(s.totalSessions).toBe(2)
  154. })
  155. it('NEVER includes standing material, and says how much it held back', () => {
  156. for (const w of ['1', '7', 'all']) {
  157. const s = sliceFor(p, parseWindow(w, TODAY))
  158. expect(s.sessions.every((d) => d.day !== null), w).toBe(true)
  159. expect(s.standingHeld, w).toBe(2)
  160. }
  161. expect(recapText(buildRecap(p, parseWindow('all', TODAY)))).toContain('2 other sections')
  162. })
  163. it('does not show a day dated in the future', () => {
  164. const future = PLAN({ days: [written(9, '2026-12-25')] })
  165. expect(sliceFor(future, parseWindow('all', TODAY)).sessions).toEqual([])
  166. })
  167. })
  168. /**
  169. * The seven states. Collapsing any two turns "nobody has written this up" into
  170. * "nothing happened", and this document is the evidence somebody's first weeks
  171. * are going well, so that is the version of the prime invariant that actually
  172. * misleads a person rather than merely a spreadsheet.
  173. */
  174. describe('the seven things that must not look alike', () => {
  175. const states = {
  176. written: () => buildRecap(PLAN({ days: [written(1, TODAY)] }), parseWindow('1', TODAY)),
  177. noRecap: () => buildRecap(PLAN({ days: [session(1, TODAY)] }), parseWindow('1', TODAY)),
  178. noAccomplished: () =>
  179. buildRecap(
  180. PLAN({ days: [written(1, TODAY, { accomplished: null })] }),
  181. parseWindow('1', TODAY),
  182. ),
  183. emptyWindow: () =>
  184. buildRecap(PLAN({ days: [written(1, '2026-09-01')] }), parseWindow('3', TODAY)),
  185. noDays: () => buildRecap(PLAN({ days: [standing()] }), parseWindow('3', TODAY)),
  186. noRecapAnywhere: () =>
  187. buildRecap(PLAN({ days: [session(1, TODAY), session(2, TODAY)] }), parseWindow('7', TODAY)),
  188. }
  189. it('a day with no recap still RENDERS, so the day count cannot shrink', () => {
  190. const m = states.noRecap()
  191. expect(m.blocks).toHaveLength(1)
  192. expect(m.blocks[0]!.kind).toBe('none')
  193. expect(recapText(m)).toMatch(/no recap has been written for this day yet/i)
  194. // And it carries the evidence that the day happened at all.
  195. expect(recapText(m)).toMatch(/the training plan records 1 topic under it/i)
  196. })
  197. it('a null accomplished says "not recorded", never a bare label', () => {
  198. const t = recapText(states.noAccomplished())
  199. expect(t).toMatch(/accomplished: not recorded for this day/i)
  200. expect(t).not.toMatch(/Accomplished:\s*$/m)
  201. })
  202. it('an empty window names the most recent day, so the next move is obvious', () => {
  203. const t = recapText(states.emptyWindow())
  204. expect(t).toMatch(/no training days fall in this window/i)
  205. expect(t).toContain('Day 1')
  206. })
  207. it('a plan with no training days says something DIFFERENT, with no window language', () => {
  208. const t = recapText(states.noDays())
  209. expect(t).toMatch(/no training days are recorded on this plan yet/i)
  210. expect(t).not.toMatch(/in this window/i)
  211. })
  212. it('days with no recap anywhere still list the days', () => {
  213. const m = states.noRecapAnywhere()
  214. expect(m.empty).toMatch(/no recap has been written for this plan yet/i)
  215. expect(m.blocks).toHaveLength(2)
  216. })
  217. it('no visible plan exports nothing, rather than an empty document', () => {
  218. expect(recapTextAll([], parseWindow('7', TODAY))).toMatch(/no onboarding plans/i)
  219. })
  220. it('produces a DISTINCT rendering for every one of them', () => {
  221. // The test that catches a future refactor folding two absences together.
  222. const seen = new Map<string, string>()
  223. for (const [name, build] of Object.entries(states)) {
  224. const line = recapText(build())
  225. expect(seen.has(line), `${name} reads identically to ${seen.get(line)}`).toBe(false)
  226. seen.set(line, name)
  227. }
  228. expect(seen.size).toBe(Object.keys(states).length)
  229. })
  230. })
  231. describe('it never claims fewer training days than happened', () => {
  232. it('renders one block per training day in the window, written or not', () => {
  233. const p = PLAN({ days: [written(1, '2026-09-14'), session(2, '2026-09-15'), standing()] })
  234. const m = buildRecap(p, parseWindow('all', TODAY))
  235. expect(m.blocks).toHaveLength(2)
  236. expect(m.blocks.map((b) => b.kind)).toEqual(['recap', 'none'])
  237. })
  238. it('states coverage at EVERY window, including when nothing was withheld', () => {
  239. // Rule 9: "3 of 3" is a claim worth making out loud. Leaving it off when
  240. // nothing is missing means completeness has to be inferred from silence,
  241. // and silence is what a bug looks like too.
  242. for (const w of [...EXPORT_WINDOWS.map(String), 'all']) {
  243. const win = parseWindow(w, TODAY)
  244. const m = buildRecap(plan, win)
  245. expect(m.coverage, w).toContain(
  246. `Covering ${m.blocks.length} of ${sliceFor(plan, win).totalSessions}`,
  247. )
  248. }
  249. })
  250. it('never says the recap points are a truncation of the plan topics', () => {
  251. // Eight curated points are not "the first 8 of 11", and "… and 3 more"
  252. // would promise a list the reader cannot reach. The disclosure is made
  253. // ONCE, in the footer, because it is a fact about the document.
  254. const m = buildRecap(plan, parseWindow('all', TODAY))
  255. expect(m.coverage).toMatch(/curated write-ups/i)
  256. const text = recapText(m)
  257. expect((text.match(/curated write-ups/gi) ?? []).length).toBe(1)
  258. for (const b of m.blocks) {
  259. if (b.kind !== 'recap') continue
  260. for (const s of b.sessions) expect(s.points.join(' ')).not.toMatch(/and \d+ more/)
  261. }
  262. })
  263. it('still truncates-and-labels the ONE list that IS a truncation', () => {
  264. // Rule 5 keeps a home: the plan-topic evidence line under a day nobody has
  265. // written up yet.
  266. const many = Array.from({ length: EXPORT_BRIEF_TOPICS + 3 }, (_, i) => `t${i}`)
  267. const m = buildRecap(PLAN({ days: [session(1, TODAY, many)] }), parseWindow('1', TODAY))
  268. const b = m.blocks[0]!
  269. expect(b.kind).toBe('none')
  270. if (b.kind === 'none') {
  271. expect(b.sentence).toContain('and 3 more')
  272. expect(b.sentence).toContain(`${many.length} topics`)
  273. }
  274. })
  275. })
  276. describe('the subtitle', () => {
  277. it('never asserts a range wider than what it shows', () => {
  278. for (const w of [...EXPORT_WINDOWS.map(String), 'all']) {
  279. const win = parseWindow(w, TODAY)
  280. const dates = sliceFor(plan, win).sessions.map((d) => d.date!)
  281. if (!dates.length) continue
  282. const sub = buildRecap(plan, win).subtitle!
  283. expect(sub, w).toContain(String(Number(dates[0]!.slice(8))))
  284. expect(sub, w).toContain(String(Number(dates.at(-1)!.slice(8))))
  285. }
  286. })
  287. it('formats one day, a same-month span, a cross-month span and a cross-year span', () => {
  288. const d = (x: string) => session(1, x)
  289. expect(recapRange([d('2026-09-14')])).toBe('September 14, 2026')
  290. expect(recapRange([d('2026-09-14'), d('2026-09-16')])).toBe('September 14 to 16, 2026')
  291. expect(recapRange([d('2026-09-28'), d('2026-10-02')])).toBe('September 28 to October 2, 2026')
  292. expect(recapRange([d('2026-12-30'), d('2027-01-02')])).toBe(
  293. 'December 30, 2026 to January 2, 2027',
  294. )
  295. expect(recapRange([])).toBeNull()
  296. })
  297. it('takes the role from the roster rather than storing a second spelling', () => {
  298. // The role comes from the roster: one fact, one place.
  299. expect(buildRecap(plan, parseWindow('all', TODAY)).subtitle).toContain(plan.role)
  300. })
  301. })
  302. describe('the document and the clipboard say the same thing', () => {
  303. it('puts every sentence of the model into both renderers', () => {
  304. // What "build once, render twice" buys, and it is only true if something
  305. // checks it.
  306. for (const w of ['1', '7', 'all']) {
  307. const m = buildRecap(plan, parseWindow(w, TODAY))
  308. const html = recapHtml(m)
  309. const text = recapText(m)
  310. for (const s of sentencesOf(m)) {
  311. expect(html, `html/${w}: ${s}`).toContain(esc(s))
  312. expect(text, `text/${w}: ${s}`).toContain(s)
  313. }
  314. }
  315. })
  316. it('escapes once in HTML and leaves the text raw, so a pasted apostrophe is one', () => {
  317. // The recap prose is dense with them ("the client's order history", "a
  318. // client's stated budget").
  319. const p = PLAN({
  320. days: [written(1, TODAY, { sessions: [{ points: ["a client's budget & scope"] }] })],
  321. })
  322. const m = buildRecap(p, parseWindow('1', TODAY))
  323. expect(recapHtml(m)).toContain('a client&#39;s budget &amp; scope')
  324. expect(recapHtml(m)).not.toContain('&amp;#39;')
  325. expect(recapText(m)).toContain("a client's budget & scope")
  326. })
  327. })
  328. describe('who can run it', () => {
  329. const viewers = [
  330. ...MERIDIAN_PEOPLE.map((p) => viewerFor(p.email)),
  331. COMPANY_VIEWER,
  332. viewerFor(null),
  333. viewerFor('stranger@example.com'),
  334. ]
  335. it('never lets the gate and the content disagree', () => {
  336. for (const v of viewers) {
  337. expect(hasOnboarding(v), v.email ?? 'anonymous').toBe(onboardingFor(v).length > 0)
  338. }
  339. })
  340. it('gives everyone who gets in exactly the plans they may already read', () => {
  341. for (const v of viewers) {
  342. const visible = onboardingFor(v)
  343. const text = recapTextAll(visible, parseWindow('all', TODAY))
  344. for (const other of ONBOARDING) {
  345. const mayRead = visible.some((p) => p.person === other.person)
  346. const doc = other.recapDoc?.title
  347. if (doc) expect(text.includes(doc), `${v.email ?? 'anonymous'} / ${other.person}`).toBe(mayRead)
  348. }
  349. if (!visible.length) expect(text).toMatch(/no onboarding plans/i)
  350. }
  351. })
  352. it('lets Nora run it, and Rosa read her own', () => {
  353. // Anyone who asks the morning question can run it themselves, the trainee
  354. // included.
  355. for (const email of ['nora.l@meridiandisplay.example', 'accounts@meridiandisplay.example']) {
  356. const v = viewerFor(email)
  357. expect(hasOnboarding(v), email).toBe(true)
  358. expect(recapTextAll(onboardingFor(v), parseWindow('7', TODAY)), email).toContain(
  359. 'Training Recap',
  360. )
  361. }
  362. })
  363. })
  364. describe('the page is actually styled where it is actually served', () => {
  365. const served = THEME_CSS + INBOX_CSS_FOR_TEST + EXPORT_CSS
  366. it('defines every class the recap page emits', () => {
  367. const html = recapPage(ONBOARDING, parseWindow('7', TODAY), '7')
  368. const used = new Set(
  369. [...html.matchAll(/class="([^"]+)"/g)].flatMap((m) => m[1]!.trim().split(/\s+/)),
  370. )
  371. expect(used.size).toBeGreaterThan(4)
  372. for (const cls of used) {
  373. expect(
  374. served,
  375. `.${cls} is emitted by the recap page but styled nowhere it is served`,
  376. ).toContain(`.${cls}`)
  377. }
  378. })
  379. it('puts the print rules in the sheet the Worker serves, not the board-only one', () => {
  380. // QUEUE_CSS never reaches a Worker-served page, and the .ob-* rules already
  381. // in it invite putting export rules there by mistake.
  382. expect(EXPORT_CSS).toContain('@page')
  383. expect(EXPORT_CSS).toContain('@media print')
  384. expect(QUEUE_CSS).not.toContain('.rc-')
  385. })
  386. it('does not need background printing to be legible', () => {
  387. // Browsers default to NOT printing backgrounds, so a document leaning on
  388. // fills prints wrong for most people the first time.
  389. //
  390. // Removing a fill is fine and is not what this guards: `background:none`
  391. // is how the on-screen card stops being a card on paper. What must not
  392. // appear is a fill the layout depends on, or print-color-adjust forcing the
  393. // reader's settings.
  394. const block = EXPORT_CSS.slice(EXPORT_CSS.indexOf('@media print'))
  395. const fills = [...block.matchAll(/background[^;}]*/g)].map((m) => m[0])
  396. for (const f of fills) expect(f, f).toMatch(/:\s*(none|transparent)\s*$/)
  397. expect(block).not.toMatch(/print-color-adjust/)
  398. })
  399. it('keeps the document out of a .card, so a long recap can paginate', () => {
  400. // THEME_CSS's print block sets .card{break-inside:avoid}; the wrapper holds
  401. // the WHOLE document, which runs to several pages once a few days are in.
  402. // Asking a box taller than the sheet not to break produces a worse break
  403. // than letting it.
  404. //
  405. // Asserted on the PAGE, not just the document fragment: the fragment never
  406. // had a card, and the first version of this wrapper did.
  407. const page = recapPage(ONBOARDING, parseWindow('all', TODAY), 'all')
  408. expect(page).toContain('<article class="rc-doc">')
  409. const shell = page.slice(page.indexOf('rc-shell'))
  410. expect(shell.slice(0, shell.indexOf('</div>'))).not.toContain('class="card"')
  411. // And the only `card` on the page is nowhere near the document.
  412. expect(page).not.toMatch(/class="card[^"]*"[\s\S]{0,200}rc-doc/)
  413. })
  414. })
  415. describe('the links on the page carry what they say', () => {
  416. const unescape = (s: string): string =>
  417. s.replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
  418. const parse = (html: string): URL[] =>
  419. [...html.matchAll(/href="([^"]+)"/g)]
  420. .map((m) => unescape(m[1]!))
  421. .filter((h) => h.startsWith('/onboarding/export'))
  422. .map((h) => new URL(h, 'https://board.example'))
  423. it('never emits a parameter the Worker would not recognise', () => {
  424. // `amp;days` is what a double-escape produces and is indistinguishable
  425. // from a typo at the far end.
  426. const allowed = new Set(['days', 'format'])
  427. for (const u of parse(recapPage(ONBOARDING, parseWindow('7', TODAY), '7'))) {
  428. for (const key of u.searchParams.keys()) {
  429. expect(allowed.has(key), `${u.href} carries "${key}"`).toBe(true)
  430. }
  431. }
  432. })
  433. it('offers every window, and a plain-text twin of the one you are on', () => {
  434. const links = parse(recapPage(ONBOARDING, parseWindow('3', TODAY), '3'))
  435. expect(links.filter((u) => !u.searchParams.has('format'))).toHaveLength(
  436. EXPORT_WINDOWS.length + 1,
  437. )
  438. const txt = links.filter((u) => u.searchParams.get('format') === 'txt')
  439. expect(txt).toHaveLength(1)
  440. expect(txt[0]!.searchParams.get('days')).toBe('3')
  441. })
  442. it('round-trips every link back through the parser it ships with', () => {
  443. for (const u of parse(recapPage(ONBOARDING, parseWindow('3', TODAY), '3'))) {
  444. expect(parseWindow(u.searchParams.get('days'), TODAY).fellBack, u.href).toBe(false)
  445. }
  446. })
  447. it('says so when a retired parameter is passed, rather than ignoring it', () => {
  448. // `&detail=full` links exist for a retired control; the page acknowledges
  449. // the parameter rather than ignoring it.
  450. expect(recapPage(ONBOARDING, parseWindow('7', TODAY), '7', true)).toContain('no longer does')
  451. expect(recapPage(ONBOARDING, parseWindow('7', TODAY), '7', false)).not.toContain(
  452. 'no longer does',
  453. )
  454. })
  455. })
  456. describe('the committed plan', () => {
  457. it('has a recap on every training day, and none on any standing group', () => {
  458. for (const d of plan.days) {
  459. if (d.day === null) expect(d.recap, d.title).toBeUndefined()
  460. else expect(d.recap, d.title).toBeDefined()
  461. }
  462. })
  463. it('renders as the document it was seeded from', () => {
  464. // DERIVED, not pinned at 3: every training day in the window gets a block,
  465. // and every one of them is written up. A hardcoded count here measures only
  466. // how long ago the test was written.
  467. const win = parseWindow('all', TODAY)
  468. const m = buildRecap(plan, win)
  469. expect(m.title).toBeTruthy()
  470. expect(m.blocks).toHaveLength(sliceFor(plan, win).sessions.length)
  471. expect(m.blocks.length).toBeGreaterThan(0)
  472. expect(m.blocks.every((b) => b.kind === 'recap')).toBe(true)
  473. expect(m.empty).toBeNull()
  474. })
  475. })