import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import legacyBackup from './backup-tasks-v1.json' with { type: 'json' }; import { HIDEABLE_STATUSES, STATUSES } from '../src/js/config.js'; import { t } from '../src/js/i18n/index.js'; import { normalizeTask, normalizeTasks } from '../src/js/task-model.js'; import { sortTasks } from '../src/js/sort.js'; import { getDueDateState } from '../src/js/utils.js'; import { applySearchFilter, renderColumn, updateTaskCounts } from '../src/js/render.js'; import { addSystemEntries, addTimelineEntry, updateTimelineEntry } from '../src/js/timeline.js'; import { executeImport, exportData, mergeTasks, readImportFile, validateImportData, } from '../src/js/import-export.js'; import { consumeStorageWriteFailure, isInitialized, loadShowHiddenData, loadSortOrdersData, loadTasksData, } from '../src/js/storage.js'; import { addTask, appendTasks, getShowHidden, getSortOrders, getTasks, initializeStore, setShowHidden, setSortOrder, updateTask, } from '../src/js/store.js'; import { createSeedTasks } from '../src/js/seed-data.js'; const base = (id, title, extra = {}) => ({ id, title, status: 'todo', priority: 'medium', progress: 20, createdDate: `2025-01-${String(id).padStart(2, '0')}T00:00:00Z`, dueDate: `2025-01-${String(id).padStart(2, '0')}`, timeline: [], ...extra, }); beforeEach(() => localStorage.clear()); afterEach(() => vi.restoreAllMocks()); describe('task model', () => { it('normalizes fields and language whitelist', () => { const task = normalizeTask({ id: 1, title: ' x ', language: 'fr', progress: 999, status: 'bad', }); expect(task).toMatchObject({ title: 'x', language: 'zh', progress: 100, status: 'todo' }); expect(normalizeTask({ id: 2, title: 'English', language: 'en' }).language).toBe('en'); }); it('deduplicates task and timeline ids', () => { const tasks = normalizeTasks([ base(1, 'a', { timeline: [{ id: 5 }, { id: 5 }] }), base(1, 'b'), ]); expect(new Set(tasks.map((task) => task.id)).size).toBe(2); expect(new Set(tasks[0].timeline.map((item) => item.id)).size).toBe(2); }); it('drops non-object timeline entries from legacy data', () => { const task = normalizeTask({ ...base(1, 'dirty timeline'), timeline: ['oops', 42, true, null, [], { id: 7, content: 'valid' }], }); expect(task.timeline).toEqual([{ id: 7, content: 'valid', type: 'manual-entry' }]); }); }); describe('all 13 sorts', () => { const tasks = [ base(1, 'Beta 10', { priority: 'medium', progress: 50, dueDate: '2025-03-02', createdDate: '2025-01-02T00:00:00Z', }), base(2, 'Alpha 2', { priority: 'high', progress: 90, dueDate: '2025-03-03', createdDate: '2025-01-03T00:00:00Z', }), base(3, 'Gamma 1', { priority: 'low', progress: 10, dueDate: '2025-03-01', createdDate: '2025-01-01T00:00:00Z', }), ]; const expected = { default: [2, 1, 3], 'title-asc': [2, 1, 3], 'title-desc': [3, 1, 2], 'number-asc': [3, 2, 1], 'number-desc': [1, 2, 3], 'dueDate-asc': [3, 1, 2], 'dueDate-desc': [2, 1, 3], 'priority-asc': [3, 1, 2], 'priority-desc': [2, 1, 3], 'progress-asc': [3, 1, 2], 'progress-desc': [2, 1, 3], 'created-asc': [3, 1, 2], 'created-desc': [2, 1, 3], }; it.each(Object.entries(expected))('sorts %s in the expected order', (order, ids) => { const copy = structuredClone(tasks); expect(sortTasks(tasks, order).map((task) => task.id)).toEqual(ids); expect(tasks).toEqual(copy); }); it('keeps pinned first for every sort', () => { const pinned = { ...tasks[2], isPinned: true }; Object.keys(expected).forEach((order) => expect(sortTasks([tasks[0], tasks[1], pinned], order)[0].id).toBe(3), ); }); it('keeps hidden todo tasks last but leaves hidden in-progress tasks in place', () => { const hiddenTodo = { ...tasks[1], isHidden: true }; expect(sortTasks([hiddenTodo, tasks[0]], 'title-asc').map((task) => task.id)).toEqual([1, 2]); const hiddenWork = { ...tasks[1], status: 'inProgress', isHidden: true }; expect(sortTasks([hiddenWork, tasks[0]], 'title-asc').map((task) => task.id)).toEqual([2, 1]); }); it('keeps tasks without due dates last in both directions', () => { const dated = base(1, 'Dated'); const undated = { ...base(2, 'Undated'), dueDate: '' }; expect(sortTasks([undated, dated], 'dueDate-asc').map((task) => task.id)).toEqual([1, 2]); expect(sortTasks([undated, dated], 'dueDate-desc').map((task) => task.id)).toEqual([1, 2]); }); }); describe('due dates', () => { const now = new Date(2025, 0, 10, 12); it('detects overdue and due-soon boundaries', () => { expect(getDueDateState('2025-01-09', now)).toEqual({ isOverdue: true, isDueSoon: false }); expect(getDueDateState('2025-01-10', now)).toEqual({ isOverdue: false, isDueSoon: true }); expect(getDueDateState('2025-01-13', now).isDueSoon).toBe(true); expect(getDueDateState('2025-01-14', now).isDueSoon).toBe(false); }); it('ignores empty and invalid dates', () => { expect(getDueDateState('', now)).toEqual({ isOverdue: false, isDueSoon: false }); expect(getDueDateState('2025-02-30', now)).toEqual({ isOverdue: false, isDueSoon: false }); }); }); describe('seed data', () => { it('creates five unique tasks at the initialization time', () => { const now = new Date('2026-07-19T02:54:00.000Z'); const tasks = createSeedTasks(now); expect(tasks).toHaveLength(5); expect(new Set(tasks.map((task) => task.id)).size).toBe(5); expect(tasks.every((task) => task.createdDate === now.toISOString())).toBe(true); }); }); describe('storage and store compatibility', () => { it('distinguishes a missing task key from an intentionally empty task list', () => { expect(loadTasksData()).toMatchObject({ tasks: [], corrupted: false, exists: false }); localStorage.setItem('tasks', '[]'); expect(loadTasksData()).toMatchObject({ tasks: [], corrupted: false, exists: true }); }); it('preserves corrupted raw data without replacing it with seed tasks', () => { localStorage.setItem('tasks', '{broken'); initializeStore([base(1, 'Seed')], false); expect(getTasks()).toEqual([]); expect(localStorage.getItem('tasks')).toBe('{broken'); expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{broken'); }); it('restores all legacy preferences and task fields', () => { localStorage.setItem('tasks', JSON.stringify(legacyBackup.data.tasks)); localStorage.setItem('taskSortOrders', JSON.stringify(legacyBackup.data.sortOrders)); localStorage.setItem('showHiddenCompletedTasks', 'true'); localStorage.setItem('showHiddenTodoTasks', 'false'); localStorage.setItem('tasksInitialized', 'true'); localStorage.setItem('tasksCorruptedBackup', '{historical backup'); initializeStore([], isInitialized()); expect(getTasks()).toMatchObject(legacyBackup.data.tasks); expect(getSortOrders()).toEqual(legacyBackup.data.sortOrders); expect(getShowHidden('completed')).toBe(true); expect(getShowHidden('todo')).toBe(false); expect(loadSortOrdersData()).toEqual(legacyBackup.data.sortOrders); expect(loadShowHiddenData('completed')).toBe(true); expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{historical backup'); }); it('maintains store ids and rejects invalid preference mutations', () => { initializeStore([], true); const first = addTask(base(1, 'First')); const duplicate = addTask(base(1, 'Second')); const [appended] = appendTasks([base(first.id, 'Third')]); expect(new Set([first.id, duplicate.id, appended.id]).size).toBe(3); expect(setSortOrder('invalid', 'title-asc')).toBe(false); expect(setSortOrder('todo', 'invalid')).toBe(false); expect(setShowHidden('inProgress', true)).toBe(false); expect(localStorage.getItem('undefined')).toBeNull(); }); it('degrades safely when storage access throws', () => { const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new DOMException('blocked', 'SecurityError'); }); expect(loadTasksData()).toMatchObject({ tasks: [], exists: false, available: false }); expect(isInitialized()).toBe(false); getItem.mockRestore(); vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new DOMException('full', 'QuotaExceededError'); }); expect(() => initializeStore([base(1, 'Seed')], false)).not.toThrow(); expect(consumeStorageWriteFailure()).toBe(true); expect(consumeStorageWriteFailure()).toBe(false); }); }); describe('imports', () => { function loadPending(data) { const original = globalThis.FileReader; class FakeReader { readAsText() { this.result = JSON.stringify(data); this.onload(); } } globalThis.FileReader = FakeReader; readImportFile({}, vi.fn(), vi.fn(), vi.fn()); globalThis.FileReader = original; } beforeEach(() => { globalThis.bootstrap = { Modal: { getOrCreateInstance: () => ({ hide: vi.fn(), show: vi.fn() }) }, }; document.body.innerHTML = '
'; initializeStore([], true); }); it('validates the legacy export fixture', () => { expect(validateImportData(legacyBackup)).toBe(true); expect(validateImportData({ version: '1.0', data: { tasks: [] } })).toBe(true); }); it('rejects unsupported versions, invalid task shapes, and dates', () => { expect(validateImportData(null)).toBe(false); expect(validateImportData({ version: '2.0', data: { tasks: [] } })).toBe(false); expect(validateImportData({ version: '1.0', data: { tasks: 'bad' } })).toBe(false); expect( validateImportData({ version: '1.0', data: { tasks: [{ id: 1, title: '', status: 'todo' }] }, }), ).toBe(false); expect( validateImportData({ version: '1.0', data: { tasks: [{ ...base(1, 'Bad date'), dueDate: '2025-02-30' }] }, }), ).toBe(false); }); it('deduplicates merge key and preserves missing dates', () => { const existing = [base(1, 'Same')]; const result = mergeTasks(existing, [ base(2, 'Same', { createdDate: existing[0].createdDate }), { ...base(3, 'Same'), createdDate: undefined }, ]); expect(result.skipped).toBe(1); expect(result.tasks).toHaveLength(2); expect(new Set(result.tasks.map((task) => task.id)).size).toBe(2); }); it('executes replacement import with legacy tasks and sort orders', () => { addTask(base(1, 'Existing')); loadPending(legacyBackup); expect(executeImport('replace')).toBe(0); expect(getTasks()).toMatchObject(legacyBackup.data.tasks); expect(getSortOrders()).toEqual(legacyBackup.data.sortOrders); }); it('executes merge import and reports duplicates', () => { const existing = legacyBackup.data.tasks[0]; addTask(existing); loadPending(legacyBackup); expect(executeImport('merge')).toBe(1); expect(getTasks()).toHaveLength(2); }); it('rejects an invalid import mode', () => { loadPending(legacyBackup); expect(() => executeImport('unexpected')).toThrow(TypeError); }); it('clears a previous pending import when a newly selected file is invalid', () => { loadPending(legacyBackup); loadPending({ version: '1.0', data: { tasks: 'invalid' } }); expect(() => executeImport('replace')).toThrow('No import pending'); }); }); describe('timeline entries', () => { beforeEach(() => initializeStore([], true)); it('ignores fields that the change set omits', () => { const task = addTask(base(1, 'Untouched', { assignee: 'Ada', dueDate: '2025-01-01' })); expect(addSystemEntries(task, { progress: task.progress })).toEqual([]); }); it('records only the fields that actually changed', () => { const task = addTask(base(1, 'Changed')); const timeline = addSystemEntries(task, { progress: 80, status: task.status }); expect(timeline).toHaveLength(1); expect(timeline[0].event).toEqual({ key: 'progressUpdated', values: { from: 20, to: 80 } }); }); it('turns an edited system entry into a manual entry owned by the assignee', () => { const task = addTask(base(1, 'Edited', { assignee: 'Ada' })); const timeline = addSystemEntries(task, { progress: 80 }); updateTask(task.id, { timeline }); expect(updateTimelineEntry(task.id, timeline[0].id, ' my own words ')).toBe(true); const [stored] = getTasks(); expect(stored.timeline[0]).toMatchObject({ type: 'manual-entry', content: 'my own words', user: 'Ada', }); expect(stored.timeline[0].userType).toBeUndefined(); expect(stored.timeline[0].event).toBeUndefined(); }); it('keeps the original author when an existing manual entry is edited', () => { const task = addTask(base(1, 'Reassigned', { assignee: 'Ada' })); updateTask(task.id, { timeline: [ { id: 11, date: '2025-01-01', content: 'by bob', type: 'manual-entry', user: 'Bob' }, ], }); expect(updateTimelineEntry(task.id, 11, ' typo fixed ')).toBe(true); expect(getTasks()[0].timeline[0]).toMatchObject({ user: 'Bob', content: 'typo fixed' }); }); }); describe('board rendering', () => { const fixture = () => '
' + STATUSES.map( (status) => `` + `` + `
`, ).join('') + HIDEABLE_STATUSES.map( (status) => ``, ).join('') + '
'; const visibleTitles = (status) => [...document.querySelectorAll(`#${status}Tasks .task-card`)] .filter((card) => !card.hidden) .map((card) => card.querySelector('h6').textContent); const entryTexts = (status) => [...document.querySelectorAll(`#${status}Tasks .timeline-content`)].map((node) => node.lastElementChild.textContent.trim(), ); const search = (term) => { document.querySelector('#searchInput').value = term; applySearchFilter(); updateTaskCounts(); }; beforeEach(() => { document.body.innerHTML = fixture(); initializeStore([], true); }); it('lists timeline entries newest first within a single day', () => { const task = addTask(base(1, 'Ordered')); ['first', 'second', 'third'].forEach((text) => addTimelineEntry(task.id, text)); renderColumn('todo'); expect(entryTexts('todo')).toEqual(['third', 'second', 'first']); }); it('lists timeline entries newest first across days', () => { const task = addTask(base(1, 'Ordered')); updateTask(task.id, { timeline: [ { id: 11, date: '2025-01-01', content: 'older', type: 'manual-entry', user: 'Ada' }, { id: 12, date: '2025-06-01', content: 'newer', type: 'manual-entry', user: 'Ada' }, ], }); renderColumn('todo'); expect(entryTexts('todo')).toEqual(['newer', 'older']); }); it('lists entries carrying an unusable date last', () => { const task = addTask(base(1, 'Ordered')); updateTask(task.id, { timeline: [ { id: 11, date: 'sometime', content: 'undated', type: 'manual-entry', user: 'Ada' }, { id: 12, date: '2025-01-01', content: 'dated', type: 'manual-entry', user: 'Ada' }, ], }); renderColumn('todo'); expect(entryTexts('todo')).toEqual(['dated', 'undated']); }); it('searches task content without matching card boilerplate', () => { addTask(base(1, 'Apple', { description: 'crisp fruit', assignee: 'Ada' })); addTask(base(2, 'Banana', { progressNotes: 'still ripening' })); renderColumn('todo'); search('apple'); expect(visibleTitles('todo')).toEqual(['Apple']); search('crisp'); expect(visibleTitles('todo')).toEqual(['Apple']); search('ripening'); expect(visibleTitles('todo')).toEqual(['Banana']); search('ada'); expect(visibleTitles('todo')).toEqual(['Apple']); [t('timeline'), t('addEntry'), t('edit'), t('progress')].forEach((label) => { search(label); expect(visibleTitles('todo')).toEqual([]); }); }); it('keeps the header count in step with the active search', () => { addTask(base(1, 'Apple')); addTask(base(2, 'Banana')); renderColumn('todo'); expect(document.querySelector('#todoCount').textContent).toBe('2'); search('apple'); expect(document.querySelector('#todoCount').textContent).toBe('1'); search(''); expect(document.querySelector('#todoCount').textContent).toBe('2'); }); it('leaves hidden tasks out of the header count while they stay collapsed', () => { addTask(base(1, 'Visible')); addTask(base(2, 'Quiet', { isHidden: true })); renderColumn('todo'); expect(document.querySelector('#todoCount').textContent).toBe('1'); setShowHidden('todo', true); renderColumn('todo'); expect(document.querySelector('#todoCount').textContent).toBe('2'); }); it('explains a column emptied by the search instead of leaving it blank', () => { addTask(base(1, 'Apple')); renderColumn('todo'); search('kiwi'); expect(document.querySelector('#todoTasks .search-empty-state').textContent).toBe( t('noSearchResults'), ); search('apple'); expect(document.querySelector('#todoTasks .search-empty-state')).toBeNull(); }); it('dims hidden todo tasks but leaves in-progress tasks alone', () => { addTask(base(1, 'Hidden todo', { isHidden: true })); addTask(base(2, 'Hidden work', { status: 'inProgress', isHidden: true })); setShowHidden('todo', true); renderColumn('todo'); renderColumn('inProgress'); expect(document.querySelector('#todoTasks .task-card').className).toContain('hidden-task'); expect(document.querySelector('#inProgressTasks .task-card').className).not.toContain( 'hidden-task', ); expect(document.querySelector('#inProgressCount').textContent).toBe('1'); }); }); describe('export', () => { const original = {}; beforeEach(() => { original.createObjectURL = URL.createObjectURL; original.revokeObjectURL = URL.revokeObjectURL; document.body.innerHTML = ''; initializeStore([], true); }); afterEach(() => { URL.createObjectURL = original.createObjectURL; URL.revokeObjectURL = original.revokeObjectURL; vi.useRealTimers(); }); it('clicks an attached anchor and revokes the blob url only afterwards', () => { vi.useFakeTimers(); addTask(base(1, 'Exported')); URL.createObjectURL = vi.fn(() => 'blob:fake'); URL.revokeObjectURL = vi.fn(); let connectedAtClick = null; const createElement = document.createElement.bind(document); vi.spyOn(document, 'createElement').mockImplementation((tag) => { const element = createElement(tag); if (tag === 'a') element.click = () => (connectedAtClick = element.isConnected); return element; }); expect(exportData()).toMatch(/^backup-tasks-\d{4}-\d{2}-\d{2}\.json$/); expect(connectedAtClick).toBe(true); expect(document.querySelector('a')).toBeNull(); expect(URL.revokeObjectURL).not.toHaveBeenCalled(); vi.runAllTimers(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:fake'); }); });