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 = () => '