tracker.test.ts
Language TypeScript7.3 KB191 lines
/*** The Content Creation tracker reader.** This source is different in kind from the other four: it is a spreadsheet a* person types into, not a system of record. So the failures worth testing are* not API failures; they are the things humans do to spreadsheets. Every case* below was observed in the live sheet. The cell contents here* are invented stand-ins with the same shape.*/import { describe, expect, it } from 'vitest'import { parseCsv, parseTab, splitOwners, tabCsvUrl, fetchTracker } from '../src/index.js'describe('parseCsv survives what people put in cells', () => {it('keeps a newline inside a quoted cell as one cell', () => {// A row shaped like "Refresh the vinyl banner gallery images\nRecrop the// hero image...". Splitting on \n first makes that two rows, the second// with no owner, so it lands in unowned work and somebody goes hunting for// who dropped it.const rows = parseCsv('"a\nb","c"\n"d","e"\n')expect(rows).toEqual([['a\nb', 'c'],['d', 'e'],])})it('keeps a comma inside a quoted cell', () => {// "Refresh the vinyl banner gallery images, recrop the hero image..."expect(parseCsv('"x, y","z"')).toEqual([['x, y', 'z']])})it('unescapes a doubled quote', () => {// A title like 'New wall art - "Built to Last"' carries quotes of its own.expect(parseCsv('"he said ""hi""",b')).toEqual([['he said "hi"', 'b']])})it('treats CRLF as one row break, not two', () => {expect(parseCsv('a,b\r\nc,d')).toEqual([['a', 'b'],['c', 'd'],])})it('keeps a last row with no trailing newline', () => {expect(parseCsv('a,b\nc,d')).toHaveLength(2)})it('strips a BOM rather than corrupting the first header', () => {expect(parseCsv('Owner,Task')[0]![0]).toBe('Owner')})})describe('splitOwners', () => {it('splits a shared row into both people', () => {// "Nora & Sam": one photoshoot, two people who have to show up.expect(splitOwners('Nora & Sam')).toEqual(['Nora', 'Sam'])})it('handles the other separators a human might type', () => {expect(splitOwners('Marco and Ines')).toEqual(['Marco', 'Ines'])expect(splitOwners('Marco, Ines')).toEqual(['Marco', 'Ines'])expect(splitOwners('Marco / Ines')).toEqual(['Marco', 'Ines'])})it('returns nothing for an empty cell rather than one blank owner', () => {expect(splitOwners(' ')).toEqual([])})})const TAB = ['Release Date,Deadline,Complete Date,Priority,Owner,Priority,sku,Task,Notes/Links/Resources,Location to Upload Files,Points,Status,Completed',',,,,Rafael,,TT10,10x10 canopy tent photos,see supplier page,,12,In Progress,FALSE',',,,,Nora & Sam,,,Studio photoshoot,,,,In Progress,FALSE',',,,,Marco,,,Blank status row,,,,,FALSE',',,,,,,,,,,,,FALSE',',,,,,,,,,,,,FALSE',].join('\n')describe('parseTab', () => {it('reads the rows that are work and skips the padding', () => {// The tabs are padded with dozens of blank rows carrying only a trailing// FALSE, which is what a spreadsheet looks like after someone dragged a// checkbox down a column. Counting those would put phantom rows on the board.const out = parseTab(TAB, 'Images', '0')expect(out).toHaveLength(3)expect(out.map((t) => t.title)).toEqual(['10x10 canopy tent photos','Studio photoshoot','Blank status row',])})it('carries a blank Status through as blank rather than inventing one', () => {// The classifier decides what blank MEANS. The reader's job is not to guess// on its behalf.expect(parseTab(TAB, 'Images', '0')[2]!.status).toBe('')})it('marks a two-owner row shared and keeps both names', () => {const shared = parseTab(TAB, 'Images', '0')[1]!expect(shared.owners).toEqual(['Nora', 'Sam'])expect(shared.shared).toBe(true)})it('finds the header even when Google drops leading empty header cells', () => {// gviz omits blank leading header cells, so a tab whose first columns are// dated arrives with '' where 'Release Date' should be. Trusting row 0// position would read the whole tab off by several columns.const shifted = TAB.replace('Release Date,Deadline,Complete Date,Priority,Owner',',,,,Owner',)expect(parseTab(shifted, 'Images', '0')).toHaveLength(3)})it('returns nothing for a tab with no Owner/Task columns', () => {// The Resources tab is image dimensions; the product matrix is per-SKU// checkboxes. Neither is assignable work, and inventing rows from whatever// the first columns happened to be would attribute them to nobody.expect(parseTab('Image,Sizes,Sizes 2\nCategory header,756x376,', 'Resources', '1')).toEqual([])})it('keeps a deadline as typed instead of parsing it', () => {// The live sheet mixes 08/19/26, 08/19/2026 and 8/21/2026. Guessing a year// from a two-digit one is how you report something overdue that is not.const csv = 'Owner,Task,Deadline,Status\nMarco,A thing,8/21/26,In Progress'expect(parseTab(csv, 'Images', '0')[0]!.deadline).toBe('8/21/26')})it('gives every row an id stable within a sync', () => {// The sheet has no ids of its own, so tab+row is the only handle there is.const out = parseTab(TAB, 'Images', '0')expect(new Set(out.map((t) => t.id)).size).toBe(out.length)})})describe('fetchTracker', () => {const ok = (body: string) => new Response(body, { status: 200 })it('reports a failed tab instead of losing the whole read', () => {// Three tabs of four is most of the picture. Throwing away all of it because// one tab was renamed is the worse outcome; the caller says "partial".return fetchTracker('sheet1',[{ gid: '0', label: 'Images' },{ gid: '9', label: 'Gone' },],(async (url: string) =>String(url).includes('gid=9') ? new Response('nope', { status: 404 }) : ok(TAB)) as typeof fetch,).then((r) => {expect(r.tasks).toHaveLength(3)expect(r.tabs.map((t) => t.label)).toEqual(['Images'])expect(r.failedTabs).toEqual([{ gid: '9', label: 'Gone', reason: 'HTTP 404' }])})})it('names link-sharing as the cause of a 403, because that is observable', () => {// The source is read from a PUBLIC csv export. A 403 means that sharing was// tightened, which is the one cause a reader can act on, and it comes from// the status code rather than from a guess (rule 1 in docs/ARCHITECTURE.md).return fetchTracker('sheet1',[{ gid: '0', label: 'Images' }],(async () => new Response('', { status: 403 })) as typeof fetch,).then((r) => {expect(r.failedTabs[0]!.reason).toContain('403')expect(r.failedTabs[0]!.reason).toMatch(/readable by link/i)expect(r.tasks).toEqual([])})})it('survives a thrown fetch the same way as a bad status', () => {return fetchTracker('sheet1',[{ gid: '0', label: 'Images' }],(async () => {throw new Error('network down')}) as typeof fetch,).then((r) => {expect(r.failedTabs[0]!.reason).toBe('network down')})})it('builds a URL that needs no credential', () => {const url = tabCsvUrl('abc', '123')expect(url).toContain('/gviz/tq')expect(url).toContain('gid=123')expect(url).not.toMatch(/key=|token=|access/i)})})