diff --git a/tests/unit/history-log.test.ts b/tests/unit/history-log.test.ts new file mode 100644 index 0000000..26addb1 --- /dev/null +++ b/tests/unit/history-log.test.ts @@ -0,0 +1,77 @@ +// The labelled undo/redo mirror (utils/historyLog.svelte.ts) against a live editor: +// labels per edit kind, entries moving between the stacks on undo/redo, and the +// redo branch being discarded by a fresh edit. +import { describe, it, expect, beforeEach } from 'vitest'; +import { Editor } from '@tiptap/core'; +import { closeHistory } from '@tiptap/pm/history'; +import { extensions } from '../../src/lib/editor/extensions'; +import { historyLog, recordTransaction, resetHistoryLog } from '../../src/lib/utils/historyLog.svelte'; + +function makeEditor() { + const el = document.createElement('div'); + document.body.appendChild(el); + return new Editor({ + element: el, + extensions, + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + onTransaction: ({ editor, transaction }) => recordTransaction(editor.state, transaction), + }); +} + +// Consecutive edits in one test tick share a timestamp and would merge into one +// undo group; an explicit group boundary keeps one log entry per edit. +const endGroup = (editor: Editor) => + editor.view.dispatch(closeHistory(editor.state.tr)); + +const undoLabels = () => historyLog.undo.map((e) => e.label); +const redoLabels = () => historyLog.redo.map((e) => e.label); + +describe('history log', () => { + beforeEach(resetHistoryLog); + + it('labels typing, marks, structure and delete', () => { + const editor = makeEditor(); + editor.commands.insertContent('Hello world'); + endGroup(editor); + editor.chain().setTextSelection({ from: 1, to: 6 }).toggleBold().run(); + endGroup(editor); + editor.commands.insertContentAt(editor.state.doc.content.size, + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] }); + endGroup(editor); + editor.chain().setTextSelection({ from: 7, to: 12 }).deleteSelection().run(); + + expect(undoLabels()).toEqual(['Typing: “Hello world”', 'Bold', 'Heading 2', 'Delete']); + expect(redoLabels()).toEqual([]); + editor.destroy(); + }); + + it('moves entries across on undo/redo and keeps counts at the plugin depths', () => { + const editor = makeEditor(); + editor.commands.insertContent('one'); + endGroup(editor); + editor.chain().selectAll().toggleBold().run(); + + editor.commands.undo(); + expect(undoLabels()).toEqual(['Typing: “one”']); + expect(redoLabels()).toEqual(['Bold']); + + editor.commands.redo(); + expect(undoLabels()).toEqual(['Typing: “one”', 'Bold']); + expect(redoLabels()).toEqual([]); + editor.destroy(); + }); + + it('a fresh edit after undo discards the redo branch', () => { + const editor = makeEditor(); + editor.commands.insertContent('one'); + endGroup(editor); + editor.commands.insertContent(' two'); + editor.commands.undo(); + expect(redoLabels()).toHaveLength(1); + + editor.commands.insertContent(' three'); + expect(redoLabels()).toEqual([]); + expect(undoLabels()).toEqual(['Typing: “one”', 'Typing: “three”']); + editor.destroy(); + }); +}); diff --git a/tests/unit/recent-files.test.ts b/tests/unit/recent-files.test.ts new file mode 100644 index 0000000..0a3f1a7 --- /dev/null +++ b/tests/unit/recent-files.test.ts @@ -0,0 +1,49 @@ +// The recent-files list (storage/recentFiles.ts): dedupe by name, newest first, +// capped at eight, and tolerant of corrupt localStorage. jsdom has no IndexedDB, +// so the handle side no-ops — exactly the browser-without-picker path. +import { describe, it, expect, beforeEach } from 'vitest'; +import { loadRecentFiles, rememberRecentFile, forgetRecentFiles } from '../../src/lib/storage/recentFiles'; + +const KEY = 'edentext-recent-files'; + +describe('recent files', () => { + beforeEach(() => localStorage.clear()); + + it('starts empty and survives corrupt or foreign values under its key', () => { + expect(loadRecentFiles()).toEqual([]); + localStorage.setItem(KEY, 'not json{'); + expect(loadRecentFiles()).toEqual([]); + localStorage.setItem(KEY, '{"a":1}'); + expect(loadRecentFiles()).toEqual([]); + localStorage.setItem(KEY, '[{"id":"x","name":"ok.odt","at":1},{"id":"y","at":2},null]'); + expect(loadRecentFiles().map((f) => f.name)).toEqual(['ok.odt']); + }); + + it('remembers newest first and dedupes a handleless file by name, keeping its id', async () => { + await rememberRecentFile('a.odt', null); + await rememberRecentFile('b.odt', null); + expect(loadRecentFiles().map((f) => f.name)).toEqual(['b.odt', 'a.odt']); + + const idA = loadRecentFiles()[1].id; + await rememberRecentFile('a.odt', null); + const list = loadRecentFiles(); + expect(list.map((f) => f.name)).toEqual(['a.odt', 'b.odt']); + expect(list[0].id).toBe(idA); + }); + + it('keeps at most eight entries, dropping the oldest', async () => { + for (let i = 1; i <= 10; i++) await rememberRecentFile(`f${i}.odt`, null); + const names = loadRecentFiles().map((f) => f.name); + expect(names).toHaveLength(8); + expect(names[0]).toBe('f10.odt'); + expect(names).not.toContain('f1.odt'); + expect(names).not.toContain('f2.odt'); + }); + + it('forgetRecentFiles clears the list and removes the key', async () => { + await rememberRecentFile('a.odt', null); + forgetRecentFiles(); + expect(loadRecentFiles()).toEqual([]); + expect(localStorage.getItem(KEY)).toBeNull(); + }); +});