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
This commit is contained in:
fancivez 2026-05-02 15:38:41 +08:00 committed by wujunchen
parent 8e055cce50
commit a5d9d74b54
4 changed files with 573 additions and 12 deletions

39
main.ts
View file

@ -89,17 +89,7 @@ class ParallelReaderPlugin extends Plugin {
this.addCommand({
id: 'open-view',
name: this.t('cmdOpenView'),
callback: async () => {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL);
if (leaves.length > 0) {
for (const leaf of leaves) leaf.detach();
return;
}
const active = this.getActiveView();
const view = await this.ensureView();
if (!view) return;
if (active?.file) await this.syncViewToFile(active.file);
},
callback: () => this.toggleParallelView(),
});
this.addCommand({
id: 'export-current',
@ -252,6 +242,33 @@ class ParallelReaderPlugin extends Plugin {
/* ---------- View management ---------- */
/**
* Open the panel if it does not exist yet; otherwise toggle the right
* sidebar's collapsed state. The leaf itself is preserved across toggles
* we never detach it, so the panel content survives a hide/show cycle.
*/
async toggleParallelView(): Promise<void> {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL);
if (leaves.length === 0) {
const active = this.getActiveView();
const view = await this.ensureView();
if (!view) return;
if (active?.file) await this.syncViewToFile(active.file);
return;
}
const rightSplit = this.app.workspace.rightSplit;
if (rightSplit?.collapsed) {
// Sidebar collapsed → expand and focus our tab.
await this.app.workspace.revealLeaf(leaves[0]);
} else if (rightSplit) {
// Sidebar expanded → collapse it. Tab state is preserved.
rightSplit.collapse();
} else {
// No right sidebar (mobile? unusual layout?) — just reveal.
await this.app.workspace.revealLeaf(leaves[0]);
}
}
async ensureView(): Promise<ParallelReaderView | null> {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0];

View file

@ -60,3 +60,4 @@ export {
export { deltaExtractorForFormat, parseSseBuffer } from './streaming';
export { addIconButton, addTextButton, copyToClipboard } from './ui-helpers';
export { folderPathsForTarget } from './vault';
export { ParallelReaderView } from './view';

View file

@ -30,7 +30,8 @@
"cache.test.js",
"direct-cache.test.js",
"generation-job-manager.test.js",
"plugin-batch.test.js"
"plugin-batch.test.js",
"view-render.test.js"
]
},
"contract": {

542
tests/view-render.test.js Normal file
View file

@ -0,0 +1,542 @@
/**
* 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);
});