mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features
- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio), summarize/improve/translate commands, proofread via ai:analyze; calls proxied through main so API keys stay out of the renderer - Collaboration plugin: anchor-based comments in .comments/ sidecars with drift detection and F8 navigation - Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel - Crash recovery: debounced session snapshots with restore prompt on launch - Version history: pre-save snapshots, History panel with restore/diff/delete - Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI) - XLSX export (native workbooks via JSZip), ODT headers/footers + page size - Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering - Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter, writing heatmap, writing-studio panels wired with rail icons - Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links, REPL first-run confirmation - Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI dangling --css/--reference-doc flags, dead converter button 8 new test suites; 613 tests green; lint clean
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* SessionStore (crash recovery) unit tests. Uses an in-memory Storage stub so
|
||||
* the pure capture/save/load/clear logic can be tested without jsdom
|
||||
* localStorage behavior, plus a quota-failing stub for the error paths.
|
||||
*/
|
||||
const {
|
||||
captureSession,
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
STORAGE_KEY,
|
||||
} = require('../src/utils/session-store');
|
||||
|
||||
/** Minimal synchronous Storage stub. */
|
||||
function makeStorage({ quotaFails = false } = {}) {
|
||||
const map = new Map();
|
||||
return {
|
||||
setItem(k, v) {
|
||||
if (quotaFails) throw new Error('QuotaExceededError');
|
||||
map.set(k, v);
|
||||
},
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
removeItem(k) {
|
||||
map.delete(k);
|
||||
},
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: build a tab map like TabManager's
|
||||
function tabMapOf(...tabs) {
|
||||
const map = new Map();
|
||||
tabs.forEach((t) => map.set(t.id, t));
|
||||
return map;
|
||||
}
|
||||
|
||||
describe('SessionStore', () => {
|
||||
describe('captureSession', () => {
|
||||
it('captures full content for dirty tabs and paths only for clean file tabs', () => {
|
||||
const session = captureSession(
|
||||
tabMapOf(
|
||||
{ id: 1, title: 'notes.md', filePath: '/tmp/notes.md', isDirty: true, type: 'markdown' },
|
||||
{
|
||||
id: 2,
|
||||
title: 'readme.md',
|
||||
filePath: '/tmp/readme.md',
|
||||
isDirty: false,
|
||||
type: 'markdown',
|
||||
},
|
||||
{ id: 3, title: 'Untitled', filePath: null, isDirty: true, type: 'markdown' }
|
||||
),
|
||||
1,
|
||||
(id) => `content-of-${id}`
|
||||
);
|
||||
|
||||
expect(session.version).toBe(1);
|
||||
expect(session.activeTabId).toBe(1);
|
||||
expect(session.tabs).toHaveLength(3);
|
||||
|
||||
const dirty = session.tabs.find((t) => t.id === 1);
|
||||
expect(dirty.content).toBe('content-of-1');
|
||||
expect(dirty.isDirty).toBe(true);
|
||||
|
||||
const clean = session.tabs.find((t) => t.id === 2);
|
||||
expect(clean.content).toBeUndefined();
|
||||
expect(clean.filePath).toBe('/tmp/readme.md');
|
||||
|
||||
const untitled = session.tabs.find((t) => t.id === 3);
|
||||
expect(untitled.content).toBe('content-of-3');
|
||||
});
|
||||
|
||||
it('drops empty untitled tabs', () => {
|
||||
const session = captureSession(
|
||||
tabMapOf({ id: 1, title: 'Untitled', filePath: null, isDirty: false, type: 'markdown' }),
|
||||
1,
|
||||
() => ''
|
||||
);
|
||||
expect(session.tabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps PDF tabs as path-only entries', () => {
|
||||
const session = captureSession(
|
||||
tabMapOf({
|
||||
id: 1,
|
||||
title: 'doc.pdf',
|
||||
filePath: '/tmp/doc.pdf',
|
||||
isDirty: false,
|
||||
type: 'pdf',
|
||||
}),
|
||||
1,
|
||||
() => ''
|
||||
);
|
||||
expect(session.tabs).toHaveLength(1);
|
||||
expect(session.tabs[0].type).toBe('pdf');
|
||||
expect(session.tabs[0].filePath).toBe('/tmp/doc.pdf');
|
||||
expect('content' in session.tabs[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('stops capturing content once the size budget is exhausted', () => {
|
||||
const big = 'x'.repeat(1024 * 1024); // 1MB each
|
||||
const session = captureSession(
|
||||
tabMapOf(
|
||||
{ id: 1, title: 'a', filePath: null, isDirty: true, type: 'markdown' },
|
||||
{ id: 2, title: 'b', filePath: null, isDirty: true, type: 'markdown' },
|
||||
{ id: 3, title: 'c', filePath: null, isDirty: true, type: 'markdown' }
|
||||
),
|
||||
3,
|
||||
() => big
|
||||
);
|
||||
// 2MB budget holds exactly two 1MB buffers
|
||||
const captured = session.tabs.filter((t) => typeof t.content === 'string');
|
||||
expect(captured.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSession / loadSession', () => {
|
||||
it('round-trips a snapshot', () => {
|
||||
const storage = makeStorage();
|
||||
const session = captureSession(
|
||||
tabMapOf({ id: 1, title: 'x', filePath: null, isDirty: true, type: 'markdown' }),
|
||||
1,
|
||||
() => 'hello'
|
||||
);
|
||||
saveSession(storage, session);
|
||||
expect(loadSession(storage)).toEqual(session);
|
||||
});
|
||||
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(loadSession(makeStorage())).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for corrupt JSON', () => {
|
||||
const storage = makeStorage();
|
||||
storage.setItem(STORAGE_KEY, '{not json');
|
||||
expect(loadSession(storage)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown versions or empty tab lists', () => {
|
||||
const storage = makeStorage();
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 99, tabs: [{}] }));
|
||||
expect(loadSession(storage)).toBeNull();
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, tabs: [] }));
|
||||
expect(loadSession(storage)).toBeNull();
|
||||
});
|
||||
|
||||
it('swallows quota errors and clears any stale snapshot', () => {
|
||||
const storage = makeStorage({ quotaFails: true });
|
||||
expect(() => saveSession(storage, { version: 1, tabs: [] })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSession', () => {
|
||||
it('removes the stored snapshot', () => {
|
||||
const storage = makeStorage();
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, tabs: [{}] }));
|
||||
clearSession(storage);
|
||||
expect(storage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user