- 移除 seed-data 模块,首次运行改为持久化空任务列表 - 简化 initializeStore(),将 tasksInitialized 处理集中到 store 层 - 移除 loadTasksData() 写入 tasksInitialized 的副作用 - 已归一化的任务数据跳过多余的 localStorage 回写 - 为旧版安装补写 tasksInitialized;首次写入失败时下次访问重试 - 补充存储兼容性测试并更新 README
543 lines
21 KiB
JavaScript
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');
|
|
});
|
|
});
|