onboarding-export.test.ts
Language TypeScript20.4 KB524 lines
/*** The training recap: what it carries, what it refuses to carry, and the seven* absences it must keep apart.** The first test is the point of the file. The page carries an "Accomplished:"* line, a sentence about how somebody's first week went, and that reads exactly* like the `did` box of her end-of-day note. It is not that. It is the* trainer's own summary, in a reviewed config file, shown to the same people* who can already open her plan; her words live in D1 behind CHECKIN_READERS,* a shorter list than this page's audience. The control is not care, it is the* import graph: this module can see `config/onboarding.ts` and nothing else.* Whoever is next asked to "pull in what she actually said" will reach for* `readableCheckinAuthors` long before they reach for a comment.** What is not asserted: the recap's wording. It is a hand-written document* that may be rewritten at any time, and tests assert properties, not wording.*/import { readFile } from 'node:fs/promises'import { fileURLToPath } from 'node:url'import { describe, expect, it } from 'vitest'import { EXPORT_CSS, INBOX_CSS_FOR_TEST, QUEUE_CSS, THEME_CSS } from './css-surface.js'import {EXPORT_BRIEF_TOPICS,EXPORT_DEFAULT_DAYS,EXPORT_WINDOWS,addDays,buildRecap,parseWindow,recapHtml,recapPage,recapRange,recapText,recapTextAll,sentencesOf,sliceFor,} from '../src/onboarding-export.js'import {ONBOARDING,hasOnboarding,onboardingFor,type OnboardingDay,type OnboardingPlan,} from '../../../config/onboarding.js'import { MERIDIAN_PEOPLE } from '../../../config/meridian-people.js'import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'import { esc } from '../src/html.js'const TODAY = '2026-09-16'const plan = ONBOARDING[0]!const PLAN = (over: Partial<OnboardingPlan> = {}): OnboardingPlan => ({person: 'Rosa Galván',role: 'Client Success',startedOn: '2026-09-14',active: true,summary: 'x',days: [],resources: [],sources: [],recapDoc: { title: 'A Recap', preparedBy: 'Sam Whitaker' },...over,})/** A training day with topics but no write-up. */const session = (day: number, date: string, titles: string[] = ['a']): OnboardingDay => ({day,date,title: `Day ${day}`,sections: titles.map((t) => ({ title: t })),})/** The same, written up. */const written = (day: number,date: string,over: Partial<NonNullable<OnboardingDay['recap']>> = {},): OnboardingDay => ({...session(day, date),recap: {label: 'A Session',sessions: [{ points: ['did a thing'] }],accomplished: 'a thing',...over,},})const standing = (title = 'Every day'): OnboardingDay => ({day: null,date: null,title,sections: [{ title: 'x' }],})describe('it cannot leak a note, because it cannot see one', () => {it('imports the onboarding config and nothing that touches the note store', async () => {const src = await readFile(fileURLToPath(new URL('../src/onboarding-export.ts', import.meta.url)),'utf8',)const imports = [...src.matchAll(/^import[\s\S]*?from '([^']+)'/gm)].map((m) => m[1]!)for (const forbidden of ['checkin', 'checkin-view', 'checkin-store']) {expect(imports.filter((i) => i.includes(forbidden)),`onboarding-export.ts must not import anything named ${forbidden}`,).toEqual([])}expect(imports.some((i) => i.includes('config/onboarding'))).toBe(true)})it('carries the authored recap and no prose from anywhere else', () => {// Over the days the window selects, not every day on the plan. `TODAY` is// frozen, so a session recorded after it is correctly future-dated and// correctly absent.const win = parseWindow('all', TODAY)const text = recapTextAll([plan], win)const shown = sliceFor(plan, win).sessionsexpect(shown.length).toBeGreaterThan(0)for (const d of shown) {for (const s of d.recap?.sessions ?? []) {for (const pt of s.points) expect(text, pt).toContain(pt)}}expect(text).not.toMatch(/what I'm unsure about/i)})})describe('the window', () => {it('reads the listed values, and “all”', () => {for (const n of EXPORT_WINDOWS) {const w = parseWindow(String(n), TODAY)expect(w.days).toBe(n)expect(w.fellBack).toBe(false)}expect(parseWindow('all', TODAY).days).toBeNull()expect(parseWindow('all', TODAY).from).toBeNull()})it('defaults quietly with no input, and LOUDLY with bad input', () => {expect(parseWindow(null, TODAY)).toMatchObject({ days: EXPORT_DEFAULT_DAYS, fellBack: false })for (const bad of ['99', 'seven', '-3', '1.5', '0']) {expect(parseWindow(bad, TODAY), bad).toMatchObject({days: EXPORT_DEFAULT_DAYS,fellBack: true,})}})it('counts a one-day window as today, not as yesterday', () => {expect(parseWindow('1', TODAY)).toMatchObject({ from: TODAY, to: TODAY })expect(parseWindow('3', TODAY).from).toBe('2026-09-14')expect(addDays(TODAY, -2)).toBe('2026-09-14')expect(addDays('2026-01-01', -1)).toBe('2025-12-31')})})describe('what the window selects', () => {const p = PLAN({days: [standing(), written(1, '2026-09-01'), written(2, '2026-09-15'), standing('Reference')],})it('takes the dated days inside it, in order', () => {const s = sliceFor(p, parseWindow('7', TODAY))expect(s.sessions.map((d) => d.day)).toEqual([2])expect(s.lastBefore?.day).toBe(1)expect(s.totalSessions).toBe(2)})it('NEVER includes standing material, and says how much it held back', () => {for (const w of ['1', '7', 'all']) {const s = sliceFor(p, parseWindow(w, TODAY))expect(s.sessions.every((d) => d.day !== null), w).toBe(true)expect(s.standingHeld, w).toBe(2)}expect(recapText(buildRecap(p, parseWindow('all', TODAY)))).toContain('2 other sections')})it('does not show a day dated in the future', () => {const future = PLAN({ days: [written(9, '2026-12-25')] })expect(sliceFor(future, parseWindow('all', TODAY)).sessions).toEqual([])})})/*** The seven states. Collapsing any two turns "nobody has written this up" into* "nothing happened", and this document is the evidence somebody's first weeks* are going well, so that is the version of the prime invariant that actually* misleads a person rather than merely a spreadsheet.*/describe('the seven things that must not look alike', () => {const states = {written: () => buildRecap(PLAN({ days: [written(1, TODAY)] }), parseWindow('1', TODAY)),noRecap: () => buildRecap(PLAN({ days: [session(1, TODAY)] }), parseWindow('1', TODAY)),noAccomplished: () =>buildRecap(PLAN({ days: [written(1, TODAY, { accomplished: null })] }),parseWindow('1', TODAY),),emptyWindow: () =>buildRecap(PLAN({ days: [written(1, '2026-09-01')] }), parseWindow('3', TODAY)),noDays: () => buildRecap(PLAN({ days: [standing()] }), parseWindow('3', TODAY)),noRecapAnywhere: () =>buildRecap(PLAN({ days: [session(1, TODAY), session(2, TODAY)] }), parseWindow('7', TODAY)),}it('a day with no recap still RENDERS, so the day count cannot shrink', () => {const m = states.noRecap()expect(m.blocks).toHaveLength(1)expect(m.blocks[0]!.kind).toBe('none')expect(recapText(m)).toMatch(/no recap has been written for this day yet/i)// And it carries the evidence that the day happened at all.expect(recapText(m)).toMatch(/the training plan records 1 topic under it/i)})it('a null accomplished says "not recorded", never a bare label', () => {const t = recapText(states.noAccomplished())expect(t).toMatch(/accomplished: not recorded for this day/i)expect(t).not.toMatch(/Accomplished:\s*$/m)})it('an empty window names the most recent day, so the next move is obvious', () => {const t = recapText(states.emptyWindow())expect(t).toMatch(/no training days fall in this window/i)expect(t).toContain('Day 1')})it('a plan with no training days says something DIFFERENT, with no window language', () => {const t = recapText(states.noDays())expect(t).toMatch(/no training days are recorded on this plan yet/i)expect(t).not.toMatch(/in this window/i)})it('days with no recap anywhere still list the days', () => {const m = states.noRecapAnywhere()expect(m.empty).toMatch(/no recap has been written for this plan yet/i)expect(m.blocks).toHaveLength(2)})it('no visible plan exports nothing, rather than an empty document', () => {expect(recapTextAll([], parseWindow('7', TODAY))).toMatch(/no onboarding plans/i)})it('produces a DISTINCT rendering for every one of them', () => {// The test that catches a future refactor folding two absences together.const seen = new Map<string, string>()for (const [name, build] of Object.entries(states)) {const line = recapText(build())expect(seen.has(line), `${name} reads identically to ${seen.get(line)}`).toBe(false)seen.set(line, name)}expect(seen.size).toBe(Object.keys(states).length)})})describe('it never claims fewer training days than happened', () => {it('renders one block per training day in the window, written or not', () => {const p = PLAN({ days: [written(1, '2026-09-14'), session(2, '2026-09-15'), standing()] })const m = buildRecap(p, parseWindow('all', TODAY))expect(m.blocks).toHaveLength(2)expect(m.blocks.map((b) => b.kind)).toEqual(['recap', 'none'])})it('states coverage at EVERY window, including when nothing was withheld', () => {// Rule 9: "3 of 3" is a claim worth making out loud. Leaving it off when// nothing is missing means completeness has to be inferred from silence,// and silence is what a bug looks like too.for (const w of [...EXPORT_WINDOWS.map(String), 'all']) {const win = parseWindow(w, TODAY)const m = buildRecap(plan, win)expect(m.coverage, w).toContain(`Covering ${m.blocks.length} of ${sliceFor(plan, win).totalSessions}`,)}})it('never says the recap points are a truncation of the plan topics', () => {// Eight curated points are not "the first 8 of 11", and "… and 3 more"// would promise a list the reader cannot reach. The disclosure is made// ONCE, in the footer, because it is a fact about the document.const m = buildRecap(plan, parseWindow('all', TODAY))expect(m.coverage).toMatch(/curated write-ups/i)const text = recapText(m)expect((text.match(/curated write-ups/gi) ?? []).length).toBe(1)for (const b of m.blocks) {if (b.kind !== 'recap') continuefor (const s of b.sessions) expect(s.points.join(' ')).not.toMatch(/and \d+ more/)}})it('still truncates-and-labels the ONE list that IS a truncation', () => {// Rule 5 keeps a home: the plan-topic evidence line under a day nobody has// written up yet.const many = Array.from({ length: EXPORT_BRIEF_TOPICS + 3 }, (_, i) => `t${i}`)const m = buildRecap(PLAN({ days: [session(1, TODAY, many)] }), parseWindow('1', TODAY))const b = m.blocks[0]!expect(b.kind).toBe('none')if (b.kind === 'none') {expect(b.sentence).toContain('and 3 more')expect(b.sentence).toContain(`${many.length} topics`)}})})describe('the subtitle', () => {it('never asserts a range wider than what it shows', () => {for (const w of [...EXPORT_WINDOWS.map(String), 'all']) {const win = parseWindow(w, TODAY)const dates = sliceFor(plan, win).sessions.map((d) => d.date!)if (!dates.length) continueconst sub = buildRecap(plan, win).subtitle!expect(sub, w).toContain(String(Number(dates[0]!.slice(8))))expect(sub, w).toContain(String(Number(dates.at(-1)!.slice(8))))}})it('formats one day, a same-month span, a cross-month span and a cross-year span', () => {const d = (x: string) => session(1, x)expect(recapRange([d('2026-09-14')])).toBe('September 14, 2026')expect(recapRange([d('2026-09-14'), d('2026-09-16')])).toBe('September 14 to 16, 2026')expect(recapRange([d('2026-09-28'), d('2026-10-02')])).toBe('September 28 to October 2, 2026')expect(recapRange([d('2026-12-30'), d('2027-01-02')])).toBe('December 30, 2026 to January 2, 2027',)expect(recapRange([])).toBeNull()})it('takes the role from the roster rather than storing a second spelling', () => {// The role comes from the roster: one fact, one place.expect(buildRecap(plan, parseWindow('all', TODAY)).subtitle).toContain(plan.role)})})describe('the document and the clipboard say the same thing', () => {it('puts every sentence of the model into both renderers', () => {// What "build once, render twice" buys, and it is only true if something// checks it.for (const w of ['1', '7', 'all']) {const m = buildRecap(plan, parseWindow(w, TODAY))const html = recapHtml(m)const text = recapText(m)for (const s of sentencesOf(m)) {expect(html, `html/${w}: ${s}`).toContain(esc(s))expect(text, `text/${w}: ${s}`).toContain(s)}}})it('escapes once in HTML and leaves the text raw, so a pasted apostrophe is one', () => {// The recap prose is dense with them ("the client's order history", "a// client's stated budget").const p = PLAN({days: [written(1, TODAY, { sessions: [{ points: ["a client's budget & scope"] }] })],})const m = buildRecap(p, parseWindow('1', TODAY))expect(recapHtml(m)).toContain('a client's budget & scope')expect(recapHtml(m)).not.toContain('&#39;')expect(recapText(m)).toContain("a client's budget & scope")})})describe('who can run it', () => {const viewers = [...MERIDIAN_PEOPLE.map((p) => viewerFor(p.email)),COMPANY_VIEWER,viewerFor(null),viewerFor('stranger@example.com'),]it('never lets the gate and the content disagree', () => {for (const v of viewers) {expect(hasOnboarding(v), v.email ?? 'anonymous').toBe(onboardingFor(v).length > 0)}})it('gives everyone who gets in exactly the plans they may already read', () => {for (const v of viewers) {const visible = onboardingFor(v)const text = recapTextAll(visible, parseWindow('all', TODAY))for (const other of ONBOARDING) {const mayRead = visible.some((p) => p.person === other.person)const doc = other.recapDoc?.titleif (doc) expect(text.includes(doc), `${v.email ?? 'anonymous'} / ${other.person}`).toBe(mayRead)}if (!visible.length) expect(text).toMatch(/no onboarding plans/i)}})it('lets Nora run it, and Rosa read her own', () => {// Anyone who asks the morning question can run it themselves, the trainee// included.for (const email of ['nora.l@meridiandisplay.example', 'accounts@meridiandisplay.example']) {const v = viewerFor(email)expect(hasOnboarding(v), email).toBe(true)expect(recapTextAll(onboardingFor(v), parseWindow('7', TODAY)), email).toContain('Training Recap',)}})})describe('the page is actually styled where it is actually served', () => {const served = THEME_CSS + INBOX_CSS_FOR_TEST + EXPORT_CSSit('defines every class the recap page emits', () => {const html = recapPage(ONBOARDING, parseWindow('7', TODAY), '7')const used = new Set([...html.matchAll(/class="([^"]+)"/g)].flatMap((m) => m[1]!.trim().split(/\s+/)),)expect(used.size).toBeGreaterThan(4)for (const cls of used) {expect(served,`.${cls} is emitted by the recap page but styled nowhere it is served`,).toContain(`.${cls}`)}})it('puts the print rules in the sheet the Worker serves, not the board-only one', () => {// QUEUE_CSS never reaches a Worker-served page, and the .ob-* rules already// in it invite putting export rules there by mistake.expect(EXPORT_CSS).toContain('@page')expect(EXPORT_CSS).toContain('@media print')expect(QUEUE_CSS).not.toContain('.rc-')})it('does not need background printing to be legible', () => {// Browsers default to NOT printing backgrounds, so a document leaning on// fills prints wrong for most people the first time.//// Removing a fill is fine and is not what this guards: `background:none`// is how the on-screen card stops being a card on paper. What must not// appear is a fill the layout depends on, or print-color-adjust forcing the// reader's settings.const block = EXPORT_CSS.slice(EXPORT_CSS.indexOf('@media print'))const fills = [...block.matchAll(/background[^;}]*/g)].map((m) => m[0])for (const f of fills) expect(f, f).toMatch(/:\s*(none|transparent)\s*$/)expect(block).not.toMatch(/print-color-adjust/)})it('keeps the document out of a .card, so a long recap can paginate', () => {// THEME_CSS's print block sets .card{break-inside:avoid}; the wrapper holds// the WHOLE document, which runs to several pages once a few days are in.// Asking a box taller than the sheet not to break produces a worse break// than letting it.//// Asserted on the PAGE, not just the document fragment: the fragment never// had a card, and the first version of this wrapper did.const page = recapPage(ONBOARDING, parseWindow('all', TODAY), 'all')expect(page).toContain('<article class="rc-doc">')const shell = page.slice(page.indexOf('rc-shell'))expect(shell.slice(0, shell.indexOf('</div>'))).not.toContain('class="card"')// And the only `card` on the page is nowhere near the document.expect(page).not.toMatch(/class="card[^"]*"[\s\S]{0,200}rc-doc/)})})describe('the links on the page carry what they say', () => {const unescape = (s: string): string =>s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")const parse = (html: string): URL[] =>[...html.matchAll(/href="([^"]+)"/g)].map((m) => unescape(m[1]!)).filter((h) => h.startsWith('/onboarding/export')).map((h) => new URL(h, 'https://board.example'))it('never emits a parameter the Worker would not recognise', () => {// `amp;days` is what a double-escape produces and is indistinguishable// from a typo at the far end.const allowed = new Set(['days', 'format'])for (const u of parse(recapPage(ONBOARDING, parseWindow('7', TODAY), '7'))) {for (const key of u.searchParams.keys()) {expect(allowed.has(key), `${u.href} carries "${key}"`).toBe(true)}}})it('offers every window, and a plain-text twin of the one you are on', () => {const links = parse(recapPage(ONBOARDING, parseWindow('3', TODAY), '3'))expect(links.filter((u) => !u.searchParams.has('format'))).toHaveLength(EXPORT_WINDOWS.length + 1,)const txt = links.filter((u) => u.searchParams.get('format') === 'txt')expect(txt).toHaveLength(1)expect(txt[0]!.searchParams.get('days')).toBe('3')})it('round-trips every link back through the parser it ships with', () => {for (const u of parse(recapPage(ONBOARDING, parseWindow('3', TODAY), '3'))) {expect(parseWindow(u.searchParams.get('days'), TODAY).fellBack, u.href).toBe(false)}})it('says so when a retired parameter is passed, rather than ignoring it', () => {// `&detail=full` links exist for a retired control; the page acknowledges// the parameter rather than ignoring it.expect(recapPage(ONBOARDING, parseWindow('7', TODAY), '7', true)).toContain('no longer does')expect(recapPage(ONBOARDING, parseWindow('7', TODAY), '7', false)).not.toContain('no longer does',)})})describe('the committed plan', () => {it('has a recap on every training day, and none on any standing group', () => {for (const d of plan.days) {if (d.day === null) expect(d.recap, d.title).toBeUndefined()else expect(d.recap, d.title).toBeDefined()}})it('renders as the document it was seeded from', () => {// DERIVED, not pinned at 3: every training day in the window gets a block,// and every one of them is written up. A hardcoded count here measures only// how long ago the test was written.const win = parseWindow('all', TODAY)const m = buildRecap(plan, win)expect(m.title).toBeTruthy()expect(m.blocks).toHaveLength(sliceFor(plan, win).sessions.length)expect(m.blocks.length).toBeGreaterThan(0)expect(m.blocks.every((b) => b.kind === 'recap')).toBe(true)expect(m.empty).toBeNull()})})