my-day.test.ts
Language TypeScript14.6 KB360 lines
/*** My Day, and the number above the list.** The header counts Ninety to-dos into the load, so the list must include them* too; a card built from order lines and Bitrix tasks alone prints a number and* then says "Nothing on this board right now." Several people hold no order* lines at all, so for them it would always be a number above an empty list* (rule 9).** These tests assert the reconciliation rather than any wording: whatever the* header counts, the card must be able to account for, either by listing it or* by saying out loud that it did not.*/import { describe, expect, it } from 'vitest'import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'import { headline, type Snapshot } from '../src/collect.js'import { buildViewModel, todoItem } from '../src/view-model.js'import { render } from '../src/render.js'import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'import { esc } from '../src/html.js'import { OWN_END, OWN_START } from '../src/own-list-view.js'import { MERIDIAN_PEOPLE } from '../../../config/meridian-people.js'import type { Todo } from '@meridian/ninety-client'const DAY = '2026-08-12'const emailOf = (name: string): string => {const p = MERIDIAN_PEOPLE.find((x) => x.name === name)if (!p?.email) throw new Error(`no email for ${name}`)return p.email}const vmFor = (name: string, snap: Snapshot = FIXTURE_SNAPSHOT) =>buildViewModel(snap, headline(snap), [], DAY, viewerFor(emailOf(name)))const cardFor = (name: string, snap: Snapshot = FIXTURE_SNAPSHOT) => {const card = vmFor(name, snap).myDays.find((d) => d.person.name === name)if (!card) throw new Error(`no My Day card for ${name}`)return card}/** A to-do shaped like a live one, so the roster join is exercised for real. */const todoOf = (over: Partial<Todo>): Todo => ({id: 't-x',title: 'A to-do',description: '',descriptionHtml: '',dueDate: null,dueDateLocal: null,dueLabel: '',completed: false,completedDate: null,archived: false,deleted: false,owner: { id: 'u-x', name: 'Someone' },createdBy: { id: 'u-x', name: 'Someone' },teamId: null,teamName: null,isPersonal: false,repeat: null,seriesId: null,milestoneId: null,followers: [],createdAt: null,raw: { _id: 't-x' } as Todo['raw'],...over,})const withTodos = (todos: Todo[]): Snapshot => ({...FIXTURE_SNAPSHOT,ninety: { ok: true, data: todos, error: null },})describe('the header number and the list agree', () => {it('accounts for every to-do it counts, on every card', () => {// The bug, stated as arithmetic. `load` counts orders + tasks + to-dos; the// card must either list a to-do or declare it withheld.for (const p of MERIDIAN_PEOPLE) {const d = cardFor(p.name)expect(d.todos + d.todosWithheld).toBe(d.person.todos ?? 0)}})it('gives Ines, Theo and Nora a list instead of a number over nothing', () => {// These three hold no Plinth assignment, so without to-dos their card could// never show a row, whatever they were carrying.for (const name of ['Ines Carvalho', 'Theo Bassett', 'Nora Lindqvist']) {const d = cardFor(name)expect(d.person.plinthOurMove).toBeNull()expect(d.todos).toBeGreaterThan(0)expect(d.items.length).toBeGreaterThanOrEqual(d.todos)}})it('puts the to-do titles in the bytes of the page that person is served', () => {// The view model agreeing is not the same as the page showing it.const out = render(FIXTURE_SNAPSHOT,headline(FIXTURE_SNAPSHOT),[],DAY,null,viewerFor(emailOf('Nora Lindqvist')),)for (const t of cardFor('Nora Lindqvist').items) expect(out).toContain(esc(t.what))})})describe('to-dos are attributed through the roster, not by spelling', () => {it('matches on the pinned Ninety id when the display name has drifted', () => {// Ninety says "Samuel Whitaker" where the roster says "Sam Whitaker". An// equality join on the name would drop them to zero, silently.const sam = MERIDIAN_PEOPLE.find((p) => p.name === 'Sam Whitaker')!const snap = withTodos([todoOf({id: 'drifted',title: 'Owned by a drifted spelling',owner: { id: sam.ninetyId!, name: 'Samuel Whitaker' },}),])expect(cardFor('Sam Whitaker', snap).items.map((i) => i.what)).toContain('Owned by a drifted spelling',)})it('leaves an unmapped owner off every card rather than guessing one', () => {const snap = withTodos([todoOf({ id: 'orphan', title: 'Nobody owns this', owner: { id: 'u-ghost', name: 'A Ghost' } }),])for (const p of MERIDIAN_PEOPLE) {expect(cardFor(p.name, snap).items.map((i) => i.what)).not.toContain('Nobody owns this')}})})describe("another person's card carries no to-do titles", () => {it('withholds them and says how many, rather than dropping them', () => {// My Day renders only the viewer's own card today. This is the guard for the// day someone adds a surface that renders another person's. The previous// design of this page shipped every person's card in one document.const vm = vmFor('Nora Lindqvist')const other = vm.myDays.find((d) => d.person.name === 'Marco Ibáñez')!expect(other.todos).toBe(0)expect(other.todosWithheld).toBe(other.person.todos ?? 0)expect(other.todosWithheld).toBeGreaterThan(0)})it('discloses nothing to a caller that forgets the viewer argument', () => {const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY)for (const d of vm.myDays) expect(d.todos).toBe(0)})it('does not put another person’s to-do title in the page bytes', () => {const noraTitles = cardFor('Nora Lindqvist').items.map((i) => i.what)const out = render(FIXTURE_SNAPSHOT,headline(FIXTURE_SNAPSHOT),[],DAY,null,viewerFor(emailOf('Marco Ibáñez')),)const marcoTitles = new Set(cardFor('Marco Ibáñez').items.map((i) => i.what))for (const title of noraTitles) {if (marcoTitles.has(title)) continue // genuinely shared work, both own itexpect(out).not.toContain(esc(title))}})})describe('a dead source is not a clear desk', () => {it('names the failed source instead of reporting an empty board', () => {// A 403 from /todos/company is a role problem, and Nora's whole load comes// through it. Rendering that as "nothing to do" is the prime invariant// inverted.// Both of Nora's sources are cut, not just Ninety: she holds a tracker row// too, so a Ninety-only outage would not empty her card. The property under test is that an// empty list caused by a dead source never reads as a clear desk.const down: Snapshot = {...FIXTURE_SNAPSHOT,ninety: { ok: false, data: [], error: 'HTTP 403' },sheet: { ok: false, data: [], error: 'HTTP 403' },}const d = cardFor('Nora Lindqvist', down)expect(d.items).toHaveLength(0)expect(d.sourcesDown).toContain('Ninety to-dos')const out = render(down, headline(down), [], DAY, null, viewerFor(emailOf('Nora Lindqvist')))expect(out).not.toContain('Nothing on this board right now.')})it('reports a clear desk as a clear desk when every source is up', () => {const quiet: Snapshot = {...FIXTURE_SNAPSHOT,ninety: { ok: true, data: [], error: null },tasks: { ok: true, data: [], error: null },orderLines: { ok: true, data: [], error: null },sheet: { ok: true, data: [], error: null },}expect(cardFor('Nora Lindqvist', quiet).sourcesDown).toHaveLength(0)})})describe('late means past a review gate, not overdue by the calendar', () => {it('does not call a to-do late before its first Monday', () => {// Nothing at Meridian closes outside a Monday review, so raw overdue counts// inflate every weekend and reset every Monday, which trains people to// ignore the number. Due Saturday 2026-08-08, read Sunday the 9th: overdue// by the calendar, but no review has passed, so it is not late.const t = todoOf({ dueDate: '2026-08-09T04:59:59.999Z', dueDateLocal: '2026-08-08' })const sunday = todoItem(t, new Date('2026-08-09T18:00:00Z'))expect(sunday.late).toBe(false)expect(sunday.age).toBe('1d over')// Same to-do, read after the Monday review it survived.const tuesday = todoItem(t, new Date('2026-08-11T18:00:00Z'))expect(tuesday.late).toBe(true)expect(tuesday.meta).toContain('past 1 review gate')})it('says "no due date" rather than inventing one', () => {const undated = todoItem(todoOf({}), new Date('2026-08-11T18:00:00Z'))expect(undated.age).toBe('–')expect(undated.late).toBe(false)expect(undated.meta).toContain('no due date')})it('counts late to-dos in the review-gate total under the list', () => {// lateCount drove a footer sentence built from Bitrix tasks only, so a card// could print "nothing is past a review gate" above an overdue to-do.const d = cardFor('Sam Whitaker')const lateItems = d.items.filter((i) => i.late)expect(d.lateCount).toBe(lateItems.length)expect(lateItems.some((i) => i.meta.includes('Ninety to-do'))).toBe(true)})})describe('recurrence is not collapsed away', () => {it('lists both instances of a series, because the header counts both', () => {// One of Ines's live to-dos appears twice, due a week apart. todoLoad()// counts every instance; collapsing only the LIST would put the number and// the list back out of agreement, which is the bug this file is about.const nora = MERIDIAN_PEOPLE.find((p) => p.name === 'Nora Lindqvist')!const snap = withTodos([todoOf({id: 'r1',title: 'Weekly image review',owner: { id: nora.ninetyId!, name: 'Nora Lindqvist' },seriesId: 's-1',repeat: 'Weekly',dueDate: '2026-08-08T04:59:59.999Z',dueDateLocal: '2026-08-07',}),todoOf({id: 'r2',title: 'Weekly image review',owner: { id: nora.ninetyId!, name: 'Nora Lindqvist' },seriesId: 's-1',repeat: 'Weekly',dueDate: '2026-08-15T04:59:59.999Z',dueDateLocal: '2026-08-14',}),])const d = cardFor('Nora Lindqvist', snap)expect(d.items.filter((i) => i.what === 'Weekly image review')).toHaveLength(2)expect(d.todos).toBe(d.person.todos)// And the repeat is stated, so a title appearing twice explains itself.// Scoped to the to-do rows: the card also carries tracker and order rows,// and asserting over all of them tested the fixture rather than the rule.for (const i of d.items.filter((x) => x.meta.startsWith('Ninety to-do'))) {expect(i.meta).toContain('Weekly')}})})describe('an order and a to-do are not the same measurement', () => {it('labels a to-do row by its due date, never as a bare age', () => {// The age column shows "34d" for an order line, meaning days waiting. A// to-do showing a bare "9d" in the same column invites a comparison between// two different things.const d = cardFor('Sam Whitaker')for (const i of d.items.filter((x) => x.meta.includes('Ninety to-do'))) {expect(i.age).toMatch(/over$|^due in |^–$/)}})})describe('COMPANY_VIEWER has no card of its own', () => {it('builds no My Day for an address the roster does not know', () => {const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY, COMPANY_VIEWER)expect(COMPANY_VIEWER.ownBriefing).toBeNull()for (const d of vm.myDays) expect(d.todos).toBe(0)})})describe('the private list sits in this card, and only in its owner’s copy', () => {const pageFor = (email: string | null): string => {const viewer = email ? viewerFor(email) : COMPANY_VIEWERreturn render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY, null, viewer)}it('every roster viewer’s page carries the markers exactly once', () => {for (const p of MERIDIAN_PEOPLE) {if (!p.email) continueconst html = pageFor(p.email)expect(html.split(OWN_START)).toHaveLength(2)expect(html.split(OWN_END)).toHaveLength(2)}})it('an address the roster does not know carries them zero times', () => {// Not a hidden div and not an empty one: nothing at all. A private list on// a shared mailbox is a list everyone who signs into it can read.const html = pageFor(null)expect(html).not.toContain(OWN_START)expect(html).not.toContain(OWN_END)})it('the markers sit inside the VIEWER’S OWN card and no other', () => {// The guard that looks redundant. myDaySurface renders only the viewer's// own card, so today this cannot go wrong, and there is one marker pair// per page, into which the Worker stamps the viewer's lines. If anything// ever renders a second person's card, a placeholder sitting in it would// put the viewer's private list under somebody else's name. Asserted// against the rendered page rather than the private function, so it holds// however the card gets built.for (const p of MERIDIAN_PEOPLE) {if (!p.email) continueconst html = pageFor(p.email)const at = html.indexOf(OWN_START)expect(at).toBeGreaterThan(-1)const panelsBefore = [...html.slice(0, at).matchAll(/data-person-panel="([^"]*)"/g)]// The last card opened before the markers is the one they are inside.expect(panelsBefore.at(-1)?.[1]).toBe(esc(p.name))}})it('no private text is in the pre-rendered page, only the placeholder is', () => {// The pages this board serves are written to KV by a cron with no viewer// session, so whatever ever leaks out of KV cannot be somebody's list.const html = pageFor('hello@meridiandisplay.example')const between = html.slice(html.indexOf(OWN_START), html.indexOf(OWN_END))expect(between).toContain('live')expect(between).not.toContain('<form')})it('“on my board” is unchanged by the existence of a private list', () => {// The decision this pins: a private count folded into `load` would leak// into the workload panel and the company narration, which both read// `load`, and it would render a person no source covers as a number// instead of a dash, the prime invariant broken on the tile it was written// for. `load` must keep coming from the sources and nowhere else.const viewer = viewerFor('frontdesk@meridiandisplay.example')const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY, viewer)for (const d of vm.myDays) {expect(d.person.load).toBe(vm.people.find((p) => p.name === d.person.name)?.load)}})})