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

onboarding.test.ts

Language TypeScript12.8 KB303 lines

apps/dashboard/test/onboarding.test.ts

  1. /**
  2. * The onboarding tab: who sees it, what the toggle does, and what the surface
  3. * refuses to imply.
  4. *
  5. * What is worth testing here is not the content. The material is a
  6. * hand-edited config file; asserting its sentences would pin training notes in
  7. * place as a requirement. What is
  8. * asserted is the machinery around it: the gate, the switch, and the roster
  9. * check that stops a typo becoming a tab nobody can open.
  10. */
  11. import { describe, expect, it } from 'vitest'
  12. import {
  13. ONBOARDING,
  14. hasOnboarding,
  15. onboardingFor,
  16. visiblePlans,
  17. type OnboardingPlan,
  18. } from '../../../config/onboarding.js'
  19. import { MERIDIAN_PEOPLE } from '../../../config/meridian-people.js'
  20. import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'
  21. import { calendarDate, onboardingSurface } from '../src/surfaces.js'
  22. import { render } from '../src/render.js'
  23. import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'
  24. import { headline } from '../src/collect.js'
  25. const PLAN = (over: Partial<OnboardingPlan> = {}): OnboardingPlan => ({
  26. person: 'Rosa Galván',
  27. role: 'Client Success',
  28. startedOn: '2026-09-14',
  29. active: true,
  30. summary: 'x',
  31. days: [],
  32. resources: [],
  33. sources: [],
  34. ...over,
  35. })
  36. const rosa = viewerFor('accounts@meridiandisplay.example')
  37. const sam = viewerFor('hello@meridiandisplay.example')
  38. const marco = viewerFor('studio@meridiandisplay.example')
  39. describe('who the plan is visible to', () => {
  40. it('shows a person their own plan', () => {
  41. expect(visiblePlans([PLAN()], rosa).map((p) => p.person)).toEqual(['Rosa Galván'])
  42. })
  43. it('shows it to a leadership reader, so somebody can check what she is being told', () => {
  44. // The audience is the new person plus leadership.
  45. expect(sam.seesLeadership).toBe(true)
  46. expect(visiblePlans([PLAN()], sam)).toHaveLength(1)
  47. })
  48. it('shows it to NOBODY else, not even an empty tab', () => {
  49. // Marco reads the whole inbox and is not new. A tab that opens on somebody
  50. // else's training is a tab about nothing.
  51. expect(marco.seesLeadership).toBe(false)
  52. expect(visiblePlans([PLAN()], marco)).toEqual([])
  53. expect(visiblePlans([PLAN()], COMPANY_VIEWER)).toEqual([])
  54. })
  55. it('puts a leadership reader’s OWN plan first when they have one', () => {
  56. const plans = [PLAN(), PLAN({ person: 'Sam Whitaker' })]
  57. expect(visiblePlans(plans, sam).map((p) => p.person)).toEqual(['Sam Whitaker', 'Rosa Galván'])
  58. })
  59. })
  60. describe('the toggle', () => {
  61. it('hides the plan from everyone, including its own subject, when off', () => {
  62. // The toggle is the feature: on while a person is new to the team. Off
  63. // means off for her too: a tab that only the
  64. // boss can still see is a different feature.
  65. const off = [PLAN({ active: false })]
  66. expect(visiblePlans(off, rosa)).toEqual([])
  67. expect(visiblePlans(off, sam)).toEqual([])
  68. })
  69. it('keeps the material in the file rather than deleting it', () => {
  70. const off = PLAN({
  71. active: false,
  72. days: [{ day: 1, date: '2026-09-14', title: 'kept', sections: [{ title: 'kept' }] }],
  73. })
  74. expect(visiblePlans([off], rosa)).toEqual([])
  75. expect(off.days).toHaveLength(1)
  76. })
  77. })
  78. describe('the committed plans', () => {
  79. it('names only people who are on the roster', () => {
  80. // The module throws at load on a mismatch; this asserts the same thing
  81. // against the committed data, so the failure is a readable test rather
  82. // than an import error somewhere else.
  83. const names = new Set(MERIDIAN_PEOPLE.map((p) => p.name))
  84. for (const plan of ONBOARDING) expect(names, plan.person).toContain(plan.person)
  85. })
  86. it('gives Rosa a live plan and gives Marco none', () => {
  87. expect(hasOnboarding(rosa)).toBe(true)
  88. expect(hasOnboarding(marco)).toBe(false)
  89. expect(onboardingFor(rosa)[0]?.person).toBe('Rosa Galván')
  90. })
  91. it('carries its sources, because the material is somebody’s and not the board’s', () => {
  92. for (const plan of ONBOARDING) expect(plan.sources.length).toBeGreaterThan(0)
  93. })
  94. })
  95. /**
  96. * The recap's shape. Same convention as the rest of this file: the module
  97. * throws at load on a violation, and these assert the same properties against
  98. * the committed data so a breach reads as a named test rather than as an import
  99. * error in whichever suite happened to load the config first.
  100. */
  101. describe('the recap on the committed plan', () => {
  102. const plan = ONBOARDING[0]!
  103. it('sits only on training days, never on standing material', () => {
  104. // A recap is the write-up of a session that happened on a date. Standing
  105. // material has no date, and would print under a heading with nothing to
  106. // place it in time.
  107. for (const d of plan.days) {
  108. if (d.day === null) expect(d.recap, d.title).toBeUndefined()
  109. }
  110. })
  111. it('never has a heading over nothing', () => {
  112. for (const d of plan.days) {
  113. if (!d.recap) continue
  114. expect(d.recap.sessions.length, d.title).toBeGreaterThan(0)
  115. expect(d.recap.label.trim(), d.title).not.toBe('')
  116. for (const s of d.recap.sessions) expect(s.points.length, s.title ?? d.title).toBeGreaterThan(0)
  117. }
  118. })
  119. it('titles all of a day’s sessions or none of them', () => {
  120. // A nameless run of points sitting beside named ones cannot be read.
  121. for (const d of plan.days) {
  122. if (!d.recap) continue
  123. const titled = d.recap.sessions.filter((x) => x.title !== undefined).length
  124. expect([0, d.recap.sessions.length], d.title).toContain(titled)
  125. }
  126. })
  127. it('says "not recorded" with null, never with an empty string', () => {
  128. // '' prints as "Accomplished:" and then nothing, which reads as a verdict
  129. // rather than an absence.
  130. for (const d of plan.days) {
  131. if (!d.recap) continue
  132. if (d.recap.accomplished !== null) expect(d.recap.accomplished.trim(), d.title).not.toBe('')
  133. }
  134. })
  135. it('has a document to head the bullets whenever any day carries them', () => {
  136. if (plan.days.some((d) => d.recap)) {
  137. expect(plan.recapDoc?.title.trim()).toBeTruthy()
  138. expect(plan.recapDoc?.preparedBy.trim()).toBeTruthy()
  139. }
  140. })
  141. })
  142. describe('the surface', () => {
  143. it('renders the plan with its attribution and the SOP precedence', () => {
  144. const html = onboardingSurface(rosa)
  145. expect(html).toContain('Client Success Field Guide')
  146. // The guide's own rule, carried onto the page: it is not the source of truth.
  147. expect(html).toContain('follow the SOP')
  148. expect(html).toContain('config/onboarding.ts')
  149. })
  150. it('tells a leadership reader they are looking at somebody else’s', () => {
  151. const html = onboardingSurface(sam)
  152. expect(html).toContain('Rosa Galván')
  153. expect(html).toContain("Leadership Team's to-dos")
  154. })
  155. it('prints the start date as the day it was typed, not the day before', () => {
  156. // A 2026-09-14 start must not render as "Sep 13". `shortDate` asks what day
  157. // an instant was in Chicago, and a bare date
  158. // parses as UTC midnight, 7 PM the previous evening here. A date a person
  159. // typed has no time of day to convert.
  160. expect(calendarDate('2026-09-14')).toBe('Sep 14')
  161. expect(calendarDate('2026-01-01')).toBe('Jan 1')
  162. // Anything that is not a bare calendar date comes back untouched rather
  163. // than being turned into a wrong-but-plausible one.
  164. expect(calendarDate('not a date')).toBe('not a date')
  165. expect(onboardingSurface(rosa)).toContain('Sep 14')
  166. })
  167. it('escapes the material rather than trusting it as markup', () => {
  168. // It is a config file, so this is not a threat model; it is the same
  169. // discipline every other surface applies to every other string.
  170. expect(onboardingSurface(rosa)).not.toContain('<script>')
  171. })
  172. })
  173. /**
  174. * The day/topic split: collapsible by day and by topic, with one master
  175. * resources list at the bottom.
  176. *
  177. * Asserted here: the machinery, not the material. Whether day 03 covers client
  178. * tiers can change at any time; that both levels actually fold, that a
  179. * folded heading's count matches what is under it, and that the master list is
  180. * genuinely master are the things a later edit can silently break.
  181. */
  182. describe('days and topics', () => {
  183. const plan = ONBOARDING[0]!
  184. const html = onboardingSurface(rosa)
  185. it('renders every group and every topic as a real disclosure, not a styled div', () => {
  186. // <details> and not a script: the board also ships as one self-contained
  187. // dashboard.html that gets opened from disk, where nothing else would fold.
  188. const topics = plan.days.reduce((n, d) => n + d.sections.length, 0)
  189. expect((html.match(/<details class="ob-day"/g) ?? []).length).toBe(plan.days.length)
  190. expect((html.match(/<details class="ob-topic"/g) ?? []).length).toBe(topics)
  191. })
  192. it('starts with something open, so the tab is not a wall of shut rows', () => {
  193. expect(plan.days.some((d) => d.open)).toBe(true)
  194. expect(html).toContain('<details class="ob-day" open>')
  195. })
  196. it('numbers the training days and does NOT number the standing groups', () => {
  197. // A rhythm she runs every morning filed under the date it was mentioned
  198. // would read as something that stopped being true afterwards.
  199. expect(plan.days.some((d) => d.day !== null)).toBe(true)
  200. expect(plan.days.some((d) => d.day === null)).toBe(true)
  201. expect(html).toContain('Day 03')
  202. // Standing groups are labelled "Ongoing" and carry no date at all: a
  203. // rhythm filed under the day it was mentioned reads as having stopped.
  204. expect(html).toContain('>Ongoing</span>')
  205. })
  206. it('makes a folded heading account for what it is hiding', () => {
  207. // RULE 9, at the smallest scale the board has. A count taken from anywhere
  208. // but the items rendered underneath is the "13 things / nothing here" bug.
  209. for (const day of plan.days) {
  210. const n = day.sections.length
  211. expect(html, day.title).toContain(`${n} ${n === 1 ? 'topic' : 'topics'}</span>`)
  212. }
  213. for (const sec of plan.days.flatMap((d) => d.sections)) {
  214. const points = (sec.bullets?.length ?? 0) + (sec.rows?.length ?? 0)
  215. // Every topic carries something; an empty one would say so instead.
  216. expect(points, sec.title).toBeGreaterThan(0)
  217. }
  218. })
  219. it('puts a master list at the bottom that is actually master', () => {
  220. // A "master" list you have to go hunting outside of is not one. The config
  221. // throws at load on a missing recording; this asserts the same on the page.
  222. expect(html).toContain('ob-resources')
  223. expect(plan.resources.length).toBeGreaterThan(0)
  224. for (const rec of plan.days.flatMap((d) => d.recordings ?? [])) {
  225. expect(html, rec.href).toContain(rec.href)
  226. expect(plan.resources.flatMap((g) => g.links.map((l) => l.href))).toContain(rec.href)
  227. }
  228. // Bottom means bottom: after the last day on the page.
  229. expect(html.indexOf('ob-resources')).toBeGreaterThan(html.lastIndexOf('<details class="ob-day"'))
  230. })
  231. it('links every training session it claims as a source', () => {
  232. // A day can be more than one call (day 03 is two), so this counts the
  233. // recordings rather than the days, and DERIVES the number rather than
  234. // pinning it. A hardcoded count here failed the first time a fourth
  235. // recording landed, which is a test that only measures its own staleness.
  236. const recordings = plan.days.flatMap((d) => d.recordings ?? [])
  237. const hrefs = plan.resources.flatMap((g) => g.links.map((l) => l.href))
  238. expect(recordings.length).toBeGreaterThan(0)
  239. expect(hrefs.filter((h) => h.includes('fathom.video'))).toHaveLength(recordings.length)
  240. })
  241. })
  242. describe('the tab', () => {
  243. const page = (email: string | null) =>
  244. render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12', null, viewerFor(email))
  245. it('is second, right after Open work, per Sam’s instruction', () => {
  246. const html = page('accounts@meridiandisplay.example')
  247. const tabs = [...html.matchAll(/data-tab="([a-z-]+)"/g)].map((m) => m[1])
  248. expect(tabs.slice(0, 2)).toEqual(['board', 'onboarding'])
  249. })
  250. it('is OMITTED for a viewer with no plan, not rendered and hidden', () => {
  251. // Hidden markup is not privacy: this file learned that once already with
  252. // the per-person briefings, which were all in one document behind `hidden`.
  253. const html = page('studio@meridiandisplay.example')
  254. expect(html).not.toContain('data-tab="onboarding"')
  255. expect(html).not.toContain('surface-onboarding')
  256. })
  257. it('keeps the Inbox link after The queue for both, whatever the tab count', () => {
  258. // The Inbox must not sit at a hard-coded index: Onboarding is conditional,
  259. // so a fixed index would put the Inbox in a different place depending on
  260. // who was looking.
  261. for (const email of ['accounts@meridiandisplay.example', 'studio@meridiandisplay.example']) {
  262. const html = page(email)
  263. const bar = html.slice(html.indexOf('<div class="tabs"'), html.indexOf('</div>\n\n<main'))
  264. const order = [...bar.matchAll(/data-tab="([a-z-]+)"|href="(\/inbox)"/g)].map(
  265. (m) => m[1] ?? 'inbox',
  266. )
  267. expect(order.indexOf('inbox'), email).toBe(order.indexOf('queue') + 1)
  268. }
  269. })
  270. })