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,149 @@
|
||||
/**
|
||||
* Collaboration comment-store tests — pure logic with in-memory IO fakes,
|
||||
* covering persistence, navigation ordering, and anchor drift detection.
|
||||
*/
|
||||
const path = require('path');
|
||||
const store = require('../src/plugins/built-in/collaboration/comment-store');
|
||||
|
||||
/** In-memory IO fake shaped like the plugin's IPC adapters. */
|
||||
function makeIo(initial = {}) {
|
||||
const files = new Map(Object.entries(initial));
|
||||
return {
|
||||
files,
|
||||
readFile: (p) => (files.has(p) ? files.get(p) : null),
|
||||
writeFile: (p, c) => {
|
||||
files.set(p, c);
|
||||
},
|
||||
fileExists: (p) => files.has(p),
|
||||
ensureDirectory: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('comment-store', () => {
|
||||
const doc = path.join('/docs', 'notes.md');
|
||||
const sidecar = path.join('/docs', '.comments', 'notes.md.json');
|
||||
|
||||
describe('commentsFilePathFor', () => {
|
||||
it('places the sidecar in .comments/ next to the document', () => {
|
||||
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
|
||||
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
|
||||
path.join('/a/b/.comments', 'c.md.json')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load/save round-trip', () => {
|
||||
it('returns [] for documents with no sidecar yet', () => {
|
||||
expect(store.loadComments(doc, makeIo(), path)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for corrupt JSON instead of throwing', () => {
|
||||
const io = makeIo({ [sidecar]: '{oops' });
|
||||
expect(store.loadComments(doc, io, path)).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists and reloads comments sorted by line', () => {
|
||||
const io = makeIo();
|
||||
const comments = [];
|
||||
store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' });
|
||||
store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' });
|
||||
store.saveComments(doc, comments, io, path);
|
||||
|
||||
const loaded = store.loadComments(doc, io, path);
|
||||
expect(loaded.map((c) => c.line)).toEqual([3, 12]);
|
||||
expect(loaded[0].text).toBe('strong opener');
|
||||
expect(loaded[0].resolved).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes malformed entries on load', () => {
|
||||
const io = makeIo({
|
||||
[sidecar]: JSON.stringify({
|
||||
comments: [
|
||||
{ line: '7', text: 42 },
|
||||
{ line: 2, text: 'ok', author: 'ana' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const loaded = store.loadComments(doc, io, path);
|
||||
// '7' (string line) is dropped; the valid one survives normalization
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutators', () => {
|
||||
it('addComment clamps lines and caps field lengths', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, {
|
||||
line: -5,
|
||||
anchorText: 'x'.repeat(500),
|
||||
text: 'y'.repeat(5000),
|
||||
});
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(c.line).toBe(1);
|
||||
expect(c.anchorText.length).toBeLessThanOrEqual(200);
|
||||
expect(c.text.length).toBeLessThanOrEqual(4000);
|
||||
expect(c.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggleResolved flips state and returns null for unknown ids', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||
expect(store.toggleResolved(comments, c.id)).toBe(true);
|
||||
expect(store.toggleResolved(comments, c.id)).toBe(false);
|
||||
expect(store.toggleResolved(comments, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteComment reports whether anything was removed', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||
expect(store.deleteComment(comments, c.id)).toBe(true);
|
||||
expect(comments).toHaveLength(0);
|
||||
expect(store.deleteComment(comments, c.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextUnresolved (F8 navigation)', () => {
|
||||
it('returns the first open comment below the cursor line', () => {
|
||||
const comments = [
|
||||
{ id: 'a', line: 5, resolved: false },
|
||||
{ id: 'b', line: 20, resolved: false },
|
||||
];
|
||||
expect(store.nextUnresolved(comments, 7).id).toBe('b');
|
||||
});
|
||||
|
||||
it('wraps to the top when no open comments are below', () => {
|
||||
const comments = [
|
||||
{ id: 'a', line: 5, resolved: false },
|
||||
{ id: 'b', line: 20, resolved: true },
|
||||
];
|
||||
expect(store.nextUnresolved(comments, 10).id).toBe('a');
|
||||
});
|
||||
|
||||
it('returns null when everything is resolved or empty', () => {
|
||||
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
|
||||
expect(store.nextUnresolved([], 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchorStatus (drift detection)', () => {
|
||||
const base = { line: 2, anchorText: 'original text' };
|
||||
|
||||
it('reports ok when the line still matches', () => {
|
||||
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
|
||||
});
|
||||
|
||||
it('reports changed when the line text differs', () => {
|
||||
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
|
||||
});
|
||||
|
||||
it('reports missing when the line is beyond the document', () => {
|
||||
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
|
||||
expect(store.anchorStatus(base, [])).toBe('missing');
|
||||
});
|
||||
|
||||
it('reports moved for comments without an anchor snippet', () => {
|
||||
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user