All checks were successful
Deploy production / deploy (push) Successful in 2m29s
141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
mapTask,
|
|
mergeCsv,
|
|
parseTickTickCsv,
|
|
moreComplete,
|
|
normTitle,
|
|
slugify,
|
|
ttDate,
|
|
ttDue,
|
|
ttPriority,
|
|
ttRecurrence,
|
|
ttReminder,
|
|
ttStatus
|
|
} from './ticktick';
|
|
|
|
describe('ticktick mapping', () => {
|
|
it('parses TickTick timestamps', () => {
|
|
expect(ttDate('2024-06-11T17:30:36.000+0000')?.toISOString()).toBe('2024-06-11T17:30:36.000Z');
|
|
expect(ttDate('')).toBeNull();
|
|
});
|
|
it('keeps the calendar date of all-day dues and converts timed dues to Amsterdam', () => {
|
|
expect(ttDue('2025-03-11T23:00:00.000+0000', true, 'Europe/Amsterdam')).toEqual({
|
|
dueDate: '2025-03-12',
|
|
dueTime: null
|
|
});
|
|
expect(ttDue('2026-05-04T04:00:00.000+0000', false, 'Europe/Berlin')).toEqual({
|
|
dueDate: '2026-05-04',
|
|
dueTime: '06:00'
|
|
});
|
|
expect(ttDue(undefined, true, undefined)).toEqual({ dueDate: null, dueTime: null });
|
|
});
|
|
it('maps priority, status, recurrence and reminders', () => {
|
|
expect([5, 3, 1, 0, undefined].map(ttPriority)).toEqual([3, 2, 1, 0, 0]);
|
|
expect([0, 2, -1].map(ttStatus)).toEqual(['todo', 'done', 'dropped']);
|
|
expect(ttRecurrence('RRULE:FREQ=WEEKLY;INTERVAL=1')).toBe('FREQ=WEEKLY;INTERVAL=1');
|
|
const due = new Date('2026-01-01T10:00:00Z');
|
|
expect(ttReminder(['TRIGGER:-PT60M', 'TRIGGER:PT0S'], due)?.toISOString()).toBe(
|
|
'2026-01-01T09:00:00.000Z'
|
|
);
|
|
expect(ttReminder(['TRIGGER:P0DT9H0M0S'], due)?.toISOString()).toBe('2026-01-01T19:00:00.000Z');
|
|
});
|
|
it('prefers the more complete status', () => {
|
|
expect(moreComplete('done', 'todo')).toBe('done');
|
|
expect(moreComplete('todo', 'done')).toBe('done');
|
|
expect(moreComplete('in_progress', 'todo')).toBe('in_progress');
|
|
});
|
|
it('turns checklist items into subtasks', () => {
|
|
const rows = mapTask({
|
|
id: 't1',
|
|
projectId: 'inbox1',
|
|
title: ' Groceries ',
|
|
kind: 'CHECKLIST',
|
|
desc: 'weekly',
|
|
status: 2,
|
|
priority: 5,
|
|
createdTime: '2025-01-01T10:00:00.000+0000',
|
|
modifiedTime: '2025-01-02T10:00:00.000+0000',
|
|
completedTime: '2025-01-02T09:00:00.000+0000',
|
|
items: [
|
|
{ id: 'i1', title: 'milk', status: 1, completedTime: '2025-01-02T08:00:00.000+0000' },
|
|
{ id: 'i2', title: 'eggs', status: 0 },
|
|
{ id: 'i3', title: 'bread', status: 1, completedTime: 978307200000 }
|
|
]
|
|
} as Parameters<typeof mapTask>[0]);
|
|
expect(rows).toHaveLength(4);
|
|
// Placeholder completion (2001) falls back to the task's completion.
|
|
expect(rows[3].completedAt?.toISOString()).toBe('2025-01-02T09:00:00.000Z');
|
|
expect(rows[0]).toMatchObject({
|
|
title: 'Groceries',
|
|
notes: 'weekly',
|
|
status: 'done',
|
|
priority: 3
|
|
});
|
|
expect(rows[0].completedAt?.toISOString()).toBe('2025-01-02T09:00:00.000Z');
|
|
expect(rows[1]).toMatchObject({ externalId: 't1:i1', parentExternalId: 't1', status: 'done' });
|
|
// Unchecked item of a completed task follows the task.
|
|
expect(rows[2]).toMatchObject({ externalId: 't1:i2', status: 'done' });
|
|
expect(rows[2].completedAt?.toISOString()).toBe('2025-01-02T09:00:00.000Z');
|
|
});
|
|
it('slugs and normalises titles', () => {
|
|
expect(slugify('Stuhee')).toBe('stuhee');
|
|
expect(normTitle('Book haircut!')).toBe('book haircut');
|
|
});
|
|
});
|
|
|
|
describe('ticktick backup csv', () => {
|
|
const csv = [
|
|
'"Date: 2026-10-24+0000"',
|
|
'"Status: ',
|
|
'0 Normal',
|
|
'2 Completed"',
|
|
'"Folder Name","List Name","Title","Kind","Tags","Content","Is Check list","Start Date","Due Date","Reminder","Repeat","Priority","Status","Created Time","Completed Time","Order","Timezone","Is All Day","Is Floating","Column Name","Column Order","View Mode","taskId","parentId","projectKind"',
|
|
'"","Inbox","Known","TEXT","","","N","","","","","0","2","2025-01-01T10:00:00+0000","2025-01-02T10:00:00+0000","0","Europe/Amsterdam","false","false",,,"list","1","","TASK"',
|
|
'"","Inbox","Old won\'t do","TEXT","a, b","line ""one""\nline two","N","","","","","5","-1","2024-01-01T10:00:00+0000","2024-02-01T10:00:00+0000","0","Europe/Amsterdam","false","false",,,"list","2","1","TASK"',
|
|
'"Old","Trips","Pack","CHECKLIST","","▫Tea\n▪Jerky\n","Y","","","","","0","0","2023-01-01T10:00:00+0000","","0","Europe/Amsterdam","false","false",,,"list","3","","TASK"',
|
|
''
|
|
].join('\n');
|
|
const api = {
|
|
projects: [],
|
|
tasks: [
|
|
{
|
|
id: 'api1',
|
|
projectId: 'inbox9',
|
|
title: 'Known',
|
|
status: 2,
|
|
createdTime: '2025-01-01T10:00:00.000+0000'
|
|
}
|
|
]
|
|
};
|
|
it('parses the backup, including multi-line quoted fields and checklists', () => {
|
|
const t = parseTickTickCsv(csv);
|
|
expect(t).toHaveLength(3);
|
|
expect(t[1]).toMatchObject({ content: 'line "one"\nline two', tags: ['a', 'b'], status: -1 });
|
|
expect(t[2].items).toEqual([
|
|
{ id: 'item1', title: 'Tea', status: 0 },
|
|
{ id: 'item2', title: 'Jerky', status: 1 }
|
|
]);
|
|
});
|
|
it('skips rows the API already has and keys the rest stably', () => {
|
|
const a = mergeCsv(api, parseTickTickCsv(csv));
|
|
const b = mergeCsv(api, parseTickTickCsv(csv));
|
|
expect(a.report).toMatchObject({
|
|
csvRows: 3,
|
|
matchedListTitleCreated: 1,
|
|
added: 2,
|
|
newLists: ['Trips']
|
|
});
|
|
const added = a.data.tasks.slice(1);
|
|
expect(added.map((t) => t.id)).toEqual(b.data.tasks.slice(1).map((t) => t.id));
|
|
expect(added[0].id).toMatch(/^csv:/);
|
|
expect(added[0]).toMatchObject({ projectId: 'inbox9', parentId: 'api1' });
|
|
expect(added[1].projectId).toBe('csvlist:Trips');
|
|
expect(a.data.projects).toContainEqual({
|
|
id: 'csvlist:Trips',
|
|
name: 'Old / Trips',
|
|
closed: true
|
|
});
|
|
});
|
|
});
|