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

tracker.test.ts

Language TypeScript7.3 KB191 lines

packages/sheets-client/test/tracker.test.ts

  1. /**
  2. * The Content Creation tracker reader.
  3. *
  4. * This source is different in kind from the other four: it is a spreadsheet a
  5. * person types into, not a system of record. So the failures worth testing are
  6. * not API failures; they are the things humans do to spreadsheets. Every case
  7. * below was observed in the live sheet. The cell contents here
  8. * are invented stand-ins with the same shape.
  9. */
  10. import { describe, expect, it } from 'vitest'
  11. import { parseCsv, parseTab, splitOwners, tabCsvUrl, fetchTracker } from '../src/index.js'
  12. describe('parseCsv survives what people put in cells', () => {
  13. it('keeps a newline inside a quoted cell as one cell', () => {
  14. // A row shaped like "Refresh the vinyl banner gallery images\nRecrop the
  15. // hero image...". Splitting on \n first makes that two rows, the second
  16. // with no owner, so it lands in unowned work and somebody goes hunting for
  17. // who dropped it.
  18. const rows = parseCsv('"a\nb","c"\n"d","e"\n')
  19. expect(rows).toEqual([
  20. ['a\nb', 'c'],
  21. ['d', 'e'],
  22. ])
  23. })
  24. it('keeps a comma inside a quoted cell', () => {
  25. // "Refresh the vinyl banner gallery images, recrop the hero image..."
  26. expect(parseCsv('"x, y","z"')).toEqual([['x, y', 'z']])
  27. })
  28. it('unescapes a doubled quote', () => {
  29. // A title like 'New wall art - "Built to Last"' carries quotes of its own.
  30. expect(parseCsv('"he said ""hi""",b')).toEqual([['he said "hi"', 'b']])
  31. })
  32. it('treats CRLF as one row break, not two', () => {
  33. expect(parseCsv('a,b\r\nc,d')).toEqual([
  34. ['a', 'b'],
  35. ['c', 'd'],
  36. ])
  37. })
  38. it('keeps a last row with no trailing newline', () => {
  39. expect(parseCsv('a,b\nc,d')).toHaveLength(2)
  40. })
  41. it('strips a BOM rather than corrupting the first header', () => {
  42. expect(parseCsv('Owner,Task')[0]![0]).toBe('Owner')
  43. })
  44. })
  45. describe('splitOwners', () => {
  46. it('splits a shared row into both people', () => {
  47. // "Nora & Sam": one photoshoot, two people who have to show up.
  48. expect(splitOwners('Nora & Sam')).toEqual(['Nora', 'Sam'])
  49. })
  50. it('handles the other separators a human might type', () => {
  51. expect(splitOwners('Marco and Ines')).toEqual(['Marco', 'Ines'])
  52. expect(splitOwners('Marco, Ines')).toEqual(['Marco', 'Ines'])
  53. expect(splitOwners('Marco / Ines')).toEqual(['Marco', 'Ines'])
  54. })
  55. it('returns nothing for an empty cell rather than one blank owner', () => {
  56. expect(splitOwners(' ')).toEqual([])
  57. })
  58. })
  59. const TAB = [
  60. 'Release Date,Deadline,Complete Date,Priority,Owner,Priority,sku,Task,Notes/Links/Resources,Location to Upload Files,Points,Status,Completed',
  61. ',,,,Rafael,,TT10,10x10 canopy tent photos,see supplier page,,12,In Progress,FALSE',
  62. ',,,,Nora & Sam,,,Studio photoshoot,,,,In Progress,FALSE',
  63. ',,,,Marco,,,Blank status row,,,,,FALSE',
  64. ',,,,,,,,,,,,FALSE',
  65. ',,,,,,,,,,,,FALSE',
  66. ].join('\n')
  67. describe('parseTab', () => {
  68. it('reads the rows that are work and skips the padding', () => {
  69. // The tabs are padded with dozens of blank rows carrying only a trailing
  70. // FALSE, which is what a spreadsheet looks like after someone dragged a
  71. // checkbox down a column. Counting those would put phantom rows on the board.
  72. const out = parseTab(TAB, 'Images', '0')
  73. expect(out).toHaveLength(3)
  74. expect(out.map((t) => t.title)).toEqual([
  75. '10x10 canopy tent photos',
  76. 'Studio photoshoot',
  77. 'Blank status row',
  78. ])
  79. })
  80. it('carries a blank Status through as blank rather than inventing one', () => {
  81. // The classifier decides what blank MEANS. The reader's job is not to guess
  82. // on its behalf.
  83. expect(parseTab(TAB, 'Images', '0')[2]!.status).toBe('')
  84. })
  85. it('marks a two-owner row shared and keeps both names', () => {
  86. const shared = parseTab(TAB, 'Images', '0')[1]!
  87. expect(shared.owners).toEqual(['Nora', 'Sam'])
  88. expect(shared.shared).toBe(true)
  89. })
  90. it('finds the header even when Google drops leading empty header cells', () => {
  91. // gviz omits blank leading header cells, so a tab whose first columns are
  92. // dated arrives with '' where 'Release Date' should be. Trusting row 0
  93. // position would read the whole tab off by several columns.
  94. const shifted = TAB.replace(
  95. 'Release Date,Deadline,Complete Date,Priority,Owner',
  96. ',,,,Owner',
  97. )
  98. expect(parseTab(shifted, 'Images', '0')).toHaveLength(3)
  99. })
  100. it('returns nothing for a tab with no Owner/Task columns', () => {
  101. // The Resources tab is image dimensions; the product matrix is per-SKU
  102. // checkboxes. Neither is assignable work, and inventing rows from whatever
  103. // the first columns happened to be would attribute them to nobody.
  104. expect(parseTab('Image,Sizes,Sizes 2\nCategory header,756x376,', 'Resources', '1')).toEqual([])
  105. })
  106. it('keeps a deadline as typed instead of parsing it', () => {
  107. // The live sheet mixes 08/19/26, 08/19/2026 and 8/21/2026. Guessing a year
  108. // from a two-digit one is how you report something overdue that is not.
  109. const csv = 'Owner,Task,Deadline,Status\nMarco,A thing,8/21/26,In Progress'
  110. expect(parseTab(csv, 'Images', '0')[0]!.deadline).toBe('8/21/26')
  111. })
  112. it('gives every row an id stable within a sync', () => {
  113. // The sheet has no ids of its own, so tab+row is the only handle there is.
  114. const out = parseTab(TAB, 'Images', '0')
  115. expect(new Set(out.map((t) => t.id)).size).toBe(out.length)
  116. })
  117. })
  118. describe('fetchTracker', () => {
  119. const ok = (body: string) => new Response(body, { status: 200 })
  120. it('reports a failed tab instead of losing the whole read', () => {
  121. // Three tabs of four is most of the picture. Throwing away all of it because
  122. // one tab was renamed is the worse outcome; the caller says "partial".
  123. return fetchTracker(
  124. 'sheet1',
  125. [
  126. { gid: '0', label: 'Images' },
  127. { gid: '9', label: 'Gone' },
  128. ],
  129. (async (url: string) =>
  130. String(url).includes('gid=9') ? new Response('nope', { status: 404 }) : ok(TAB)) as typeof fetch,
  131. ).then((r) => {
  132. expect(r.tasks).toHaveLength(3)
  133. expect(r.tabs.map((t) => t.label)).toEqual(['Images'])
  134. expect(r.failedTabs).toEqual([{ gid: '9', label: 'Gone', reason: 'HTTP 404' }])
  135. })
  136. })
  137. it('names link-sharing as the cause of a 403, because that is observable', () => {
  138. // The source is read from a PUBLIC csv export. A 403 means that sharing was
  139. // tightened, which is the one cause a reader can act on, and it comes from
  140. // the status code rather than from a guess (rule 1 in docs/ARCHITECTURE.md).
  141. return fetchTracker(
  142. 'sheet1',
  143. [{ gid: '0', label: 'Images' }],
  144. (async () => new Response('', { status: 403 })) as typeof fetch,
  145. ).then((r) => {
  146. expect(r.failedTabs[0]!.reason).toContain('403')
  147. expect(r.failedTabs[0]!.reason).toMatch(/readable by link/i)
  148. expect(r.tasks).toEqual([])
  149. })
  150. })
  151. it('survives a thrown fetch the same way as a bad status', () => {
  152. return fetchTracker(
  153. 'sheet1',
  154. [{ gid: '0', label: 'Images' }],
  155. (async () => {
  156. throw new Error('network down')
  157. }) as typeof fetch,
  158. ).then((r) => {
  159. expect(r.failedTabs[0]!.reason).toBe('network down')
  160. })
  161. })
  162. it('builds a URL that needs no credential', () => {
  163. const url = tabCsvUrl('abc', '123')
  164. expect(url).toContain('/gviz/tq')
  165. expect(url).toContain('gid=123')
  166. expect(url).not.toMatch(/key=|token=|access/i)
  167. })
  168. })