ouroboros/web/tests/render_batch.test.js
Ouroboros fe86bfadb8 fix(review): refresh task-bound verdicts after lifecycle completion
Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-08-28 15:55:31 +03:00

301 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import {
LOAD_OLDER_QUOTA_STEPS,
createHistoryResyncScheduler,
createRebuildBatch,
loadOlderControlState,
nextQuotaEscalation,
orderBatchNodes,
timelineNodeSortKey,
} from '../modules/chat_render_batch.js';
const chatSource = readFileSync(new URL('../modules/chat.js', import.meta.url), 'utf8');
function makeNode(id, ts = null) {
return { id, dataset: ts == null ? {} : { ts: String(ts) }, parentNode: null };
}
// ───────────────────────── stable batch ordering ─────────────────────────
test('batch order sorts by stamped ts', () => {
const ordered = orderBatchNodes([makeNode('c', 3), makeNode('a', 1), makeNode('b', 2)]);
assert.deepEqual(ordered.map((n) => n.id), ['a', 'b', 'c']);
});
test('equal timestamps preserve collection (arrival) order', () => {
// Mirrors the insertTimelineNode pin (chat_chronology.test.js:59-64):
// equal ts = arrival order, reproduced by the stable tie-break.
const ordered = orderBatchNodes([
makeNode('first', 5), makeNode('second', 5), makeNode('third', 5),
]);
assert.deepEqual(ordered.map((n) => n.id), ['first', 'second', 'third']);
});
test('timestamp-free nodes land at the end in arrival order', () => {
const ordered = orderBatchNodes([
makeNode('undatedA'), makeNode('late', 9), makeNode('undatedB'), makeNode('early', 1),
]);
assert.deepEqual(ordered.map((n) => n.id), ['early', 'late', 'undatedA', 'undatedB']);
assert.equal(timelineNodeSortKey(makeNode('u')), Infinity);
assert.equal(timelineNodeSortKey(makeNode('d', 42)), 42);
});
// ───────────────────────── batch mount semantics ─────────────────────────
function makeFakeDom() {
const doc = {
createDocumentFragment() {
return {
_isFragment: true,
children: [],
appendChild(node) { this.children.push(node); },
};
},
};
const makeMessages = () => ({
children: [],
insertBefore(node, before) {
const incoming = node._isFragment ? node.children : [node];
const index = this.children.indexOf(before);
this.children.splice(index === -1 ? this.children.length : index, 0, ...incoming);
},
appendChild(node) { this.insertBefore(node, null); },
});
return { doc, makeMessages };
}
test('mount inserts one sorted fragment before typing; typing stays last', () => {
const { doc, makeMessages } = makeFakeDom();
const messages = makeMessages();
const typing = makeNode('typing');
messages.children.push(typing);
typing.parentNode = messages;
const batch = createRebuildBatch(doc);
const b = makeNode('b', 2);
batch.collect(b);
batch.collect(makeNode('a', 1));
batch.collect(b); // duplicate collect is a no-op
batch.collect(makeNode('c', 3));
batch.mount(messages, typing);
assert.deepEqual(messages.children.map((n) => n.id), ['a', 'b', 'c', 'typing']);
});
test('mount without a mounted typing node appends at the end', () => {
const { doc, makeMessages } = makeFakeDom();
const messages = makeMessages();
const batch = createRebuildBatch(doc);
batch.collect(makeNode('only', 7));
batch.mount(messages, null);
assert.deepEqual(messages.children.map((n) => n.id), ['only']);
});
test('touch() registers each record once for the per-card finals', () => {
const batch = createRebuildBatch();
const record = { groupId: 't1' };
batch.touch(record);
batch.touch(record);
batch.touch(null);
assert.deepEqual([...batch.touched], [record]);
});
// ───────────────────────── Load-older escalation ─────────────────────────
test('quota escalation ladder: default window -> 400 -> caps -> exhausted', () => {
const first = nextQuotaEscalation(null);
assert.deepEqual(first, LOAD_OLDER_QUOTA_STEPS[0]);
assert.equal(first.n_human, 400);
const second = nextQuotaEscalation(first);
assert.deepEqual(second, { n_human: 1500, n_progress: 600 });
assert.equal(nextQuotaEscalation(second), null);
});
test('load-older control follows the SERVER window verdict', () => {
// Complete window (or a server predating the field): no control at all —
// a short history must never be told about phantom archives.
assert.equal(loadOlderControlState({ complete: true, truncated_by: [] }).mode, 'hidden');
assert.equal(loadOlderControlState(null).mode, 'hidden');
// Quota truncation with escalation headroom: a real button.
const btn = loadOlderControlState({ complete: false, truncated_by: ['quota'] }, null);
assert.equal(btn.mode, 'button');
// Quota truncation at the caps: honest boundary notice instead.
const capped = loadOlderControlState(
{ complete: false, truncated_by: ['quota'] },
{ n_human: 1500, n_progress: 600 },
);
assert.equal(capped.mode, 'notice');
// Archive-floor / lineage-cap truncation cannot be escalated away.
const floor = loadOlderControlState(
{ complete: false, truncated_by: ['archive_floor', 'lineage_cap'] }, null,
);
assert.equal(floor.mode, 'notice');
// The notice names BOTH boundaries: on-disk archives AND the lineage cap.
assert.match(floor.label, /archive/i);
assert.match(floor.label, /lineage/i);
});
// ─────────────── source pins: routine path & sticky boundary ───────────────
test('routine path still routes through chronological insertTimelineNode', () => {
// [Fable#9] The batch collector only diverts while a rebuildAll batch is
// active; the routine/live insertMessageNode path must keep flowing into
// insertTimelineNode (the py pin becomes provable structure, not luck).
const fn = chatSource.slice(
chatSource.indexOf('function insertMessageNode('),
chatSource.indexOf('function isBackgroundTaskId('),
);
const guard = fn.indexOf('if (_rebuildBatch) {');
const divert = fn.indexOf('_rebuildBatch.collect(node);');
const chronological = fn.indexOf('insertTimelineNode(messagesDiv, node, typing');
assert.ok(guard !== -1 && divert !== -1 && chronological !== -1);
// The batch divert RETURNS before the chronological insertion, which stays
// the one and only mounted-DOM path.
assert.ok(guard < divert && divert < chronological);
assert.match(fn.slice(divert, chronological), /return;/);
});
test('sticky single-flight never swallows the post-completion resync', () => {
// [GPT#12 + Fable#1] scheduleHistorySync (700ms debounce after task
// completion) must do a REAL fetch — a lost task_done is healed only by
// refetching — so it calls syncHistory directly, never the sticky
// awaitInitialHydration shortcut.
const fn = chatSource.slice(
chatSource.indexOf('function scheduleHistorySync('),
chatSource.indexOf('function applyLiveCardState('),
);
assert.match(fn, /syncHistory\(\{ includeUser: false \}\)/);
assert.doesNotMatch(fn, /awaitInitialHydration/);
// The reconnect branch of the open handler also always refetches.
assert.match(
chatSource,
/\? syncHistory\(\{ includeUser: !historyLoaded, fromReconnect: isReconnect \}\)/,
);
// Every failed sync resets the sticky promise.
assert.ok((chatSource.match(/initialHydrationPromise = null;/g) || []).length >= 3);
});
test('rebuild batch runs inside ONE outer withStableViewport with a synchronous mount', () => {
// [GPT#14] the clearing→mount critical section must stay synchronous and
// wrapped once; the fragment mounts before the typing bubble.
const start = chatSource.indexOf('_rebuildBatch = createRebuildBatch();');
assert.ok(start !== -1);
const section = chatSource.slice(start, start + 900);
assert.match(section, /withStableViewport\(\(\) => \{/);
assert.match(section, /applySyncedMessages\(\);/);
assert.match(section, /batch\.mount\(messagesDiv, messagesDiv\.querySelector\('\.typing-bubble'\)\);/);
assert.match(section, /finalizeRebuildBatch\(batch\);/);
assert.doesNotMatch(section.slice(0, section.indexOf('finalizeRebuildBatch')), /await /);
});
// ──────────── replay-time resync suppression (double-fetch fix) ────────────
function makeFakeTimers() {
const pending = [];
return {
pending,
setTimer(fn, ms) {
const id = { fn, ms };
pending.push(id);
return id;
},
clearTimer(id) {
const i = pending.indexOf(id);
if (i !== -1) pending.splice(i, 1);
},
fire() {
const jobs = pending.splice(0);
for (const job of jobs) job.fn();
},
};
}
test('a finished transition during a history replay does NOT schedule the resync', () => {
const timers = makeFakeTimers();
let runs = 0;
let replayActive = true;
const scheduler = createHistoryResyncScheduler({
isReplayActive: () => replayActive,
run: () => { runs += 1; },
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
assert.equal(scheduler.schedule(), false);
assert.equal(timers.pending.length, 0);
timers.fire();
assert.equal(runs, 0);
// The suppression is not sticky: the same scheduler works once the replay ends.
replayActive = false;
assert.equal(scheduler.schedule(), true);
});
test('a LIVE finished transition (outside a replay) schedules a real 700ms resync', () => {
const timers = makeFakeTimers();
let runs = 0;
const scheduler = createHistoryResyncScheduler({
isReplayActive: () => false,
run: () => { runs += 1; },
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
assert.equal(scheduler.schedule(), true);
assert.equal(timers.pending.length, 1);
assert.equal(timers.pending[0].ms, 700);
// Re-scheduling debounces: the previous timer is replaced, not stacked.
scheduler.schedule();
assert.equal(timers.pending.length, 1);
timers.fire();
assert.equal(runs, 1);
// cancel() (instance destroy) clears a pending resync.
scheduler.schedule();
scheduler.cancel();
assert.equal(timers.pending.length, 0);
});
test('chat.js wires the replay flag around the replay and keeps live callsites intact', () => {
// scheduleHistorySync delegates to the scheduler, whose replay gate reads
// _historyReplayActive; the flag wraps the whole replay dispatch (both the
// rebuildAll batch and the routine branch) and drops in a finally.
assert.match(chatSource, /isReplayActive: \(\) => _historyReplayActive,/);
const flagUp = chatSource.indexOf('_historyReplayActive = true;');
assert.ok(flagUp !== -1);
// (search from flagUp: the earlier `let … = false;` declaration also matches)
const replaySection = chatSource.slice(flagUp, chatSource.indexOf('_historyReplayActive = false;', flagUp));
assert.match(replaySection, /if \(rebuildAll\) \{/);
assert.match(replaySection, /applySyncedMessages\(\);/);
assert.doesNotMatch(replaySection, /await /);
// The LIVE path is untouched: both finished-transition callsites (the
// task_done frame in applyLiveCardStateMutation and finishLiveCardMutation)
// plus a terminal task-bound review lifecycle call scheduleHistorySync()
// unconditionally — the replay decision lives ONLY behind the scheduler's
// gate.
assert.equal((chatSource.match(/scheduleHistorySync\(\);/g) || []).length, 3);
assert.doesNotMatch(chatSource, /_historyReplayActive[^\n]*scheduleHistorySync/);
});
test('the Load-older control is mounted ONLY while it has something to show', () => {
// A permanently-present (even hidden) control is an extra top-level feed
// child that breaks child-order consumers (ui-smoke chronology pattern)
// and diverges from the pre-P4 feed layout on complete windows.
const fn = chatSource.slice(
chatSource.indexOf('function syncLoadOlderControl('),
chatSource.indexOf('async function loadOlderHistory('),
);
assert.match(fn, /if \(control\.mode === 'hidden'\) \{[\s\n]*loadOlderEl\.remove\(\);/);
assert.match(fn, /if \(!loadOlderEl\.isConnected\) messagesDiv\.prepend\(loadOlderEl\);/);
// The ONLY mount site is the on-demand one inside syncLoadOlderControl —
// no unconditional prepend at instance construction.
assert.equal((chatSource.match(/messagesDiv\.prepend\(loadOlderEl\);/g) || []).length, 1);
});
test('the Load-older control is excluded from viewport anchoring like typing', () => {
// [GPT#13] the anchor must land on the first visible TIMESTAMPED node.
const fn = chatSource.slice(
chatSource.indexOf('function captureVisibleTimelineAnchor('),
chatSource.indexOf('function restoreVisibleTimelineAnchor('),
);
assert.match(fn, /!node\.classList\.contains\('typing-bubble'\)/);
assert.match(fn, /!node\.classList\.contains\('chat-load-older'\)/);
});