onboarding.test.ts
Language TypeScript12.8 KB303 lines
/*** The onboarding tab: who sees it, what the toggle does, and what the surface* refuses to imply.** What is worth testing here is not the content. The material is a* hand-edited config file; asserting its sentences would pin training notes in* place as a requirement. What is* asserted is the machinery around it: the gate, the switch, and the roster* check that stops a typo becoming a tab nobody can open.*/import { describe, expect, it } from 'vitest'import {ONBOARDING,hasOnboarding,onboardingFor,visiblePlans,type OnboardingPlan,} from '../../../config/onboarding.js'import { MERIDIAN_PEOPLE } from '../../../config/meridian-people.js'import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'import { calendarDate, onboardingSurface } from '../src/surfaces.js'import { render } from '../src/render.js'import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'import { headline } from '../src/collect.js'const PLAN = (over: Partial<OnboardingPlan> = {}): OnboardingPlan => ({person: 'Rosa Galván',role: 'Client Success',startedOn: '2026-09-14',active: true,summary: 'x',days: [],resources: [],sources: [],...over,})const rosa = viewerFor('accounts@meridiandisplay.example')const sam = viewerFor('hello@meridiandisplay.example')const marco = viewerFor('studio@meridiandisplay.example')describe('who the plan is visible to', () => {it('shows a person their own plan', () => {expect(visiblePlans([PLAN()], rosa).map((p) => p.person)).toEqual(['Rosa Galván'])})it('shows it to a leadership reader, so somebody can check what she is being told', () => {// The audience is the new person plus leadership.expect(sam.seesLeadership).toBe(true)expect(visiblePlans([PLAN()], sam)).toHaveLength(1)})it('shows it to NOBODY else, not even an empty tab', () => {// Marco reads the whole inbox and is not new. A tab that opens on somebody// else's training is a tab about nothing.expect(marco.seesLeadership).toBe(false)expect(visiblePlans([PLAN()], marco)).toEqual([])expect(visiblePlans([PLAN()], COMPANY_VIEWER)).toEqual([])})it('puts a leadership reader’s OWN plan first when they have one', () => {const plans = [PLAN(), PLAN({ person: 'Sam Whitaker' })]expect(visiblePlans(plans, sam).map((p) => p.person)).toEqual(['Sam Whitaker', 'Rosa Galván'])})})describe('the toggle', () => {it('hides the plan from everyone, including its own subject, when off', () => {// The toggle is the feature: on while a person is new to the team. Off// means off for her too: a tab that only the// boss can still see is a different feature.const off = [PLAN({ active: false })]expect(visiblePlans(off, rosa)).toEqual([])expect(visiblePlans(off, sam)).toEqual([])})it('keeps the material in the file rather than deleting it', () => {const off = PLAN({active: false,days: [{ day: 1, date: '2026-09-14', title: 'kept', sections: [{ title: 'kept' }] }],})expect(visiblePlans([off], rosa)).toEqual([])expect(off.days).toHaveLength(1)})})describe('the committed plans', () => {it('names only people who are on the roster', () => {// The module throws at load on a mismatch; this asserts the same thing// against the committed data, so the failure is a readable test rather// than an import error somewhere else.const names = new Set(MERIDIAN_PEOPLE.map((p) => p.name))for (const plan of ONBOARDING) expect(names, plan.person).toContain(plan.person)})it('gives Rosa a live plan and gives Marco none', () => {expect(hasOnboarding(rosa)).toBe(true)expect(hasOnboarding(marco)).toBe(false)expect(onboardingFor(rosa)[0]?.person).toBe('Rosa Galván')})it('carries its sources, because the material is somebody’s and not the board’s', () => {for (const plan of ONBOARDING) expect(plan.sources.length).toBeGreaterThan(0)})})/*** The recap's shape. Same convention as the rest of this file: the module* throws at load on a violation, and these assert the same properties against* the committed data so a breach reads as a named test rather than as an import* error in whichever suite happened to load the config first.*/describe('the recap on the committed plan', () => {const plan = ONBOARDING[0]!it('sits only on training days, never on standing material', () => {// A recap is the write-up of a session that happened on a date. Standing// material has no date, and would print under a heading with nothing to// place it in time.for (const d of plan.days) {if (d.day === null) expect(d.recap, d.title).toBeUndefined()}})it('never has a heading over nothing', () => {for (const d of plan.days) {if (!d.recap) continueexpect(d.recap.sessions.length, d.title).toBeGreaterThan(0)expect(d.recap.label.trim(), d.title).not.toBe('')for (const s of d.recap.sessions) expect(s.points.length, s.title ?? d.title).toBeGreaterThan(0)}})it('titles all of a day’s sessions or none of them', () => {// A nameless run of points sitting beside named ones cannot be read.for (const d of plan.days) {if (!d.recap) continueconst titled = d.recap.sessions.filter((x) => x.title !== undefined).lengthexpect([0, d.recap.sessions.length], d.title).toContain(titled)}})it('says "not recorded" with null, never with an empty string', () => {// '' prints as "Accomplished:" and then nothing, which reads as a verdict// rather than an absence.for (const d of plan.days) {if (!d.recap) continueif (d.recap.accomplished !== null) expect(d.recap.accomplished.trim(), d.title).not.toBe('')}})it('has a document to head the bullets whenever any day carries them', () => {if (plan.days.some((d) => d.recap)) {expect(plan.recapDoc?.title.trim()).toBeTruthy()expect(plan.recapDoc?.preparedBy.trim()).toBeTruthy()}})})describe('the surface', () => {it('renders the plan with its attribution and the SOP precedence', () => {const html = onboardingSurface(rosa)expect(html).toContain('Client Success Field Guide')// The guide's own rule, carried onto the page: it is not the source of truth.expect(html).toContain('follow the SOP')expect(html).toContain('config/onboarding.ts')})it('tells a leadership reader they are looking at somebody else’s', () => {const html = onboardingSurface(sam)expect(html).toContain('Rosa Galván')expect(html).toContain("Leadership Team's to-dos")})it('prints the start date as the day it was typed, not the day before', () => {// A 2026-09-14 start must not render as "Sep 13". `shortDate` asks what day// an instant was in Chicago, and a bare date// parses as UTC midnight, 7 PM the previous evening here. A date a person// typed has no time of day to convert.expect(calendarDate('2026-09-14')).toBe('Sep 14')expect(calendarDate('2026-01-01')).toBe('Jan 1')// Anything that is not a bare calendar date comes back untouched rather// than being turned into a wrong-but-plausible one.expect(calendarDate('not a date')).toBe('not a date')expect(onboardingSurface(rosa)).toContain('Sep 14')})it('escapes the material rather than trusting it as markup', () => {// It is a config file, so this is not a threat model; it is the same// discipline every other surface applies to every other string.expect(onboardingSurface(rosa)).not.toContain('<script>')})})/*** The day/topic split: collapsible by day and by topic, with one master* resources list at the bottom.** Asserted here: the machinery, not the material. Whether day 03 covers client* tiers can change at any time; that both levels actually fold, that a* folded heading's count matches what is under it, and that the master list is* genuinely master are the things a later edit can silently break.*/describe('days and topics', () => {const plan = ONBOARDING[0]!const html = onboardingSurface(rosa)it('renders every group and every topic as a real disclosure, not a styled div', () => {// <details> and not a script: the board also ships as one self-contained// dashboard.html that gets opened from disk, where nothing else would fold.const topics = plan.days.reduce((n, d) => n + d.sections.length, 0)expect((html.match(/<details class="ob-day"/g) ?? []).length).toBe(plan.days.length)expect((html.match(/<details class="ob-topic"/g) ?? []).length).toBe(topics)})it('starts with something open, so the tab is not a wall of shut rows', () => {expect(plan.days.some((d) => d.open)).toBe(true)expect(html).toContain('<details class="ob-day" open>')})it('numbers the training days and does NOT number the standing groups', () => {// A rhythm she runs every morning filed under the date it was mentioned// would read as something that stopped being true afterwards.expect(plan.days.some((d) => d.day !== null)).toBe(true)expect(plan.days.some((d) => d.day === null)).toBe(true)expect(html).toContain('Day 03')// Standing groups are labelled "Ongoing" and carry no date at all: a// rhythm filed under the day it was mentioned reads as having stopped.expect(html).toContain('>Ongoing</span>')})it('makes a folded heading account for what it is hiding', () => {// RULE 9, at the smallest scale the board has. A count taken from anywhere// but the items rendered underneath is the "13 things / nothing here" bug.for (const day of plan.days) {const n = day.sections.lengthexpect(html, day.title).toContain(`${n} ${n === 1 ? 'topic' : 'topics'}</span>`)}for (const sec of plan.days.flatMap((d) => d.sections)) {const points = (sec.bullets?.length ?? 0) + (sec.rows?.length ?? 0)// Every topic carries something; an empty one would say so instead.expect(points, sec.title).toBeGreaterThan(0)}})it('puts a master list at the bottom that is actually master', () => {// A "master" list you have to go hunting outside of is not one. The config// throws at load on a missing recording; this asserts the same on the page.expect(html).toContain('ob-resources')expect(plan.resources.length).toBeGreaterThan(0)for (const rec of plan.days.flatMap((d) => d.recordings ?? [])) {expect(html, rec.href).toContain(rec.href)expect(plan.resources.flatMap((g) => g.links.map((l) => l.href))).toContain(rec.href)}// Bottom means bottom: after the last day on the page.expect(html.indexOf('ob-resources')).toBeGreaterThan(html.lastIndexOf('<details class="ob-day"'))})it('links every training session it claims as a source', () => {// A day can be more than one call (day 03 is two), so this counts the// recordings rather than the days, and DERIVES the number rather than// pinning it. A hardcoded count here failed the first time a fourth// recording landed, which is a test that only measures its own staleness.const recordings = plan.days.flatMap((d) => d.recordings ?? [])const hrefs = plan.resources.flatMap((g) => g.links.map((l) => l.href))expect(recordings.length).toBeGreaterThan(0)expect(hrefs.filter((h) => h.includes('fathom.video'))).toHaveLength(recordings.length)})})describe('the tab', () => {const page = (email: string | null) =>render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12', null, viewerFor(email))it('is second, right after Open work, per Sam’s instruction', () => {const html = page('accounts@meridiandisplay.example')const tabs = [...html.matchAll(/data-tab="([a-z-]+)"/g)].map((m) => m[1])expect(tabs.slice(0, 2)).toEqual(['board', 'onboarding'])})it('is OMITTED for a viewer with no plan, not rendered and hidden', () => {// Hidden markup is not privacy: this file learned that once already with// the per-person briefings, which were all in one document behind `hidden`.const html = page('studio@meridiandisplay.example')expect(html).not.toContain('data-tab="onboarding"')expect(html).not.toContain('surface-onboarding')})it('keeps the Inbox link after The queue for both, whatever the tab count', () => {// The Inbox must not sit at a hard-coded index: Onboarding is conditional,// so a fixed index would put the Inbox in a different place depending on// who was looking.for (const email of ['accounts@meridiandisplay.example', 'studio@meridiandisplay.example']) {const html = page(email)const bar = html.slice(html.indexOf('<div class="tabs"'), html.indexOf('</div>\n\n<main'))const order = [...bar.matchAll(/data-tab="([a-z-]+)"|href="(\/inbox)"/g)].map((m) => m[1] ?? 'inbox',)expect(order.indexOf('inbox'), email).toBe(order.indexOf('queue') + 1)}})})