fancive_obsidian-parallel-r.../tests/view-render.test.js
fancivez a5d9d74b54 feat: toggle right sidebar instead of detaching tab + component tests
UX change:
- The "open Parallel Reader" command now toggles the right sidebar's
  collapsed state instead of detaching the leaf. The tab and its content
  are preserved across hide/show, matching standard Obsidian behaviour
  for File Explorer and friends. Detaching the tab on every second
  press destroyed cached cards and forced re-generation.

Behaviour:
- No leaf yet → create + reveal
- Leaf exists, right sidebar collapsed → reveal (expands sidebar)
- Leaf exists, right sidebar expanded → collapse(); tab is preserved
- No right sidebar (mobile / unusual layout) → reveal as fallback

Tests:
- New tests/view-render.test.js (18 cases) covering the component layer
  that was previously a gap. The 1.0.12 → 1.0.13 sidebar regression
  could not have been caught by any existing pure-function test; this
  file fills that gap by mocking app.workspace + view spies and running
  real runForFile / refreshViewAfterCache* / toggleParallelView.
- shouldRender matrix (4 cases): silentView × sourceFile state — the
  exact regression introduced in PR2 where guards were over-applied.
- toggleParallelView (4 cases): no-leaf opens, expanded → collapse,
  collapsed → reveal, no-rightSplit fallback.
- refreshViewAfterCacheDelete + Clear (4 cases): matching file vs
  different file vs no-view, plus clear-all always renderEmpty.
- runForFile outcome enum mapping (6 cases): already-running early
  return, already-running from catch, cancelled, generic error,
  regenerate-confirm cancellation, skipEditConfirm bypass — these
  drive batch statistics and were untested before.
- view.deleteCard / updateCard cardPersistFailed path (3 cases):
  exposes ParallelReaderView via test-exports for instantiation;
  mocks cacheReplaceCards returning false to verify the failure
  notice path lands and the public method returns false.

src/test-exports.ts: export ParallelReaderView for component tests.
tests/catalog.json: register view-render.test.js under component.

Change-Id: I439b5fca07b0928e6fe44d9025c2421c71c6cb28
2026-05-02 15:38:41 +08:00

542 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Component-level tests for plugin/view interaction paths.
* These cover regressions that pure unit tests cannot catch — the bugs only
* manifest when state on `view` (sourceFile) and runForFile options
* (silentView) interact in specific combinations.
*/
const { assert, t } = require('./test-setup');
const { CACHE_SCHEMA_VERSION, generationFingerprint } = t;
const crypto = require('crypto');
const hashContent = (text) => crypto.createHash('sha1').update(text, 'utf8').digest('hex');
// Settings used by all tests in this file. Anthropic backend by default so we
// never accidentally hit any HTTP code path — the cache-hit path returns before
// summarizeDocument is called.
function makeSettings() {
return {
uiLanguage: 'en',
backend: 'api',
cliPath: '',
apiProvider: 'anthropic',
apiFormat: 'anthropic-messages',
apiBaseUrl: 'https://api.anthropic.com/v1',
apiKey: 'test',
apiKeyEnvVar: '',
apiAuthType: 'x-api-key',
apiHeaders: '',
apiMaxTokens: 4096,
maxDocChars: 100000,
maxCacheEntries: 100,
promptLanguage: 'en',
minCards: 5,
maxCards: 15,
customSystemPrompt: '',
model: 'claude-sonnet-4-6',
exportFolder: 'Reading/Articles',
cliTimeoutMs: 120000,
streaming: false,
streamingTimeoutMs: 120000,
};
}
function makeFakeView() {
const calls = { renderLoading: [], renderError: [], renderEmpty: 0, loadFor: [], renderStreamingPreview: [] };
const view = {
sourceFile: null,
sections: [],
renderLoading(file, msg) {
this.sourceFile = file;
calls.renderLoading.push([file?.path, msg]);
},
renderError(file, msg) {
this.sourceFile = file;
calls.renderError.push([file?.path, msg]);
},
renderEmpty() {
this.sourceFile = null;
calls.renderEmpty++;
},
loadFor(file, sections) {
this.sourceFile = file;
this.sections = sections;
calls.loadFor.push([file.path, sections.length]);
},
renderStreamingPreview(file, text) {
this.sourceFile = file;
calls.renderStreamingPreview.push([file.path, text.length]);
},
};
return { view, calls };
}
function makeFakeFile(path) {
return { path, basename: path.replace(/\.md$/, '') };
}
function makeBasePlugin(settings, { view, leaves, vaultRead } = {}) {
const plugin = new t.ParallelReaderPlugin();
plugin.settings = settings;
plugin.t = (key) => key;
plugin.jobs = new t.GenerationJobManager();
plugin.cacheManager = {
cache: {},
get: () => null,
put: async () => {},
touch: () => null,
delete: async () => {},
clear: async () => {},
};
plugin.app = {
workspace: {
getLeavesOfType: () => leaves || [],
getRightLeaf: () => null,
revealLeaf: async () => {},
getActiveViewOfType: () => null,
on: () => ({}),
},
vault: {
read: vaultRead || (async () => 'hello world content'),
adapter: {},
configDir: '.obsidian',
getMarkdownFiles: () => [],
getAbstractFileByPath: () => null,
on: () => ({}),
},
};
// Default getParallelView returns the supplied view if any.
plugin.getParallelView = () => view;
// Default getActiveView (used by activeFileStillMatches) returns null → match.
plugin.getActiveView = () => null;
return plugin;
}
/* ============================================================
* shouldRender matrix: silentView × view.sourceFile state
* Regression coverage for 1.0.13 (bug 1).
* Uses the cache-hit code path (no LLM call) to focus on the render guard.
* ============================================================ */
async function testShouldRender_NonSilent_FreshView() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
const fileA = makeFakeFile('A.md');
const content = 'hello world content';
const cardEntry = {
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: hashContent(content),
settingsHash: generationFingerprint(settings),
cards: [{ title: 'T', anchor: 'hello', gist: '', bullets: [] }],
generatedAt: '2026-01-01T00:00:00.000Z',
};
const plugin = makeBasePlugin(settings, { view, leaves: [{ view, setViewState: async () => {} }] });
plugin.cacheManager.get = () => cardEntry;
// sourceFile=null (panel never showed anything yet)
view.sourceFile = null;
const result = await plugin.runForFile(fileA, false);
assert.strictEqual(result, 'cached', 'cache-hit returns cached');
assert.strictEqual(calls.loadFor.length, 1, 'non-silent + fresh view → loadFor must fire (1.0.12 regression)');
assert.strictEqual(calls.loadFor[0][0], 'A.md', 'loadFor passed correct file');
}
async function testShouldRender_NonSilent_DifferentFile() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
const fileB = makeFakeFile('B.md');
const content = 'hello world content';
const cardEntry = {
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: hashContent(content),
settingsHash: generationFingerprint(settings),
cards: [{ title: 'T', anchor: 'hello', gist: '', bullets: [] }],
generatedAt: '2026-01-01T00:00:00.000Z',
};
const plugin = makeBasePlugin(settings, { view, leaves: [{ view, setViewState: async () => {} }] });
plugin.cacheManager.get = () => cardEntry;
// panel was showing fileA, user generates fileB via hotkey
view.sourceFile = makeFakeFile('A.md');
await plugin.runForFile(fileB, false);
assert.strictEqual(
calls.loadFor.length,
1,
'non-silent + different sourceFile → loadFor must fire (1.0.12 regression)',
);
assert.strictEqual(calls.loadFor[0][0], 'B.md', 'loadFor switched to file B');
}
async function testShouldRender_Silent_DifferentFile_Skipped() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
const fileB = makeFakeFile('B.md');
const content = 'hello world content';
const cardEntry = {
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: hashContent(content),
settingsHash: generationFingerprint(settings),
cards: [{ title: 'T', anchor: 'hello', gist: '', bullets: [] }],
generatedAt: '2026-01-01T00:00:00.000Z',
};
const plugin = makeBasePlugin(settings, { view, leaves: [{ view, setViewState: async () => {} }] });
plugin.cacheManager.get = () => cardEntry;
// batch path: panel shows A, batch processes B → must NOT disturb panel
view.sourceFile = makeFakeFile('A.md');
const result = await plugin.runForFile(fileB, false, { silentView: true });
assert.strictEqual(result, 'cached');
assert.strictEqual(calls.loadFor.length, 0, 'silentView + different file → must NOT render (preserve user focus)');
}
async function testShouldRender_Silent_SameFile_Updates() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
const fileA = makeFakeFile('A.md');
const content = 'hello world content';
const cardEntry = {
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: hashContent(content),
settingsHash: generationFingerprint(settings),
cards: [{ title: 'T', anchor: 'hello', gist: '', bullets: [] }],
generatedAt: '2026-01-01T00:00:00.000Z',
};
const plugin = makeBasePlugin(settings, { view, leaves: [{ view, setViewState: async () => {} }] });
plugin.cacheManager.get = () => cardEntry;
// batch path but the panel happens to be showing the file currently being processed
view.sourceFile = fileA;
await plugin.runForFile(fileA, false, { silentView: true });
assert.strictEqual(calls.loadFor.length, 1, 'silentView + same file → render IS allowed');
assert.strictEqual(calls.loadFor[0][0], 'A.md');
}
/* ============================================================
* toggleParallelView: open → close → open
* Regression coverage for 1.0.13 (bug 2).
* ============================================================ */
async function testToggleParallelView_NoLeaf_Opens() {
const settings = makeSettings();
let setViewStateCalled = 0;
let revealLeafCalled = 0;
const fakeLeaf = {
view: { containerEl: { children: [{}, {}] } },
setViewState: async () => {
setViewStateCalled++;
},
};
const plugin = makeBasePlugin(settings, { leaves: [] });
plugin.app.workspace.getRightLeaf = () => fakeLeaf;
plugin.app.workspace.revealLeaf = async () => {
revealLeafCalled++;
};
await plugin.toggleParallelView();
assert.strictEqual(setViewStateCalled, 1, 'first toggle creates leaf via setViewState');
assert.strictEqual(revealLeafCalled, 1, 'first toggle reveals leaf');
}
async function testToggleParallelView_LeafExists_SidebarOpen_Collapses() {
const settings = makeSettings();
let detachCalled = 0;
let collapseCalled = 0;
let revealCalled = 0;
const fakeLeaf = { detach: () => detachCalled++, view: {} };
const plugin = makeBasePlugin(settings, { leaves: [fakeLeaf] });
plugin.app.workspace.rightSplit = {
collapsed: false,
collapse: () => collapseCalled++,
};
plugin.app.workspace.revealLeaf = async () => {
revealCalled++;
};
await plugin.toggleParallelView();
assert.strictEqual(detachCalled, 0, 'leaf must NOT be detached (preserve tab content)');
assert.strictEqual(collapseCalled, 1, 'expanded sidebar → collapse');
assert.strictEqual(revealCalled, 0, 'no reveal when collapsing');
}
async function testToggleParallelView_LeafExists_SidebarCollapsed_Reveals() {
const settings = makeSettings();
let detachCalled = 0;
let collapseCalled = 0;
let revealCalled = 0;
const fakeLeaf = { detach: () => detachCalled++, view: {} };
const plugin = makeBasePlugin(settings, { leaves: [fakeLeaf] });
plugin.app.workspace.rightSplit = {
collapsed: true,
collapse: () => collapseCalled++,
};
plugin.app.workspace.revealLeaf = async () => {
revealCalled++;
};
await plugin.toggleParallelView();
assert.strictEqual(detachCalled, 0, 'leaf must NOT be detached');
assert.strictEqual(collapseCalled, 0, 'no collapse when sidebar already collapsed');
assert.strictEqual(revealCalled, 1, 'collapsed sidebar → reveal expands it and focuses our tab');
}
async function testToggleParallelView_NoRightSplit_FallsBackToReveal() {
const settings = makeSettings();
let revealCalled = 0;
const fakeLeaf = { detach: () => {}, view: {} };
const plugin = makeBasePlugin(settings, { leaves: [fakeLeaf] });
plugin.app.workspace.rightSplit = undefined;
plugin.app.workspace.revealLeaf = async () => {
revealCalled++;
};
await plugin.toggleParallelView();
assert.strictEqual(revealCalled, 1, 'no right sidebar (mobile?) → fallback to revealLeaf');
}
/* ============================================================
* refreshViewAfterCacheDelete / refreshViewAfterCacheClear
* Regression coverage for PR2 #14 (clear cache UI not refreshed).
* ============================================================ */
async function testRefreshViewAfterCacheDelete_MatchingFile() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
view.sourceFile = makeFakeFile('A.md');
const plugin = makeBasePlugin(settings, { view });
plugin.refreshViewAfterCacheDelete('A.md');
assert.strictEqual(calls.renderEmpty, 1, 'view showing the cleared file → renderEmpty');
}
async function testRefreshViewAfterCacheDelete_DifferentFile() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
view.sourceFile = makeFakeFile('A.md');
const plugin = makeBasePlugin(settings, { view });
plugin.refreshViewAfterCacheDelete('B.md'); // different file
assert.strictEqual(calls.renderEmpty, 0, 'view showing different file → must NOT renderEmpty');
}
async function testRefreshViewAfterCacheClear_AlwaysClears() {
const settings = makeSettings();
const { view, calls } = makeFakeView();
view.sourceFile = makeFakeFile('A.md');
const plugin = makeBasePlugin(settings, { view });
plugin.refreshViewAfterCacheClear();
assert.strictEqual(calls.renderEmpty, 1, 'clear-all → renderEmpty regardless of file');
}
/* ============================================================
* runForFile outcome enum mapping — drives batch statistics.
* Covers the catch-block branches that were untested before.
* ============================================================ */
async function testRunForFile_AlreadyRunning_EarlyReturn() {
const plugin = makeBasePlugin(makeSettings());
plugin.jobs = { isPending: () => true, isRunning: () => true };
const result = await plugin.runForFile(makeFakeFile('A.md'), false);
assert.strictEqual(result, 'already-running', 'isPending=true returns early with already-running');
}
async function testRunForFile_AlreadyRunning_FromCatch() {
const plugin = makeBasePlugin(makeSettings());
plugin.jobs = {
isPending: () => false,
isRunning: () => false,
start: async () => {
throw new t.GenerationJobAlreadyRunningError('A.md');
},
};
const result = await plugin.runForFile(makeFakeFile('A.md'), false);
assert.strictEqual(result, 'already-running', 'GenerationJobAlreadyRunningError from start → already-running');
}
async function testRunForFile_Cancelled_FromCatch() {
const plugin = makeBasePlugin(makeSettings());
plugin.jobs = {
isPending: () => false,
isRunning: () => false,
start: async () => {
throw new t.GenerationJobCancelledError('A.md');
},
};
const result = await plugin.runForFile(makeFakeFile('A.md'), false);
assert.strictEqual(result, 'cancelled', 'GenerationJobCancelledError → cancelled');
}
async function testRunForFile_GenericError_FromCatch() {
const plugin = makeBasePlugin(makeSettings());
plugin.jobs = {
isPending: () => false,
isRunning: () => false,
start: async () => {
throw new Error('backend down');
},
};
const origError = console.error;
console.error = () => {};
try {
const result = await plugin.runForFile(makeFakeFile('A.md'), false);
assert.strictEqual(result, 'error', 'plain Error → error');
} finally {
console.error = origError;
}
}
async function testRunForFile_RegenerateConfirm_Cancels() {
const plugin = makeBasePlugin(makeSettings());
// shouldConfirmRegenerate triggers when entry has user edits — simulate via cache entry shape.
plugin.cacheManager.get = () => ({
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: 'x',
settingsHash: 'y',
cards: [],
generatedAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z', // user edited after generation → confirm prompt
});
plugin.confirmRegenerateEditedCards = async () => false; // user clicks cancel
const result = await plugin.runForFile(makeFakeFile('A.md'), true);
assert.strictEqual(result, 'cancelled', 'user-cancelled regenerate confirm → cancelled');
}
async function testRunForFile_SkipEditConfirm_BypassesPrompt() {
const plugin = makeBasePlugin(makeSettings());
plugin.cacheManager.get = () => ({
schemaVersion: CACHE_SCHEMA_VERSION,
contentHash: 'x',
settingsHash: 'y',
cards: [],
generatedAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
});
let confirmCalled = 0;
plugin.confirmRegenerateEditedCards = async () => {
confirmCalled++;
return false;
};
// Make jobs.start succeed so we can verify confirm was skipped.
plugin.jobs = {
isPending: () => false,
isRunning: () => false,
start: async () => {
throw new Error('downstream'); // any quick exit
},
};
const origError = console.error;
console.error = () => {};
try {
await plugin.runForFile(makeFakeFile('A.md'), true, { skipEditConfirm: true });
assert.strictEqual(confirmCalled, 0, 'skipEditConfirm=true must bypass confirm prompt (batch flow)');
} finally {
console.error = origError;
}
}
/* ============================================================
* view.deleteCard / view.updateCard — cardPersistFailed path
* Covers the case where cacheReplaceCards returns false (cache missing)
* and verifies that user is notified of the failure.
* ============================================================ */
async function testViewDeleteCard_PersistFails_ReturnsFalse() {
const plugin = makeBasePlugin(makeSettings());
let cacheReplaceCalled = null;
plugin.cacheReplaceCards = async (path, sections) => {
cacheReplaceCalled = { path, count: sections.length };
return false; // simulate missing cache entry
};
const fakeLeaf = { view: {} };
const view = new t.ParallelReaderView(fakeLeaf, plugin);
view.render = () => {}; // skip DOM
view.sourceFile = makeFakeFile('A.md');
view.sections = [
{ title: 'a', anchor: '', gist: '', bullets: [], startLine: 0, level: 0 },
{ title: 'b', anchor: '', gist: '', bullets: [], startLine: 1, level: 0 },
];
view.activeIdx = 0;
const ok = await view.deleteCard(0);
assert.strictEqual(ok, false, 'deleteCard returns false when cache persist fails');
assert.deepStrictEqual(cacheReplaceCalled, { path: 'A.md', count: 1 }, 'cacheReplaceCards called with new sections');
assert.strictEqual(view.sections.length, 1, 'view.sections is updated locally even on persist failure');
}
async function testViewDeleteCard_PersistOk_ReturnsTrue() {
const plugin = makeBasePlugin(makeSettings());
plugin.cacheReplaceCards = async () => true;
const fakeLeaf = { view: {} };
const view = new t.ParallelReaderView(fakeLeaf, plugin);
view.render = () => {};
view.sourceFile = makeFakeFile('A.md');
view.sections = [
{ title: 'a', anchor: '', gist: '', bullets: [], startLine: 0, level: 0 },
{ title: 'b', anchor: '', gist: '', bullets: [], startLine: 1, level: 0 },
];
view.activeIdx = 0;
const ok = await view.deleteCard(0);
assert.strictEqual(ok, true, 'deleteCard returns true on successful persist');
}
async function testViewUpdateCard_PersistFails_ReturnsFalse() {
const plugin = makeBasePlugin(makeSettings());
plugin.cacheReplaceCards = async () => false;
const fakeLeaf = { view: {} };
const view = new t.ParallelReaderView(fakeLeaf, plugin);
view.render = () => {};
view.sourceFile = makeFakeFile('A.md');
view.sections = [{ title: 'a', anchor: '', gist: '', bullets: [], startLine: 0, level: 0 }];
view.activeIdx = 0;
const ok = await view.updateCard(0, { title: 'a-edited' });
assert.strictEqual(ok, false, 'updateCard returns false on persist failure');
}
async function testRefreshViewAfterCacheClear_NoView() {
const settings = makeSettings();
const plugin = makeBasePlugin(settings, { view: undefined });
plugin.getParallelView = () => undefined;
// Must not throw when no view exists.
plugin.refreshViewAfterCacheClear();
}
(async () => {
await testShouldRender_NonSilent_FreshView();
await testShouldRender_NonSilent_DifferentFile();
await testShouldRender_Silent_DifferentFile_Skipped();
await testShouldRender_Silent_SameFile_Updates();
await testToggleParallelView_NoLeaf_Opens();
await testToggleParallelView_LeafExists_SidebarOpen_Collapses();
await testToggleParallelView_LeafExists_SidebarCollapsed_Reveals();
await testToggleParallelView_NoRightSplit_FallsBackToReveal();
await testRefreshViewAfterCacheDelete_MatchingFile();
await testRefreshViewAfterCacheDelete_DifferentFile();
await testRefreshViewAfterCacheClear_AlwaysClears();
await testRefreshViewAfterCacheClear_NoView();
await testRunForFile_AlreadyRunning_EarlyReturn();
await testRunForFile_AlreadyRunning_FromCatch();
await testRunForFile_Cancelled_FromCatch();
await testRunForFile_GenericError_FromCatch();
await testRunForFile_RegenerateConfirm_Cancels();
await testRunForFile_SkipEditConfirm_BypassesPrompt();
await testViewDeleteCard_PersistFails_ReturnsFalse();
await testViewDeleteCard_PersistOk_ReturnsTrue();
await testViewUpdateCard_PersistFails_ReturnsFalse();
console.log('view-render tests passed');
})().catch((e) => {
console.error(e);
process.exit(1);
});