[ {"fn": "importCsv", "name": "the header alone is an empty list", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\n"], "expect": []}, {"fn": "importCsv", "name": "the header without a final line break", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order"], "expect": []}, {"fn": "importCsv", "name": "one plain row: empty fields become null, false, order 0", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}]}, {"fn": "importCsv", "name": "the last row without a final line break", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0"], "expect": [{"id": "t1", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}]}, {"fn": "importCsv", "name": "LF line endings and a UTF-8 byte order mark", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}]}, {"fn": "importCsv", "name": "columns in another order are matched by name", "args": ["order,completedAt,createdAt,recurrenceAnchor,recurrenceInterval,recurrenceFrequency,tags,due,priority,done,notes,title,id\r\n7,2026-09-28T10:00:00Z,2026-09-20T08:00:00Z,2026-10-01,2,monthly,home,2026-10-01,low,true,Note,Pay rent,t9\r\n"], "expect": [{"id": "t9", "title": "Pay rent", "notes": "Note", "done": true, "priority": "low", "due": "2026-10-01", "tags": ["home"], "recurrence": {"frequency": "monthly", "interval": 2, "anchor": "2026-10-01"}, "createdAt": "2026-09-20T08:00:00Z", "completedAt": "2026-09-28T10:00:00Z", "order": 7}]}, {"fn": "importCsv", "name": "round trip: the file todo.export-csv writes for quotes, commas, line breaks, unicode, a repeat and a done todo", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,\"Read \"\"Dune\"\", then lend it\",\"Chapter 1\r\nChapter 2\nsee \"\"notes\"\", p. 3\",false,medium,2026-10-05,books home-office,weekly,2,2026-09-28,2026-09-28T09:30:00.5Z,,0\r\nt2,Café ☕ 😀 =1+1,,true,high,,,,,,2026-09-27T23:59:59Z,2026-09-28T00:00:00Z,1\r\n"], "expect": [{"id": "t1", "title": "Read \"Dune\", then lend it", "notes": "Chapter 1\r\nChapter 2\nsee \"notes\", p. 3", "done": false, "priority": "medium", "due": "2026-10-05", "tags": ["books", "home-office"], "recurrence": {"frequency": "weekly", "interval": 2, "anchor": "2026-09-28"}, "createdAt": "2026-09-28T09:30:00.5Z", "completedAt": null, "order": 0}, {"id": "t2", "title": "Café ☕ 😀 =1+1", "notes": null, "done": true, "priority": "high", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-27T23:59:59Z", "completedAt": "2026-09-28T00:00:00Z", "order": 1}]}, {"fn": "importCsv", "name": "empty lines between and after rows are skipped", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\n\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\n\nt2,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n\r\n\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}, {"id": "t2", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}]}, {"fn": "importCsv", "name": "tags from another tool are normalised: # dropped, lower-cased, commas and repeats gone", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,\"#Home Work, home Home-Office\",,,,2026-09-28T09:30:00Z,,0\r\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": ["home", "work", "home-office"], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}]}, {"fn": "importCsv", "name": "quoted fields that did not need quoting, and an empty quoted field for notes (null)", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\n\"t1\",\"Buy milk\",\"\",\"false\",\"none\",\"\",\"\",\"\",\"\",\"\",\"2026-09-28T09:30:00Z\",\"\",\"0\"\r\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}]}, {"fn": "importCsv", "name": "a lone CR inside quotes is kept as it is", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,\"a\rb\",false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": "a\rb", "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}]}, {"fn": "importCsv", "name": "leading zeros in order and interval are read as the number", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,2026-10-01,,daily,003,2026-10-01,2026-09-28T09:30:00Z,,007\r\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": "2026-10-01", "tags": [], "recurrence": {"frequency": "daily", "interval": 3, "anchor": "2026-10-01"}, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 7}]}, {"fn": "importCsv", "name": "spaces around a field are data, not trimmed (notes keep them)", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk, note ,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": " note ", "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}]}, {"fn": "importCsv", "name": "the same title twice is fine; only ids must differ", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\nt2,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,1\r\n"], "expect": [{"id": "t1", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 0}, {"id": "t2", "title": "Buy milk", "notes": null, "done": false, "priority": "none", "due": null, "tags": [], "recurrence": null, "createdAt": "2026-09-28T09:30:00Z", "completedAt": null, "order": 1}]}, {"fn": "importCsv", "name": "empty text has no header row", "args": [""], "expectError": "the CSV has no header row"}, {"fn": "importCsv", "name": "only empty lines and a byte order mark have no header row", "args": ["\r\n\n\r\n"], "expectError": "the CSV has no header row"}, {"fn": "importCsv", "name": "a header without the order column", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt\r\n"], "expectError": "the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: missing column \"order\""}, {"fn": "importCsv", "name": "a header with an extra column", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order,colour\r\n"], "expectError": "the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: unknown column \"colour\""}, {"fn": "importCsv", "name": "header names are matched exactly: Title is not title", "args": ["id,Title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\n"], "expectError": "the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: unknown column \"Title\""}, {"fn": "importCsv", "name": "a header naming a column twice", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order,title\r\n"], "expectError": "the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: column \"title\" appears twice"}, {"fn": "importCsv", "name": "a row one field short", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,\r\n"], "expectError": "row 3: expected 13 fields, found 12"}, {"fn": "importCsv", "name": "a row with a trailing comma has one field too many", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0,\r\n"], "expectError": "row 2: expected 13 fields, found 14"}, {"fn": "importCsv", "name": "a quoted field that is never closed", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,\"Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: a quoted field is not closed"}, {"fn": "importCsv", "name": "text after a closing quote", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,\"Buy\" milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: text after the closing quote of a field"}, {"fn": "importCsv", "name": "a double quote inside a field that is not quoted", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Read \"Dune\",,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: a field with a double quote in it must be quoted"}, {"fn": "importCsv", "name": "a lone CR outside quotes (old Mac line endings)", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\rt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r"], "expectError": "row 1: a CR outside quotes must be followed by LF"}, {"fn": "importCsv", "name": "done must be true or false, not yes", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,yes,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: done must be true or false, found \"yes\""}, {"fn": "importCsv", "name": "done is case-sensitive: TRUE is refused", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,TRUE,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: done must be true or false, found \"TRUE\""}, {"fn": "importCsv", "name": "an interval that is not a whole number", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,2026-10-01,,daily,1.5,2026-10-01,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: recurrenceInterval must be a whole number, found \"1.5\""}, {"fn": "importCsv", "name": "an empty order", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,\r\n"], "expectError": "row 2: order must be a whole number, found \"\""}, {"fn": "importCsv", "name": "an order with a plus sign", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,+1\r\n"], "expectError": "row 2: order must be a whole number, found \"+1\""}, {"fn": "importCsv", "name": "an order in Arabic-Indic digits", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,٣\r\n"], "expectError": "row 2: order must be a whole number, found \"٣\""}, {"fn": "importCsv", "name": "an order with 16 digits is refused", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,1234567890123456\r\n"], "expectError": "row 2: order must be a whole number, found \"1234567890123456\""}, {"fn": "importCsv", "name": "recurrence columns partly filled", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,2026-10-01,,daily,,2026-10-01,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: fill in all three recurrence columns or leave them all empty"}, {"fn": "importCsv", "name": "a blank title", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1, ,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: title: Enter a title."}, {"fn": "importCsv", "name": "spaces around the title", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1, Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: title: Remove the spaces around the title."}, {"fn": "importCsv", "name": "an unknown priority and a bad due date: priority comes first", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,urgent,2026-02-30,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: priority: Choose a priority: none, low, medium or high."}, {"fn": "importCsv", "name": "a done todo without a completion time", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,true,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: completedAt: A done todo needs the time it was completed."}, {"fn": "importCsv", "name": "a negative order is a whole number, so validateTodo reports it", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,-1\r\n"], "expectError": "row 2: order: Order must be a whole number, 0 or more."}, {"fn": "importCsv", "name": "an interval of 0", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,2026-10-01,,daily,0,2026-10-01,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: recurrence: Repeat every 1 to 999 days, weeks, months or years."}, {"fn": "importCsv", "name": "eleven different tags", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,a b c d e f g h i j k,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: tags: Use no more than 10 tags."}, {"fn": "importCsv", "name": "an empty id", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\n,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 2: id: Every todo needs an id."}, {"fn": "importCsv", "name": "a timestamp with an offset", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00+01:00,,0\r\n"], "expectError": "row 2: createdAt: Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z."}, {"fn": "importCsv", "name": "the same id twice", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,0\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:30:00Z,,1\r\n"], "expectError": "row 3: duplicate id \"t1\""}, {"fn": "importCsv", "name": "rows are records, not lines: empty lines and line breaks in quotes are not counted", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\n\r\nt1,Buy milk,\"a\nb\nc\",false,none,,,,,,2026-09-28T09:30:00Z,,0\r\n\nt2,Buy milk,,maybe,none,,,,,,2026-09-28T09:30:00Z,,0\r\n"], "expectError": "row 3: done must be true or false, found \"maybe\""}, {"fn": "importCsv", "name": "the first bad row wins, even when a later row cannot be read", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,no,none,,,,,,2026-09-28T09:30:00Z,,0\r\nt2,\"open\r\n"], "expectError": "row 2: done must be true or false, found \"no\""}, {"fn": "validateTodoCsv", "name": "a good file is valid and counts its rows", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0\r\nt2,Call mum,,false,high,2026-09-29,family,,,,2026-09-28T09:00:00Z,,1\r\n"], "expect": {"valid": true, "rows": 2, "errors": []}}, {"fn": "validateTodoCsv", "name": "a header with no rows is a valid, empty file", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\n"], "expect": {"valid": true, "rows": 0, "errors": []}}, {"fn": "validateTodoCsv", "name": "empty text has no header row", "args": [""], "expect": {"valid": false, "rows": 0, "errors": [{"row": 1, "field": null, "message": "the CSV has no header row"}]}}, {"fn": "validateTodoCsv", "name": "every bad row is listed, and a good row between them is not", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,yes,none,,,,,,2026-09-28T09:00:00Z,,0\r\nt2, ,,false,none,,,,,,2026-09-28T09:00:00Z,,1\r\nt3,Call mum,,false,none,,,,,,2026-09-28T09:00:00Z,,2\r\nt4,Pay rent,,false,urgent,2026-02-30,,,,,yesterday,,-1\r\n"], "expect": {"valid": false, "rows": 4, "errors": [{"row": 2, "field": "done", "message": "done must be true or false, found \"yes\""}, {"row": 3, "field": "title", "message": "Enter a title."}, {"row": 5, "field": "priority", "message": "Choose a priority: none, low, medium or high."}, {"row": 5, "field": "due", "message": "Enter the due date as a real date, YYYY-MM-DD."}, {"row": 5, "field": "createdAt", "message": "Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z."}, {"row": 5, "field": "order", "message": "Order must be a whole number, 0 or more."}]}}, {"fn": "validateTodoCsv", "name": "one row with three columns that cannot be read lists all three", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,maybe,none,,,weekly,,2026-09-28,2026-09-28T09:00:00Z,,x\r\n"], "expect": {"valid": false, "rows": 1, "errors": [{"row": 2, "field": "done", "message": "done must be true or false, found \"maybe\""}, {"row": 2, "field": "recurrence", "message": "fill in all three recurrence columns or leave them all empty"}, {"row": 2, "field": "order", "message": "order must be a whole number, found \"x\""}]}}, {"fn": "validateTodoCsv", "name": "an unreadable done does not also blame the completion time", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,yes,none,,,,,,2026-09-28T09:00:00Z,2026-09-28T10:00:00Z,0\r\n"], "expect": {"valid": false, "rows": 1, "errors": [{"row": 2, "field": "done", "message": "done must be true or false, found \"yes\""}]}}, {"fn": "validateTodoCsv", "name": "an unreadable interval is reported once, not again as a bad repeat", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Water plants,,false,none,2026-09-28,,daily,1.5,2026-09-28,2026-09-28T09:00:00Z,,0\r\n"], "expect": {"valid": false, "rows": 1, "errors": [{"row": 2, "field": "recurrenceInterval", "message": "recurrenceInterval must be a whole number, found \"1.5\""}]}}, {"fn": "validateTodoCsv", "name": "a duplicate id is listed alongside the row's other problem", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0\r\nt1, Buy bread,,false,none,,,,,,2026-09-28T09:00:00Z,,1\r\n"], "expect": {"valid": false, "rows": 2, "errors": [{"row": 3, "field": "title", "message": "Remove the spaces around the title."}, {"row": 3, "field": "id", "message": "duplicate id \"t1\""}]}}, {"fn": "validateTodoCsv", "name": "a row with the wrong number of fields does not stop the rows after it", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk\r\nt2,Call mum,,false,none,,,,,,2026-09-28T09:00:00Z,,1\r\nt3,Pay rent,,no,none,,,,,,2026-09-28T09:00:00Z,,2\r\n"], "expect": {"valid": false, "rows": 3, "errors": [{"row": 2, "field": null, "message": "expected 13 fields, found 2"}, {"row": 4, "field": "done", "message": "done must be true or false, found \"no\""}]}}, {"fn": "validateTodoCsv", "name": "an unclosed quote ends the check at that row, since the rows after it cannot be found", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0\r\nt2,\"Call mum,,false,none,,,,,,2026-09-28T09:00:00Z,,1\r\nt3,Pay rent,,no,none,,,,,,2026-09-28T09:00:00Z,,2\r\n"], "expect": {"valid": false, "rows": 1, "errors": [{"row": 3, "field": null, "message": "a quoted field is not closed"}]}}, {"fn": "validateTodoCsv", "name": "a header without every column is one problem on row 1", "args": ["id,title\r\nt1,Buy milk\r\n"], "expect": {"valid": false, "rows": 0, "errors": [{"row": 1, "field": null, "message": "the header must have the columns id, title, notes, done, priority, due, tags, recurrenceFrequency, recurrenceInterval, recurrenceAnchor, createdAt, completedAt, order: missing column \"notes\""}]}}, {"fn": "validateTodoCsv", "name": "two rows without an id each say so, and are not duplicates of each other", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\r\n,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0\r\n,Call mum,,false,none,,,,,,2026-09-28T09:00:00Z,,1\r\n"], "expect": {"valid": false, "rows": 2, "errors": [{"row": 2, "field": "id", "message": "Every todo needs an id."}, {"row": 3, "field": "id", "message": "Every todo needs an id."}]}}, {"fn": "validateTodoCsv", "name": "rows count records, not lines: a byte order mark and blank lines are skipped", "args": ["id,title,notes,done,priority,due,tags,recurrenceFrequency,recurrenceInterval,recurrenceAnchor,createdAt,completedAt,order\n\n\nt1,Buy milk,,false,none,,,,,,2026-09-28T09:00:00Z,,0\n\nt2,Call mum,,false,none,,,,,,2026-09-28,,1\n"], "expect": {"valid": false, "rows": 2, "errors": [{"row": 3, "field": "createdAt", "message": "Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z."}]}} ]