own-list.test.ts
Language TypeScript14.9 KB371 lines
/*** The private list, rendered.** Not the prose, which may be rewritten freely. What is pinned is the set of things that would be a bug rather than a rewrite: that a* viewer with no list gets nothing rather than an empty card; that a store* belonging to somebody else refuses to render; that the four facts a read can* report stay four different sentences; that "overdue" is decided from the clock* the page was served with; and that the numbers in the header can be accounted* for by the rows under them (rule 9).*/import { describe, expect, it } from 'vitest'import {OWN_END,OWN_START,dueState,ownListBlock,ownListPlaceholder,parseOwnOutcome,sortOpen,stampOwnList,type OwnItem,type OwnStore,} from '../src/own-list-view.js'import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'/*** Collapse runs of whitespace before asserting on prose.** The sentences below are written across several source lines, and a browser* collapses that run to one space, so a raw substring match would be asserting* the shape of the template literal rather than what the reader sees, and would* fail the next time somebody rewrapped a paragraph.*/const flat = (html: string): string => html.replace(/\s+/g, ' ')const SAM = viewerFor('hello@meridiandisplay.example')const MARCO = viewerFor('studio@meridiandisplay.example')/** A real address on the inbox lists that is NOT a roster person. */const SUPPORT = viewerFor('help@meridiandisplay.example')/** 10:00 AM Chicago on Wednesday 23 September 2026. */const NOW = new Date('2026-09-23T15:00:00.000Z')let nextId = 1const item = (over: Partial<OwnItem> = {}): OwnItem => ({id: nextId++,text: 'Call the gym back about the backdrop proof',due: null,priority: 'normal',linkKey: null,createdAt: '2026-09-20T12:00:00.000Z',updatedAt: '2026-09-20T12:00:00.000Z',edits: 0,doneAt: null,...over,})const ok = (open: OwnItem[] = [], done: OwnItem[] = [], person = 'Sam Whitaker'): OwnStore => ({state: 'ok',person,open,done,truncated: false,doneHidden: 0,})describe('who gets a list at all', () => {it('a roster person does', () => {expect(ownListBlock(SAM, ok(), NOW)).toContain('My own list')})it('every one of the seven does, with no config line written for any of them', () => {// No grant map. config/own-list.ts has the argument: rule 13 guards// permission over other people's things, and there is nobody to over-grant// to on a store with one reader. A hand-written list here invites the// failure where correct, reviewed, obviously-intended lines do nothing.for (const email of ['hello@meridiandisplay.example','accounts@meridiandisplay.example','studio@meridiandisplay.example','orderdesk@meridiandisplay.example','projects@meridiandisplay.example','frontdesk@meridiandisplay.example','nora.l@meridiandisplay.example',]) {expect(ownListBlock(viewerFor(email), ok(), NOW)).toContain('My own list')}})it('an off-roster shared mailbox gets NOTHING: not an empty card, not a hidden div', () => {// help@ and service@ are real addresses with real inbox grants and no// roster person. A private list on a shared mailbox is a list everyone who// signs into it can read.expect(SUPPORT.person).toBeNull()expect(ownListBlock(SUPPORT, ok(), NOW)).toBe('')expect(ownListBlock(COMPANY_VIEWER, ok(), NOW)).toBe('')})it('refuses to render a store that belongs to somebody else', () => {// Unreachable through any current call path, and checked anyway: the cost// of the refactor that breaks it is one person reading another's private// list, and the cost of the check is a string comparison.const out = ownListBlock(SAM, ok([item({ text: 'Marco’s line' })], [], 'Marco Ibáñez'), NOW)expect(out).not.toContain('Marco’s line')expect(flat(out)).toContain('refused to render')})it('never puts one person’s text in another person’s block', () => {const mine = ownListBlock(SAM, ok([item({ text: 'SAM-SENTINEL' })]), NOW)const theirs = ownListBlock(MARCO, ok([item({ text: 'MARCO-SENTINEL' })], [], 'Marco Ibáñez'), NOW)expect(mine).toContain('SAM-SENTINEL')expect(mine).not.toContain('MARCO-SENTINEL')expect(theirs).toContain('MARCO-SENTINEL')expect(theirs).not.toContain('SAM-SENTINEL')})})describe('the four facts a read can report', () => {const states: Record<string, OwnStore> = {unconfigured: { state: 'unconfigured' },failed: { state: 'failed', error: 'D1_ERROR: no such table: own_item' },empty: ok(),full: ok([item()]),}it('renders four different things, and none of them is silence', () => {const rendered = Object.values(states).map((s) => ownListBlock(SAM, s, NOW))for (const r of rendered) expect(r.length).toBeGreaterThan(0)expect(new Set(rendered).size).toBe(4)})it('an unconfigured store says it is a deploy step and offers no form', () => {// A form that could only lose what was typed into it is worse than none.// It is the same call bootNotes makes when the notes tag is absent.const out = ownListBlock(SAM, states.unconfigured!, NOW)expect(flat(out)).toContain('not set up')expect(out).toContain('CHECKINS')expect(out).not.toContain('<form')})it('a failed store says missing, not empty, and still offers the form', () => {// A read failure is not a write failure. Hiding the box guarantees the loss// that showing it only risks.const out = ownListBlock(SAM, states.failed!, NOW)expect(out).toContain('no such table: own_item')expect(flat(out)).toContain('missing, not empty')expect(out).toContain('<form')})it('neither absence prints a count', () => {// A store that could not be read has no number. "0 open" here would be the// prime invariant broken on the one surface where the reader is the only// person who could notice.for (const s of [states.unconfigured!, states.failed!]) {const out = ownListBlock(SAM, s, NOW)expect(out).toContain('not read')expect(out).not.toMatch(/\d+ open/)}})it('an empty list says it is empty BECAUSE NOTHING WAS WRITTEN, which is a true zero', () => {// Everywhere else on this board an empty list is a claim about the world// and must be kept apart from "not tracked". Here it is a claim about the// owner, and saying so is what stops it reading like every other absence.const out = ownListBlock(SAM, states.empty!, NOW)expect(flat(out)).toContain('Nothing on your own list')expect(flat(out)).toContain('have not added anything')expect(out).toContain('<form')})})describe('dates, decided from the clock the page was served with', () => {it('no date is never overdue', () => {expect(dueState(null, '2026-09-23')).toBe('none')})it('tells overdue, today and later apart', () => {expect(dueState('2026-09-22', '2026-09-23')).toBe('overdue')expect(dueState('2026-09-23', '2026-09-23')).toBe('today')expect(dueState('2026-09-24', '2026-09-23')).toBe('later')})it('the same store renders differently on two days', () => {// This is why the block is stamped at serve time rather than pre-rendered:// an overdue flag decided by the 6 a.m. cron would be wrong by lunchtime,// and silently wrong, which is the worst kind.const store = ok([item({ due: '2026-09-23' })])const onTheDay = ownListBlock(SAM, store, NOW)const theNextWeek = ownListBlock(SAM, store, new Date('2026-09-30T15:00:00.000Z'))expect(onTheDay).toContain('Due today')expect(onTheDay).not.toContain('1 overdue')expect(theNextWeek).toContain('Overdue')expect(theNextWeek).toContain('1 overdue')})it('crosses midnight in Meridian time, not UTC', () => {// 00:30 UTC on the 24th is still 7:30 PM Chicago on the 23rd, so a line due// on the 23rd is due TODAY and not yet late.const store = ok([item({ due: '2026-09-23' })])expect(ownListBlock(SAM, store, new Date('2026-09-24T00:30:00.000Z'))).toContain('Due today')})it('sorts dated before undated, soonest first, priority breaking ties', () => {const a = item({ due: '2026-09-25', priority: 'low' })const b = item({ due: null, priority: 'high' })const c = item({ due: '2026-09-24', priority: 'normal' })const d = item({ due: '2026-09-25', priority: 'high' })expect(sortOpen([a, b, c, d]).map((x) => x.id)).toEqual([c.id, d.id, a.id, b.id])})})describe('the number and the list under it account for each other (rule 9)', () => {const countRows = (html: string): number => (html.match(/class="own-row own-row-(?!done)/g) ?? []).lengthit('the open count equals the rows rendered', () => {const store = ok([item({ due: '2026-09-20' }),item({ due: '2026-09-23' }),item({ due: '2026-09-30' }),item({ due: null }),])const out = ownListBlock(SAM, store, NOW)expect(out).toContain('4 open')expect(countRows(out)).toBe(4)expect(flat(out)).toContain('4 open, and all of them are listed above')})it('overdue is a labelled subset, never a fourth addend', () => {const store = ok([item({ due: '2026-09-20' }), item({ due: '2026-09-30' })])const out = ownListBlock(SAM, store, NOW)expect(out).toContain('2 open')expect(out).toContain('1 overdue')expect(flat(out)).toContain('past its date and counted in both numbers')expect(countRows(out)).toBe(2)})it('says this list is NOT inside "on my board" at the top of the card', () => {// The risk here is not a number above an empty list. It is a reader seeing// "on my board 4" and "3 open" and having no way to know whether the 3 is// inside the 4.const out = ownListBlock(SAM, ok([item()]), NOW)expect(out).toContain('on my board')expect(flat(out)).toContain('is not a source')})it('says the right thing when "on my board" is a dash rather than a count', () => {// Theo and Ines hold no Plinth assignment, so their load is null and the// tile reads "–". A sentence claiming this list is excluded from a number// that is not there would be nonsense.const out = ownListBlock(SAM, ok([item()]), NOW, null, true)expect(flat(out)).toContain('no source covers your work individually')expect(flat(out)).toContain('adding to it does not change that')})it('completed lines are folded, counted, and said to be in neither number', () => {const out = ownListBlock(SAM, ok([item()], [item({ doneAt: '2026-09-22T12:00:00.000Z' })]), NOW)expect(out).toContain('Done (1)')expect(flat(out)).toContain('folded below and in neither')})it('states how many completed lines are kept but not listed', () => {// Where a surface deliberately withholds a row it says how many and why:// the todosWithheld idiom, which rule 9 is written from.const store: OwnStore = {...ok([], [item({ doneAt: '2026-09-22T12:00:00.000Z' })]),doneHidden: 12,}const out = ownListBlock(SAM, store, NOW)expect(flat(out)).toContain('12 older completed')expect(flat(out)).toContain('Nothing was deleted')})it('a truncated read says so rather than passing a short list off as whole', () => {const store: OwnStore = { ...ok([item()]), truncated: true }expect(ownListBlock(SAM, store, NOW)).toContain('should not be possible')})})describe('outcomes', () => {it('narrows against an allow-list, so only our own words reach the page', () => {expect(parseOwnOutcome('added')).toBe('added')expect(parseOwnOutcome('<img src=x onerror=1>')).toBeNull()expect(parseOwnOutcome('')).toBeNull()expect(parseOwnOutcome(null)).toBeNull()expect(parseOwnOutcome('nope')).toBeNull()})it('says loudly when a write did not land', () => {// A banner that is merely absent reads as success, on the one action whose// failure costs somebody the thing they just typed.const out = ownListBlock(SAM, ok(), NOW, 'failed')expect(flat(out)).toContain('<b>not</b> saved')expect(flat(out)).toContain('Copy what you typed')})it('says completing does not throw anything away', () => {expect(ownListBlock(SAM, ok(), NOW, 'completed')).toContain('never throws it away')})it('says a refused add was not trimmed to fit', () => {expect(ownListBlock(SAM, ok(), NOW, 'full')).toContain('nothing was trimmed')expect(ownListBlock(SAM, ok(), NOW, 'toolong')).toContain('rather than saving part of it')})})describe('user text cannot become markup', () => {const nasty = '</textarea><script>alert(1)</script>"><b>x'it('not in a row', () => {const out = ownListBlock(SAM, ok([item({ text: nasty })]), NOW)expect(out).not.toContain('<script>')expect(out).toContain('<script>')})it('not in the edit box', () => {const out = ownListBlock(SAM, ok([item({ text: nasty })]), NOW)expect(out).not.toContain('</textarea><script>')})it('not in the store’s error message', () => {const out = ownListBlock(SAM, { state: 'failed', error: '<script>alert(1)</script>' }, NOW)expect(out).not.toContain('<script>alert(1)</script>')})it('and a malformed link renders as nothing rather than as a chip of garbage', () => {const out = ownListBlock(SAM, ok([item({ linkKey: 'javascript:alert(1)' })]), NOW)expect(out).not.toContain('javascript:')expect(out).not.toContain('own-link')})it('a well-formed link renders as a label, not a link', () => {const out = ownListBlock(SAM, ok([item({ linkKey: 'order:MD2639582' })]), NOW)expect(out).toContain('Order MD2639582')expect(out).not.toContain('<a ')})})describe('the placeholder and the stamp', () => {it('the placeholder is not empty, and says nothing private is in the file', () => {// Rule 8. An empty div here is a feature that silently is not there, and// whoever is looking at a dumped variant needs to know at a glance that no// private text is in it.const p = ownListPlaceholder()expect(p.startsWith(OWN_START)).toBe(true)expect(p.endsWith(OWN_END)).toBe(true)expect(flat(p)).toContain('lives on the live board')expect(flat(p)).toContain('Nothing of yours is in this file')})it('stamping swaps the placeholder for the block', () => {const page = `<main>before${ownListPlaceholder()}after</main>`const out = stampOwnList(page, ownListBlock(SAM, ok([item({ text: 'SENTINEL' })]), NOW))expect(out).toContain('SENTINEL')expect(out).not.toContain(OWN_START)expect(out).toContain('before')expect(out).toContain('after')})it('a page with no markers comes back byte-identical', () => {const page = '<main>nothing to stamp</main>'expect(stampOwnList(page, 'anything')).toBe(page)})it('a viewer with no list stamps an empty string over the markers', () => {const page = `<main>${ownListPlaceholder()}</main>`expect(stampOwnList(page, ownListBlock(COMPANY_VIEWER, ok(), NOW))).toBe('<main></main>',)})})