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

content-tracker.test.ts

Language TypeScript8.1 KB188 lines

apps/dashboard/test/content-tracker.test.ts

  1. /**
  2. * The Content Creation tracker as source five.
  3. *
  4. * Most internal tasks live in this spreadsheet and nowhere else, while Ninety
  5. * holds a single rollup to-do for a dozen image jobs. Without this source both
  6. * designers render as nearly idle.
  7. *
  8. * Every other source here is a system of record with
  9. * ids and timestamps. This one is a document people type into: owners are first
  10. * names, statuses are free text, deadlines are hand-typed in three formats, and
  11. * nothing reports when it was last edited. The tests below are about not
  12. * pretending otherwise.
  13. */
  14. import { describe, expect, it } from 'vitest'
  15. import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'
  16. import { headline, peopleBoard, trackerCounts, type Snapshot } from '../src/collect.js'
  17. import { buildViewModel } from '../src/view-model.js'
  18. import { viewerFor } from '../src/viewer.js'
  19. import { MERIDIAN_PEOPLE, personFromTrackerOwner } from '../../../config/meridian-people.js'
  20. import { classifyTrackerStatus } from '../../../config/content-tracker.js'
  21. const DAY = '2026-08-12'
  22. const emailOf = (n: string) => MERIDIAN_PEOPLE.find((p) => p.name === n)!.email!
  23. const vmFor = (n: string, snap: Snapshot = FIXTURE_SNAPSHOT) =>
  24. buildViewModel(snap, headline(snap), [], DAY, viewerFor(emailOf(n)))
  25. const cardFor = (n: string, snap: Snapshot = FIXTURE_SNAPSHOT) =>
  26. vmFor(n, snap).myDays.find((d) => d.person.name === n)!
  27. describe('owners are first names, and that is only safe because it is checked', () => {
  28. it('resolves the spellings the live sheet actually uses', () => {
  29. for (const [owner, name] of [
  30. ['Rafael', 'Rafael Montero'],
  31. ['Marco', 'Marco Ibáñez'],
  32. ['Ines', 'Ines Carvalho'],
  33. ['Sam', 'Sam Whitaker'],
  34. ['Theo', 'Theo Bassett'],
  35. ['Nora', 'Nora Lindqvist'],
  36. ]) {
  37. expect(personFromTrackerOwner(owner)?.name).toBe(name)
  38. }
  39. })
  40. it('has no two roster people sharing a first name', () => {
  41. // The day this stops being true, first-name matching starts attributing one
  42. // person's work to another and says nothing. config/meridian-people.ts throws at
  43. // load rather than letting that ship; this asserts the property directly so
  44. // the reason is findable from the test name.
  45. const firsts = MERIDIAN_PEOPLE.map((p) => p.name.split(' ')[0]!.toLowerCase())
  46. expect(new Set(firsts).size).toBe(firsts.length)
  47. })
  48. it('returns null for an owner nobody on the roster matches', () => {
  49. expect(personFromTrackerOwner('Freelancer')).toBeNull()
  50. expect(personFromTrackerOwner('')).toBeNull()
  51. })
  52. })
  53. describe('a blank Status is unclassified, never defaulted', () => {
  54. it('classifies the vocabulary the sheet uses and nothing else', () => {
  55. expect(classifyTrackerStatus('In Progress')).toBe('ours')
  56. expect(classifyTrackerStatus('Not Started')).toBe('ours')
  57. expect(classifyTrackerStatus('In Adjustments')).toBe('ours')
  58. expect(classifyTrackerStatus('Launched')).toBe('terminal')
  59. expect(classifyTrackerStatus('')).toBeNull()
  60. expect(classifyTrackerStatus('Something New')).toBeNull()
  61. })
  62. it('does not fold unclassified rows into open or done', () => {
  63. // Rule 2 in docs/ARCHITECTURE.md. `classify()` defaulted unknown lead stages
  64. // to `ours` "so they get noticed", nothing reported them, and most of the
  65. // pipeline became work the team owed. The counts must not add up without it.
  66. const t = trackerCounts(FIXTURE_SNAPSHOT)
  67. expect(t.unclassified).toBeGreaterThan(0)
  68. expect(t.ours + t.waiting + t.done + t.unclassified).toBe(t.rows)
  69. })
  70. it('names the unrecognised statuses on the page', () => {
  71. const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY)
  72. expect(vm.caveats.join(' ')).toMatch(/\(blank\)/)
  73. })
  74. })
  75. describe('"Ready to Launch" is context, not workload', () => {
  76. it('keeps it out of the load number', () => {
  77. // The design work is done and the launch is not. Counting it as backlog is
  78. // how a board makes someone look behind for something they delivered, for the
  79. // same reason waiting-on-client is not workload.
  80. const board = peopleBoard(FIXTURE_SNAPSHOT)
  81. const sam = board.rows.find((r) => r.name === 'Sam Whitaker')!
  82. expect(sam.sheetWaiting).toBeGreaterThan(0)
  83. const vm = vmFor('Sam Whitaker')
  84. const view = vm.people.find((p) => p.name === 'Sam Whitaker')!
  85. expect(view.load).toBe(
  86. (sam.plinthOurMove ?? 0) + (sam.taskCount ?? 0) + (sam.todos ?? 0) + (sam.sheetOurMove ?? 0),
  87. )
  88. })
  89. it('still says it out loud in the aside', () => {
  90. const view = vmFor('Sam Whitaker').people.find((p) => p.name === 'Sam Whitaker')!
  91. expect(view.aside).toMatch(/ready to launch/i)
  92. })
  93. it('does not list it as a row on My Day', () => {
  94. const card = cardFor('Sam Whitaker')
  95. expect(card.items.map((i) => i.what)).not.toContain('New image for the feather flag back view')
  96. })
  97. })
  98. describe('tracker work reaches the person it belongs to', () => {
  99. it('puts rows on My Day and reconciles with the header', () => {
  100. for (const p of MERIDIAN_PEOPLE) {
  101. const d = cardFor(p.name)
  102. expect(d.ourMove + d.tasks + d.todos + d.tracker).toBe(d.person.load ?? 0)
  103. }
  104. })
  105. it('gives both designers the work Ninety only rolled up', () => {
  106. for (const name of ['Rafael Montero', 'Marco Ibáñez']) {
  107. expect(cardFor(name).tracker).toBeGreaterThan(0)
  108. }
  109. })
  110. it('shows a shared row to every named owner, and says who else holds it', () => {
  111. // "Nora & Sam" is one photoshoot and two people who have to show up.
  112. for (const name of ['Nora Lindqvist', 'Sam Whitaker']) {
  113. const row = cardFor(name).items.find((i) => i.what === 'Photo shoot for every feather flag size')
  114. expect(row, name).toBeDefined()
  115. expect(row!.meta).toMatch(/with /)
  116. }
  117. })
  118. it('does not give a tracker row a fabricated age', () => {
  119. // The sheet has no created date and its deadlines are free text, so there is
  120. // no number that means what an order's "34d" means. An invented one would
  121. // sort against real ages and win.
  122. for (const i of cardFor('Marco Ibáñez').items.filter((x) => x.meta.startsWith('Tracker'))) {
  123. expect(i.age).toBe('–')
  124. expect(i.late).toBe(false)
  125. }
  126. })
  127. })
  128. describe('the source row is honest about what it cannot know', () => {
  129. const LIVE: Snapshot = { ...FIXTURE_SNAPSHOT, mode: 'live', fixtureCapturedAt: null }
  130. const note = (snap: Snapshot = LIVE) =>
  131. buildViewModel(snap, headline(snap), [], DAY).sources.find(
  132. (s) => s.name === 'Content Creation tracker',
  133. )!
  134. it('says staleness is not tracked rather than leaving it unsaid', () => {
  135. // The public CSV export sends no last-modified and the real modifiedTime
  136. // needs a credential the Worker does not have. The one input most likely to
  137. // go quietly stale is the one we can say the least about, so it says so.
  138. expect(note().note).toMatch(/not tracked/i)
  139. })
  140. it('does not call a hand-maintained sheet "live"', () => {
  141. // Every other pill means "we queried a system of record just now". Borrowing
  142. // the word here would make the least reliable input look like the most
  143. // current.
  144. expect(note().status).not.toMatch(/live/i)
  145. })
  146. it('reports a partial read as partial, and caveats every tracker number', () => {
  147. const partial: Snapshot = {
  148. ...LIVE,
  149. sheetTabs: {
  150. ok: [{ label: 'Images', gid: '0', rows: 12 }],
  151. failed: [{ label: 'Newsletter', gid: '9', reason: 'HTTP 404' }],
  152. },
  153. }
  154. expect(note(partial).tone).toBe('degraded')
  155. expect(note(partial).note).toMatch(/PARTIAL/)
  156. const vm = buildViewModel(partial, headline(partial), [], DAY)
  157. expect(vm.caveats.join(' ')).toMatch(/FLOOR/)
  158. })
  159. it('renders a dead sheet as failed, never as nobody having tracker work', () => {
  160. const down: Snapshot = { ...LIVE, sheet: { ok: false, data: [], error: 'HTTP 403' } }
  161. const vm = buildViewModel(down, headline(down), [], DAY)
  162. expect(note(down).tone).toBe('failed')
  163. expect(vm.failures.join(' ')).toMatch(/403/)
  164. // Not zero: the column is absent, because nothing can see it.
  165. for (const r of peopleBoard(down).rows) expect(r.sheetOurMove).toBeNull()
  166. expect(cardFor('Marco Ibáñez', down).sourcesDown).toContain('the Content Creation tracker')
  167. })
  168. })