Files
Tasks/test/core.test.js
T
eddy f1c6c052bc refactor: 首次运行为空列表并简化存储初始化
- 移除 seed-data 模块,首次运行改为持久化空任务列表
- 简化 initializeStore(),将 tasksInitialized 处理集中到 store 层
- 移除 loadTasksData() 写入 tasksInitialized 的副作用
- 已归一化的任务数据跳过多余的 localStorage 回写
- 为旧版安装补写 tasksInitialized;首次写入失败时下次访问重试
- 补充存储兼容性测试并更新 README
2026-08-01 14:53:32 +08:00

543 lines
21 KiB
JavaScript

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';
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();
consumeStorageWriteFailure();
});
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('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('backfills tasksInitialized for legacy installs that already have tasks', () => {
localStorage.setItem('tasks', JSON.stringify([base(1, 'Legacy')]));
expect(isInitialized()).toBe(false);
initializeStore();
expect(isInitialized()).toBe(true);
expect(getTasks()).toMatchObject([{ id: 1, title: 'Legacy' }]);
});
it('starts with an empty task list on first use', () => {
initializeStore();
expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBe('[]');
expect(isInitialized()).toBe(true);
expect(consumeStorageWriteFailure()).toBe(false);
});
it('skips the redundant rewrite when stored tasks are already normalized', () => {
initializeStore();
addTask(base(1, 'Kept'));
const setItem = vi.spyOn(Storage.prototype, 'setItem');
initializeStore();
const taskWrites = setItem.mock.calls.filter(([key]) => key === 'tasks');
expect(taskWrites).toHaveLength(0);
expect(getTasks()).toMatchObject([{ id: 1, title: 'Kept' }]);
});
it('does not mark initialized when first persist fails', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError');
});
initializeStore();
expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBeNull();
expect(isInitialized()).toBe(false);
expect(consumeStorageWriteFailure()).toBe(true);
});
it('attempts no write at all when storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blocked', 'SecurityError');
});
const setItem = vi.spyOn(Storage.prototype, 'setItem');
initializeStore();
expect(getTasks()).toEqual([]);
expect(setItem).not.toHaveBeenCalled();
expect(consumeStorageWriteFailure()).toBe(false);
});
it('preserves corrupted raw data without replacing it', () => {
localStorage.setItem('tasks', '{broken');
initializeStore();
expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBe('{broken');
expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{broken');
expect(isInitialized()).toBe(false);
});
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();
expect(getTasks()).toMatchObject(legacyBackup.data.tasks);
expect(JSON.parse(localStorage.getItem('tasks'))).toEqual(getTasks());
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();
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()).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 = '<div id="importModal"></div>';
initializeStore();
});
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());
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 = () =>
'<input id="searchInput" value="" /><div id="taskBoard">' +
STATUSES.map(
(status) =>
`<span id="${status}Count"></span>` +
`<select class="sort-selector" data-status="${status}"></select>` +
`<div id="${status}Tasks" class="task-list" data-status="${status}"></div>`,
).join('') +
HIDEABLE_STATUSES.map(
(status) =>
`<button data-action="toggle-show-hidden" data-status="${status}"><i></i></button>`,
).join('') +
'</div>';
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();
});
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();
});
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');
});
});