fancive_obsidian-parallel-r.../tests/generation-job-manager.test.js
fancivez 76d2e8050a fix: address 10 P2 behavioral and concurrency findings
Findings from the original review (P2):
- streaming: flush unterminated final SSE event at EOF (some providers
  close the stream without a trailing blank line, dropping the last delta)
- cache-manager: validate entry shape on load — drop entries where
  cards is not an array, anchor is not a string, or bullets is not an
  array. Tolerates missing optional fields (treated as cache miss).
- view: card edit/delete now check cacheReplaceCards return and surface
  failures via a localized Notice instead of pretending success
- main: clear-current / clear-all / file-menu-clear refresh open view
  via renderEmpty so stale UI does not display deleted data
- prompt + settings-tab: card count is normalized via a single helper
  used by buildPrompts and onChange. Prompt and fingerprint stay in sync.
  UI value is written back to the textbox after clamping.
- generation-job-manager: global concurrency limit (default 3) with a
  cancellable wait queue. Race-safe slot accounting via a reserved
  counter so resolved-but-not-yet-set waiters are visible to fast-path
  start() callers.
- runForFile: now returns RunForFileResult; accepts preloadedContent +
  silentView + skipEditConfirm options used by batch (and reflected on
  the PluginHost interface).
- batch: avoid double file read, do not steal UI focus, classify
  results correctly (generated / cached / already-running / empty / ...)

Follow-up from the 1.0.11 review:
- generationFingerprint: codex backend excludes settings.model from the
  hash. Codex ignores --model; previously editing model spuriously
  invalidated all codex cache.

Codex review pass:
- generation-job-manager: race fix — releaseSlot's resolve microtask
  and a synchronous start() fast-path could briefly overshoot
  maxConcurrent. Reserve the slot synchronously inside the wrapped
  resolve to close the window.
- cache-manager: anchor type validation in addition to bullets.

NOTE: Codex backend users will see a one-time "stale cache" banner on
existing notes due to the fingerprint change; regenerate to refresh.

Change-Id: I7721c7dfe51dea3f51b0215764f721523c2f6806
2026-05-02 13:52:49 +08:00

206 lines
7.1 KiB
JavaScript

const { assert, t } = require('./test-setup');
const { GenerationJobAlreadyRunningError, GenerationJobCancelledError, GenerationJobManager, classifyGenerationError } =
t;
async function testSingleFlightAndCleanup() {
const manager = new GenerationJobManager();
let release;
const blocker = new Promise((resolve) => {
release = resolve;
});
const first = manager.start('note.md', async (job) => {
job.setPhase('reading');
await blocker;
job.setPhase('done');
return 'ok';
});
assert.strictEqual(manager.isRunning('note.md'), true);
assert.strictEqual(manager.get('note.md').phase, 'reading');
await assert.rejects(() => manager.start('note.md', async () => 'duplicate'), GenerationJobAlreadyRunningError);
release();
assert.strictEqual(await first, 'ok');
assert.strictEqual(manager.isRunning('note.md'), false);
assert.strictEqual(manager.get('note.md'), null);
}
async function testGlobalConcurrencyLimit() {
const manager = new GenerationJobManager(2); // max 2 concurrent
const releases = [null, null, null, null];
const blockers = releases.map(
(_, i) =>
new Promise((r) => {
releases[i] = r;
}),
);
const startedAt = [];
const promises = ['a.md', 'b.md', 'c.md', 'd.md'].map((path, i) =>
manager.start(path, async () => {
startedAt.push(i);
await blockers[i];
return path;
}),
);
// Let microtasks flush
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.isRunning('a.md'), true, 'a is running');
assert.strictEqual(manager.isRunning('b.md'), true, 'b is running');
assert.strictEqual(manager.isRunning('c.md'), false, 'c is queued, not running yet');
assert.strictEqual(manager.isRunning('d.md'), false, 'd is queued');
assert.strictEqual(manager.isPending('c.md'), true, 'c is pending (queued)');
assert.strictEqual(manager.waitingCount(), 2, 'two waiters queued');
assert.deepStrictEqual(startedAt, [0, 1], 'only first 2 entered runner');
// Release first -> c gets slot
releases[0]();
await promises[0];
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.isRunning('c.md'), true, 'c promoted after a finishes');
assert.strictEqual(manager.waitingCount(), 1, 'one waiter left');
// Same-key dedup while queued: starting c again throws (without waiting)
await assert.rejects(
() => manager.start('d.md', async () => 'dup'),
GenerationJobAlreadyRunningError,
'same-key (queued) dedup throws',
);
releases[1]();
releases[2]();
releases[3]();
await Promise.all(promises);
assert.strictEqual(manager.waitingCount(), 0);
assert.strictEqual(manager.isRunning('d.md'), false, 'all jobs settled');
}
async function testNoOverbookingDuringRelease() {
// Regression for the microtask race: when a queued waiter is resolved by
// releaseSlot(), a synchronous start() call must NOT slip past with the now-stale
// jobs.size and bring concurrent runners above maxConcurrent.
const manager = new GenerationJobManager(2);
const releases = [null, null, null];
const runnerEntered = [];
const blockers = releases.map(
(_, i) =>
new Promise((r) => {
releases[i] = r;
}),
);
const a = manager.start('a.md', async () => {
runnerEntered.push('a');
await blockers[0];
return 'a';
});
const b = manager.start('b.md', async () => {
runnerEntered.push('b');
await blockers[1];
return 'b';
});
// c is queued (slot full); c will block too so we can observe state during transitions.
const c = manager.start('c.md', async () => {
runnerEntered.push('c');
await blockers[2];
return 'c';
});
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.waitingCount(), 1, 'c is queued');
assert.deepStrictEqual(runnerEntered, ['a', 'b'], 'only 2 runners entered');
// Release a → c's waiter resolves and continuation queued. c will run and block.
releases[0]();
await a;
await new Promise((r) => setTimeout(r, 5));
// After a settles + microtasks: c is now in jobs (b + c running), runnerEntered = a,b,c
assert.deepStrictEqual(runnerEntered, ['a', 'b', 'c'], 'c promoted after a finished');
// Now slots are full again (b + c). New start('d.md') MUST queue, not jump in.
const d = manager.start('d.md', async () => {
runnerEntered.push('d');
return 'd';
});
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.waitingCount(), 1, 'd queued because b+c are running');
assert.deepStrictEqual(runnerEntered, ['a', 'b', 'c'], 'd not entered while b+c running');
releases[1]();
releases[2]();
await Promise.all([b, c, d]);
assert.ok(runnerEntered.includes('d'), 'd entered eventually');
assert.strictEqual(manager.waitingCount(), 0);
}
async function testCancelAllWaiters() {
const manager = new GenerationJobManager(1);
let release;
const blocker = new Promise((r) => {
release = r;
});
const running = manager.start('first.md', async () => {
await blocker;
return 'first';
});
// queued: should be cancellable
const queued1 = manager.start('q1.md', async () => 'q1');
const queued2 = manager.start('q2.md', async () => 'q2');
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.waitingCount(), 2, 'two queued');
const drained = manager.cancelAllWaiters();
assert.strictEqual(drained, 2, 'cancelAllWaiters returns drained count');
await assert.rejects(queued1, GenerationJobCancelledError, 'q1 rejects with cancelled');
await assert.rejects(queued2, GenerationJobCancelledError, 'q2 rejects with cancelled');
release();
assert.strictEqual(await running, 'first', 'running job still completes');
assert.strictEqual(manager.waitingCount(), 0);
}
async function testCancelSignalsRunnerAndCleansUp() {
const manager = new GenerationJobManager();
let cancelHookCalled = false;
const running = manager.start('cancel.md', async (job) => {
job.onCancel(() => {
cancelHookCalled = true;
});
await Promise.resolve();
job.throwIfCancelled();
return 'should-not-complete';
});
assert.strictEqual(manager.cancel('cancel.md'), true);
await assert.rejects(running, GenerationJobCancelledError);
assert.strictEqual(cancelHookCalled, true);
assert.strictEqual(manager.isRunning('cancel.md'), false);
assert.strictEqual(manager.cancel('cancel.md'), false);
}
function testErrorClassification() {
assert.strictEqual(classifyGenerationError(new Error('API key 未设置')), 'auth');
assert.strictEqual(classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout');
assert.strictEqual(classifyGenerationError(new Error('OpenAI API 429: rate limit')), 'rate-limit');
assert.strictEqual(classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');
assert.strictEqual(classifyGenerationError(new Error('Model 未设置')), 'config');
assert.strictEqual(classifyGenerationError(new Error('something else')), 'unknown');
}
(async () => {
await testSingleFlightAndCleanup();
await testGlobalConcurrencyLimit();
await testNoOverbookingDuringRelease();
await testCancelAllWaiters();
await testCancelSignalsRunnerAndCleansUp();
testErrorClassification();
console.log('generation job manager tests passed');
})();