mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(ai): add Anthropic-compatible provider; fix async plugin backends
Anthropic-compatible provider: - New 'anthropic-compatible' option for any base URL speaking the Anthropic messages schema (LiteLLM proxies, Bedrock gateways, local servers) - Sends x-api-key AND Bearer auth when a key is set (gateway-friendly, harmless for the official API); keyless proxies supported - Tolerates base URLs with or without a trailing /v1 segment - Settings modal, manifest, and provider docs updated Runtime bug fixes found by booting the app (run-to-verify pass): - PDF editor: File > Open PDF sends operation=null which matched no switch case and crashed on getElementById(undefined); now defaults to the merge section - backlinks-panel: wrong require depth (../../utils -> ../utils) threw at panel registration time - writing-studio stack was written against a synchronous settings backend but the real one is IPC-backed: GoalTracker/SnapshotManager/ProjectManager and all four panels now await; JSON.parse(Promise) crashes eliminated - manuscript panel used window.prompt (unavailable in Electron); replaced with an inline dialog - collaboration comment-store/save-load made async to match its IPC IO 617/617 tests green; 4 consecutive clean app boots (no uncaught errors)
This commit is contained in:
@@ -10,11 +10,11 @@ function makeIo(initial = {}) {
|
||||
const files = new Map(Object.entries(initial));
|
||||
return {
|
||||
files,
|
||||
readFile: (p) => (files.has(p) ? files.get(p) : null),
|
||||
writeFile: (p, c) => {
|
||||
readFile: async (p) => (files.has(p) ? files.get(p) : null),
|
||||
writeFile: async (p, c) => {
|
||||
files.set(p, c);
|
||||
},
|
||||
fileExists: (p) => files.has(p),
|
||||
fileExists: async (p) => files.has(p),
|
||||
ensureDirectory: () => {},
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,7 @@ describe('comment-store', () => {
|
||||
const sidecar = path.join('/docs', '.comments', 'notes.md.json');
|
||||
|
||||
describe('commentsFilePathFor', () => {
|
||||
it('places the sidecar in .comments/ next to the document', () => {
|
||||
it('places the sidecar in .comments/ next to the document', async () => {
|
||||
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
|
||||
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
|
||||
path.join('/a/b/.comments', 'c.md.json')
|
||||
@@ -33,29 +33,29 @@ describe('comment-store', () => {
|
||||
});
|
||||
|
||||
describe('load/save round-trip', () => {
|
||||
it('returns [] for documents with no sidecar yet', () => {
|
||||
expect(store.loadComments(doc, makeIo(), path)).toEqual([]);
|
||||
it('returns [] for documents with no sidecar yet', async () => {
|
||||
expect(await store.loadComments(doc, makeIo(), path)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for corrupt JSON instead of throwing', () => {
|
||||
it('returns [] for corrupt JSON instead of throwing', async () => {
|
||||
const io = makeIo({ [sidecar]: '{oops' });
|
||||
expect(store.loadComments(doc, io, path)).toEqual([]);
|
||||
expect(await store.loadComments(doc, io, path)).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists and reloads comments sorted by line', () => {
|
||||
it('persists and reloads comments sorted by line', async () => {
|
||||
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);
|
||||
await store.saveComments(doc, comments, io, path);
|
||||
|
||||
const loaded = store.loadComments(doc, io, path);
|
||||
const loaded = await 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', () => {
|
||||
it('normalizes malformed entries on load', async () => {
|
||||
const io = makeIo({
|
||||
[sidecar]: JSON.stringify({
|
||||
comments: [
|
||||
@@ -64,7 +64,7 @@ describe('comment-store', () => {
|
||||
],
|
||||
}),
|
||||
});
|
||||
const loaded = store.loadComments(doc, io, path);
|
||||
const loaded = await 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' });
|
||||
@@ -72,7 +72,7 @@ describe('comment-store', () => {
|
||||
});
|
||||
|
||||
describe('mutators', () => {
|
||||
it('addComment clamps lines and caps field lengths', () => {
|
||||
it('addComment clamps lines and caps field lengths', async () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, {
|
||||
line: -5,
|
||||
@@ -86,7 +86,7 @@ describe('comment-store', () => {
|
||||
expect(c.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggleResolved flips state and returns null for unknown ids', () => {
|
||||
it('toggleResolved flips state and returns null for unknown ids', async () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||
expect(store.toggleResolved(comments, c.id)).toBe(true);
|
||||
@@ -94,7 +94,7 @@ describe('comment-store', () => {
|
||||
expect(store.toggleResolved(comments, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteComment reports whether anything was removed', () => {
|
||||
it('deleteComment reports whether anything was removed', async () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||
expect(store.deleteComment(comments, c.id)).toBe(true);
|
||||
@@ -104,7 +104,7 @@ describe('comment-store', () => {
|
||||
});
|
||||
|
||||
describe('nextUnresolved (F8 navigation)', () => {
|
||||
it('returns the first open comment below the cursor line', () => {
|
||||
it('returns the first open comment below the cursor line', async () => {
|
||||
const comments = [
|
||||
{ id: 'a', line: 5, resolved: false },
|
||||
{ id: 'b', line: 20, resolved: false },
|
||||
@@ -112,7 +112,7 @@ describe('comment-store', () => {
|
||||
expect(store.nextUnresolved(comments, 7).id).toBe('b');
|
||||
});
|
||||
|
||||
it('wraps to the top when no open comments are below', () => {
|
||||
it('wraps to the top when no open comments are below', async () => {
|
||||
const comments = [
|
||||
{ id: 'a', line: 5, resolved: false },
|
||||
{ id: 'b', line: 20, resolved: true },
|
||||
@@ -120,7 +120,7 @@ describe('comment-store', () => {
|
||||
expect(store.nextUnresolved(comments, 10).id).toBe('a');
|
||||
});
|
||||
|
||||
it('returns null when everything is resolved or empty', () => {
|
||||
it('returns null when everything is resolved or empty', async () => {
|
||||
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
|
||||
expect(store.nextUnresolved([], 1)).toBeNull();
|
||||
});
|
||||
@@ -129,20 +129,20 @@ describe('comment-store', () => {
|
||||
describe('anchorStatus (drift detection)', () => {
|
||||
const base = { line: 2, anchorText: 'original text' };
|
||||
|
||||
it('reports ok when the line still matches', () => {
|
||||
it('reports ok when the line still matches', async () => {
|
||||
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
|
||||
});
|
||||
|
||||
it('reports changed when the line text differs', () => {
|
||||
it('reports changed when the line text differs', async () => {
|
||||
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
|
||||
});
|
||||
|
||||
it('reports missing when the line is beyond the document', () => {
|
||||
it('reports missing when the line is beyond the document', async () => {
|
||||
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
|
||||
expect(store.anchorStatus(base, [])).toBe('missing');
|
||||
});
|
||||
|
||||
it('reports moved for comments without an anchor snippet', () => {
|
||||
it('reports moved for comments without an anchor snippet', async () => {
|
||||
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
|
||||
});
|
||||
});
|
||||
|
||||
+46
-25
@@ -1,5 +1,8 @@
|
||||
const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker');
|
||||
|
||||
// All GoalTracker methods are async (the real settings backend is the
|
||||
// IPC-backed SettingsStore; these tests inject a synchronous fake, which
|
||||
// `await` handles transparently).
|
||||
describe('GoalTracker', () => {
|
||||
let tracker;
|
||||
let store;
|
||||
@@ -14,71 +17,89 @@ describe('GoalTracker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('addWords records words for today', () => {
|
||||
tracker.addWords(500);
|
||||
test('addWords records words for today', async () => {
|
||||
await tracker.addWords(500);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
expect(store['plugins.writing-studio.history']).toBeDefined();
|
||||
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||
expect(history[today].words).toBe(500);
|
||||
});
|
||||
|
||||
test('addWords accumulates across multiple calls', () => {
|
||||
tracker.addWords(300);
|
||||
tracker.addWords(200);
|
||||
test('addWords accumulates across multiple calls', async () => {
|
||||
await tracker.addWords(300);
|
||||
await tracker.addWords(200);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||
expect(history[today].words).toBe(500);
|
||||
});
|
||||
|
||||
test('getDailyProgress returns 0 when no history', () => {
|
||||
expect(tracker.getDailyProgress(1000)).toEqual({ written: 0, goal: 1000, pct: 0 });
|
||||
test('works with a Promise-returning (IPC-style) backend', async () => {
|
||||
const asyncTracker = new GoalTracker({
|
||||
get: async (key) => store[key],
|
||||
set: async (key, value) => {
|
||||
store[key] = value;
|
||||
},
|
||||
});
|
||||
await asyncTracker.addWords(250);
|
||||
await expect(asyncTracker.getDailyProgress(1000)).resolves.toEqual({
|
||||
written: 250,
|
||||
goal: 1000,
|
||||
pct: 25,
|
||||
});
|
||||
});
|
||||
|
||||
test('getDailyProgress returns percentage', () => {
|
||||
tracker.addWords(500);
|
||||
const progress = tracker.getDailyProgress(1000);
|
||||
test('getDailyProgress returns 0 when no history', async () => {
|
||||
await expect(tracker.getDailyProgress(1000)).resolves.toEqual({
|
||||
written: 0,
|
||||
goal: 1000,
|
||||
pct: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('getDailyProgress returns percentage', async () => {
|
||||
await tracker.addWords(500);
|
||||
const progress = await tracker.getDailyProgress(1000);
|
||||
expect(progress.written).toBe(500);
|
||||
expect(progress.pct).toBe(50);
|
||||
});
|
||||
|
||||
test('getStreak counts consecutive days meeting goal', () => {
|
||||
test('getStreak counts consecutive days meeting goal', async () => {
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
tracker._setHistoryDay(key, { words: 1200 });
|
||||
await tracker._setHistoryDay(key, { words: 1200 });
|
||||
}
|
||||
const streak = tracker.getStreak(1000);
|
||||
expect(streak).toBe(3);
|
||||
await expect(tracker.getStreak(1000)).resolves.toBe(3);
|
||||
});
|
||||
|
||||
test('getStreak breaks on missed day', () => {
|
||||
test('getStreak breaks on missed day', async () => {
|
||||
const today = new Date();
|
||||
tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 });
|
||||
await tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 });
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 });
|
||||
await tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 });
|
||||
const dayBefore = new Date(today);
|
||||
dayBefore.setDate(dayBefore.getDate() - 2);
|
||||
tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 });
|
||||
expect(tracker.getStreak(1000)).toBe(1);
|
||||
await tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 });
|
||||
await expect(tracker.getStreak(1000)).resolves.toBe(1);
|
||||
});
|
||||
|
||||
test('getLast30Days returns array of 30 entries', () => {
|
||||
tracker.addWords(100);
|
||||
const days = tracker.getLast30Days();
|
||||
test('getLast30Days returns array of 30 entries', async () => {
|
||||
await tracker.addWords(100);
|
||||
const days = await tracker.getLast30Days();
|
||||
expect(days.length).toBe(30);
|
||||
expect(days[29].words).toBe(100);
|
||||
});
|
||||
|
||||
test('getWeeklyTotal sums last 7 days', () => {
|
||||
test('getWeeklyTotal sums last 7 days', async () => {
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 });
|
||||
await tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 });
|
||||
}
|
||||
expect(tracker.getWeeklyTotal()).toBe(1400);
|
||||
await expect(tracker.getWeeklyTotal()).resolves.toBe(1400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,10 @@ describe('AiProviders', () => {
|
||||
it('does not require an API key for local providers', () => {
|
||||
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
|
||||
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
|
||||
// Self-hosted Anthropic-compatible proxies may front their own auth
|
||||
expect(() =>
|
||||
resolveSettings({ provider: 'anthropic-compatible', baseUrl: 'http://localhost:4000' })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires an API key for remote providers', () => {
|
||||
@@ -109,6 +113,52 @@ describe('AiProviders', () => {
|
||||
expect(body.system).toBe('be nice');
|
||||
});
|
||||
|
||||
it('routes anthropic-compatible to the custom base with Bearer + x-api-key auth', async () => {
|
||||
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'proxy ok' }] });
|
||||
const result = await complete(
|
||||
{
|
||||
provider: 'anthropic-compatible',
|
||||
baseUrl: 'http://localhost:4000',
|
||||
apiKey: 'proxy-key',
|
||||
messages,
|
||||
},
|
||||
{ fetchImpl }
|
||||
);
|
||||
|
||||
expect(result.content).toBe('proxy ok');
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
// Base without a /v1 suffix gets one appended
|
||||
expect(url).toBe('http://localhost:4000/v1/messages');
|
||||
// Both auth styles are sent so LiteLLM/Bedrock-style gateways accept it
|
||||
expect(init.headers['x-api-key']).toBe('proxy-key');
|
||||
expect(init.headers.Authorization).toBe('Bearer proxy-key');
|
||||
expect(init.headers['anthropic-version']).toBe('2023-06-01');
|
||||
});
|
||||
|
||||
it('does not double the /v1 segment when the base already ends with it', async () => {
|
||||
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'ok' }] });
|
||||
await complete(
|
||||
{
|
||||
provider: 'anthropic-compatible',
|
||||
baseUrl: 'http://localhost:4000/v1/',
|
||||
messages,
|
||||
},
|
||||
{ fetchImpl }
|
||||
);
|
||||
// Trailing slash is stripped by resolveSettings, so the base ends in /v1
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('http://localhost:4000/v1/messages');
|
||||
// Keyless proxies must work without auth headers
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
expect(init.headers['x-api-key']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects anthropic-compatible without a base URL', async () => {
|
||||
await expect(
|
||||
complete({ provider: 'anthropic-compatible', messages }, { fetchImpl: okFetch({}) })
|
||||
).rejects.toThrow(/Invalid API base URL/);
|
||||
});
|
||||
|
||||
it('surfaces HTTP failures as user-safe errors without the body', async () => {
|
||||
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||
await expect(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager');
|
||||
|
||||
// All ProjectManager methods are async (the real fs backend is the IPC-backed
|
||||
// file API; these tests inject a synchronous fake, which `await` handles).
|
||||
describe('ProjectManager', () => {
|
||||
let pm;
|
||||
let files;
|
||||
@@ -19,8 +21,8 @@ describe('ProjectManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('createProject writes .project.json', () => {
|
||||
const project = pm.createProject('/manuscripts/novel', {
|
||||
test('createProject writes .project.json', async () => {
|
||||
const project = await pm.createProject('/manuscripts/novel', {
|
||||
title: 'My Novel',
|
||||
type: 'manuscript',
|
||||
targetWords: 80000,
|
||||
@@ -31,7 +33,7 @@ describe('ProjectManager', () => {
|
||||
expect(parsed.target.words).toBe(80000);
|
||||
});
|
||||
|
||||
test('loadProject reads and returns project data', () => {
|
||||
test('loadProject reads and returns project data', async () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
@@ -39,15 +41,15 @@ describe('ProjectManager', () => {
|
||||
chapters: [],
|
||||
metadata: {},
|
||||
});
|
||||
const project = pm.loadProject('/manuscripts/novel');
|
||||
const project = await pm.loadProject('/manuscripts/novel');
|
||||
expect(project.title).toBe('Test');
|
||||
});
|
||||
|
||||
test('loadProject returns null if no project file', () => {
|
||||
expect(pm.loadProject('/nonexistent')).toBeNull();
|
||||
test('loadProject returns null if no project file', async () => {
|
||||
await expect(pm.loadProject('/nonexistent')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('addChapter appends chapter and saves', () => {
|
||||
test('addChapter appends chapter and saves', async () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
@@ -55,7 +57,7 @@ describe('ProjectManager', () => {
|
||||
chapters: [],
|
||||
metadata: {},
|
||||
});
|
||||
pm.addChapter('/manuscripts/novel', {
|
||||
await pm.addChapter('/manuscripts/novel', {
|
||||
file: '01-chapter.md',
|
||||
title: 'Chapter One',
|
||||
status: 'draft',
|
||||
@@ -65,7 +67,7 @@ describe('ProjectManager', () => {
|
||||
expect(parsed.chapters[0].title).toBe('Chapter One');
|
||||
});
|
||||
|
||||
test('compileManuscript concatenates chapter files', () => {
|
||||
test('compileManuscript concatenates chapter files', async () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
@@ -78,11 +80,11 @@ describe('ProjectManager', () => {
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
||||
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
||||
const result = pm.compileManuscript('/manuscripts/novel');
|
||||
const result = await pm.compileManuscript('/manuscripts/novel');
|
||||
expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.');
|
||||
});
|
||||
|
||||
test('compileManuscript skips missing files', () => {
|
||||
test('compileManuscript skips missing files', async () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
@@ -94,11 +96,11 @@ describe('ProjectManager', () => {
|
||||
metadata: {},
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
||||
const result = pm.compileManuscript('/manuscripts/novel');
|
||||
const result = await pm.compileManuscript('/manuscripts/novel');
|
||||
expect(result).toBe('Only chapter one.');
|
||||
});
|
||||
|
||||
test('getStats returns total word count across chapters', () => {
|
||||
test('getStats returns total word count across chapters', async () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
@@ -111,14 +113,14 @@ describe('ProjectManager', () => {
|
||||
});
|
||||
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
||||
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
||||
const stats = pm.getStats('/manuscripts/novel');
|
||||
const stats = await pm.getStats('/manuscripts/novel');
|
||||
expect(stats.totalWords).toBeGreaterThan(0);
|
||||
expect(stats.chapterCount).toBe(2);
|
||||
expect(stats.targetWords).toBe(50000);
|
||||
expect(stats.pctComplete).toBeDefined();
|
||||
});
|
||||
|
||||
test('updateChapter modifies a chapter by index', () => {
|
||||
test('updateChapter modifies a chapter by index', async () => {
|
||||
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||
title: 'Test',
|
||||
type: 'manuscript',
|
||||
@@ -126,7 +128,7 @@ describe('ProjectManager', () => {
|
||||
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
||||
metadata: {},
|
||||
});
|
||||
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||
await pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||
expect(parsed.chapters[0].title).toBe('New Title');
|
||||
expect(parsed.chapters[0].status).toBe('revised');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager');
|
||||
|
||||
// All SnapshotManager methods are async (the real settings backend is the
|
||||
// IPC-backed SettingsStore; these tests inject a synchronous fake).
|
||||
describe('SnapshotManager', () => {
|
||||
let manager;
|
||||
let store;
|
||||
@@ -14,62 +16,63 @@ describe('SnapshotManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('create stores snapshot with timestamp, content, wordCount', () => {
|
||||
const snap = manager.create('Hello world this is a test', 'auto');
|
||||
test('create stores snapshot with timestamp, content, wordCount', async () => {
|
||||
const snap = await manager.create('Hello world this is a test', 'auto');
|
||||
expect(snap).toHaveProperty('id');
|
||||
expect(snap.content).toBe('Hello world this is a test');
|
||||
expect(snap.wordCount).toBe(6);
|
||||
expect(snap.label).toBe('auto');
|
||||
});
|
||||
|
||||
test('list returns snapshots ordered newest first', () => {
|
||||
manager.create('first', 'auto');
|
||||
manager.create('second', 'auto');
|
||||
const list = manager.list();
|
||||
test('list returns snapshots ordered newest first', async () => {
|
||||
await manager.create('first', 'auto');
|
||||
await manager.create('second', 'auto');
|
||||
const list = await manager.list();
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0].content).toBe('second');
|
||||
});
|
||||
|
||||
test('getById returns specific snapshot', () => {
|
||||
const snap = manager.create('find me', 'manual');
|
||||
const found = manager.getById(snap.id);
|
||||
test('getById returns specific snapshot', async () => {
|
||||
const snap = await manager.create('find me', 'manual');
|
||||
const found = await manager.getById(snap.id);
|
||||
expect(found.content).toBe('find me');
|
||||
});
|
||||
|
||||
test('getById returns null for missing id', () => {
|
||||
expect(manager.getById('nope')).toBeNull();
|
||||
test('getById returns null for missing id', async () => {
|
||||
await expect(manager.getById('nope')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('restore returns content of snapshot', () => {
|
||||
const snap = manager.create('restore this', 'manual');
|
||||
expect(manager.restore(snap.id)).toBe('restore this');
|
||||
test('restore returns content of snapshot', async () => {
|
||||
const snap = await manager.create('restore this', 'manual');
|
||||
await expect(manager.restore(snap.id)).resolves.toBe('restore this');
|
||||
});
|
||||
|
||||
test('restore throws for missing snapshot', () => {
|
||||
expect(() => manager.restore('nope')).toThrow('Snapshot not found');
|
||||
test('restore throws for missing snapshot', async () => {
|
||||
await expect(manager.restore('nope')).rejects.toThrow('Snapshot not found');
|
||||
});
|
||||
|
||||
test('delete removes a snapshot', () => {
|
||||
const snap = manager.create('delete me', 'auto');
|
||||
manager.delete(snap.id);
|
||||
expect(manager.getById(snap.id)).toBeNull();
|
||||
test('delete removes a snapshot', async () => {
|
||||
const snap = await manager.create('delete me', 'auto');
|
||||
await manager.delete(snap.id);
|
||||
await expect(manager.getById(snap.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('diff returns added/removed line counts', () => {
|
||||
const snap = manager.create('line one\nline two\nline three', 'auto');
|
||||
const result = manager.diff(snap.id, 'line one\nline modified\nline three\nline four');
|
||||
test('diff returns added/removed line counts', async () => {
|
||||
const snap = await manager.create('line one\nline two\nline three', 'auto');
|
||||
const result = await manager.diff(snap.id, 'line one\nline modified\nline three\nline four');
|
||||
expect(result.added).toBe(2);
|
||||
expect(result.removed).toBe(1);
|
||||
});
|
||||
|
||||
test('diff throws for missing snapshot', () => {
|
||||
expect(() => manager.diff('nope', 'new content')).toThrow('Snapshot not found');
|
||||
test('diff throws for missing snapshot', async () => {
|
||||
await expect(manager.diff('nope', 'new content')).rejects.toThrow('Snapshot not found');
|
||||
});
|
||||
|
||||
test('prune keeps only the N most recent snapshots', () => {
|
||||
for (let i = 0; i < 10; i++) manager.create('snap ' + i, 'auto');
|
||||
manager.prune(5);
|
||||
expect(manager.list().length).toBe(5);
|
||||
expect(manager.list()[0].content).toBe('snap 9');
|
||||
test('prune keeps only the N most recent snapshots', async () => {
|
||||
for (let i = 0; i < 10; i++) await manager.create('snap ' + i, 'auto');
|
||||
await manager.prune(5);
|
||||
const list = await manager.list();
|
||||
expect(list.length).toBe(5);
|
||||
expect(list[0].content).toBe('snap 9');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user