Reconcile eviction comment wording; add handleSourceEnded cascade test (Phase 21.1)

The inclusive <= bound is correct; comments now say 'at or behind'. New
test drives eviction through the real onended trigger with a live mid-array
source pinning the frontier.
This commit is contained in:
daniel-c-harvey
2026-06-23 22:49:12 -04:00
parent ed606d94c7
commit 07f29a8216
2 changed files with 74 additions and 2 deletions
@@ -253,6 +253,78 @@ test('eviction does not drop buffers under live sources or past the schedule cur
assertEqual(dropped, 0, 'no eviction while front sources are live');
});
// handleSourceEnded cascade: eviction fires from the real production trigger (onended), not
// via a direct evictPlayedBuffers() call. Confirms the anchor/index invariants hold end-to-end
// through the scheduler's own event handling while playback is still active with a live source.
//
// Setup: 0.3s buffers so the 500ms lookahead window fits exactly two sources after
// playFromPosition(0). Buffer 0 ends at ~0.31s, buffer 1 ends at ~0.61s — both are scheduled.
// Clock is then advanced to t=0.6 so buffer 0's end (0.31) < evictBefore (0.6) while the live
// source on buffer 1 pins firstLiveIndex=1, blocking further eviction. This is the mid-array
// pinning scenario that later waves (21.2/21.3) build on.
test('eviction via handleSourceEnded: position exact, live bufferIndex decremented, frontier respected', () => {
const cm = new FakeContextManager();
const s = makeScheduler(cm);
// Retain nothing behind the playhead — evict aggressively so the cascade fires.
s.setBackRetainSeconds(0);
// Eight 0.3s buffers. scheduleBuffersFrom with lookaheadTarget=0.5s at t=0:
// after buf 0: nextScheduleTime≈0.31, lookahead=0.31 < 0.5 → continues
// after buf 1: nextScheduleTime≈0.61, lookahead=0.61 > 0.5 → breaks
// → exactly two sources are live after playFromPosition.
for (let i = 0; i < 8; i++) s.addBuffer(buf(0.3));
cm.now = 0;
s.playFromPosition(0);
// Reach inside to see which sources were scheduled and what bufferIndex they hold.
const priv = s as unknown as {
scheduledSources: Array<{ source: FakeSource; bufferIndex: number; startTime: number; endTime: number }>;
nextBufferIndex: number;
};
// Confirm two sources are live — the setup guarantee.
if (priv.scheduledSources.length < 2) {
throw new Error(`Expected ≥2 scheduled sources after playFromPosition, got ${priv.scheduledSources.length}`);
}
// Identify the first and second scheduled sources by bufferIndex order.
const sorted = [...priv.scheduledSources].sort((a, b) => a.bufferIndex - b.bufferIndex);
const firstScheduled = sorted[0]; // bufferIndex 0
const secondScheduled = sorted[1]; // bufferIndex 1
const secondBufferIndexBefore = secondScheduled.bufferIndex; // must be 1
// Record the second FakeSource so we can assert it was not stopped by eviction.
const secondFakeSource = secondScheduled.source as unknown as FakeSource;
// Advance clock to 0.6s. Buffer 0 ends at ~0.31s → evictBefore=0.6, end=0.31 ≤ 0.6 →
// droppable. Buffer 1 ends at ~0.61s → its live source pins firstLiveIndex=1 → NOT dropped.
cm.now = 0.6;
// Confirm playback is still active before firing the cascade.
assertEqual(s.isActive(), true, 'isActive must be true before cascade');
// Fire the cascade via the production trigger: stop the first source, which calls onended,
// which calls handleSourceEnded, which calls evictPlayedBuffers internally.
(firstScheduled.source as unknown as FakeSource).stop();
// (a) Absolute position must remain exactly 0.6.
assertClose(s.getCurrentPosition(), 0.6, 'position after handleSourceEnded cascade');
// (b) The second live source's bufferIndex must have been decremented by 1 (the one evicted
// front buffer), shifting it from absolute index 1 to absolute index 0.
const expectedSecondIndex = secondBufferIndexBefore - 1;
assertEqual(secondScheduled.bufferIndex, expectedSecondIndex, 'live source bufferIndex decremented');
// (c) Eviction stopped at firstLiveIndex=1, not nextBufferIndex — the second buffer was
// NOT dropped. Verify the second source was not stopped (it remained live throughout).
assertEqual(secondFakeSource.stopped, false, 'live second source not stopped by eviction');
// And the scheduler still has buffers (the array was not wiped past the frontier).
if (s.getBufferCount() === 0) {
throw new Error('eviction wiped all buffers — should have stopped at firstLiveIndex');
}
});
// --- run -------------------------------------------------------------------------------------
if (failures.length > 0) {
console.error(failures.join('\n'));