Files
Haocode/tests/test_render_window.js
T
sorrow404null ef00f64435 feat(web): incremental render-window batch rendering engine
Add render_window.js: Python-driven incremental pagination with id+chainIndex state, anchor restore, batch guard and streaming auto-detect; wired through chat_bridge and the page shell.
2026-09-17 16:40:04 +08:00

348 lines
16 KiB
JavaScript
Raw 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.
// -*- coding: utf-8 -*-
// P1-01 渲染窗口状态机单测(DOM 无关,直接加载 ui/web/render_window.js,无 JSDOM/npm
// 运行: node tests/test_render_window.js
'use strict';
const path = require('path');
const RW = require(path.join(__dirname, '..', 'ui', 'web', 'render_window.js'));
let pass = 0, fail = 0;
function check(name, cond, extra) {
if (cond) { pass++; console.log('PASS ' + name); }
else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); }
}
// ---------- 夹具 ----------
function makeChain(n) {
var out = [];
for (var i = 0; i < n; i++) {
out.push({ id: 'm' + i, role: i % 2 === 0 ? 'user' : 'assistant',
content: 'msg ' + i, created_at: 1000 + i });
}
return out;
}
// 模拟 Python 页载荷:按 (方向 + 边界消息 ID) 返回 [{id, chainIndex}] + 链长
function pageFor(chain, st, req) {
var bi = chain.findIndex(function (d) { return d.id === req.boundaryId; });
if (bi < 0) return null;
var start, items;
if (req.direction === 'older') {
start = Math.max(0, bi - st.size);
items = chain.slice(start, bi);
} else {
start = bi + 1;
items = chain.slice(bi + 1, bi + 1 + st.size);
}
return {
sessionId: st.sessionId, generation: st.generation,
boundaryId: req.boundaryId, direction: req.direction,
chainLen: chain.length,
items: items.map(function (d, k) { return { id: d.id, chainIndex: start + k }; })
};
}
function initChain(st, chain) {
var n = Math.min(st.size, chain.length);
var items = chain.slice(chain.length - n);
return RW.initFullChain(st, {
sessionId: 'sess-1', generation: 1,
chainLen: chain.length,
items: items.map(function (d, k) { return { id: d.id, chainIndex: chain.length - n + k }; })
});
}
function idsOf(st) { return st.order.slice(); }
function chainIds(chain) { return chain.map(function (d) { return d.id; }); }
// ============ 1) 配置规范化 ============
{
var c;
c = RW.normalizeConfig({});
check('size 缺失 → 40 / mode 缺失 → auto', c.size === 40 && c.mode === 'auto');
c = RW.normalizeConfig(undefined);
check('配置 undefined → {auto,40}', c.size === 40 && c.mode === 'auto');
c = RW.normalizeConfig({ render_window_size: true });
check('size=true(布尔)→ 40', c.size === 40);
c = RW.normalizeConfig({ render_window_size: false });
check('size=false(布尔)→ 40', c.size === 40);
c = RW.normalizeConfig({ render_window_size: '40' });
check('size="40"(字符串)→ 40', c.size === 40);
c = RW.normalizeConfig({ render_window_size: 0 });
check('size=0 → 40', c.size === 40);
c = RW.normalizeConfig({ render_window_size: -5 });
check('size=-5 → 40', c.size === 40);
c = RW.normalizeConfig({ render_window_size: 9 });
check('size=9(下越界)→ 40', c.size === 40);
c = RW.normalizeConfig({ render_window_size: 201 });
check('size=201(上越界)→ 40', c.size === 40);
c = RW.normalizeConfig({ render_window_size: 40.5 });
check('size=40.5(小数)→ 40', c.size === 40);
c = RW.normalizeConfig({ render_window_size: 10 });
check('size=10 保留', c.size === 10);
c = RW.normalizeConfig({ render_window_size: 200 });
check('size=200 保留', c.size === 200);
c = RW.normalizeConfig({ render_window_mode: 'manual' });
check('mode=manual 保留', c.mode === 'manual');
c = RW.normalizeConfig({ render_window_mode: 'AUTO' });
check('mode=AUTO(大小写不符)→ auto', c.mode === 'auto');
c = RW.normalizeConfig({ render_window_mode: 1 });
check('mode=1 → auto', c.mode === 'auto');
c = RW.normalizeConfig({ render_window_mode: null, render_window_size: 10 });
check('mode=null + size=10 → {auto,10}', c.mode === 'auto' && c.size === 10);
}
// ============ 2) auto/manual × 10/40/200 参数化初始窗口 ============
[10, 40, 200].forEach(function (size) {
['auto', 'manual'].forEach(function (mode) {
var st = RW.create({ render_window_mode: mode, render_window_size: size });
var chain = makeChain(400);
initChain(st, chain);
var tail = chainIds(chain).slice(400 - size);
check('初始窗口 [' + mode + '/' + size + '] 长度=size', st.order.length === size);
check('初始窗口 [' + mode + '/' + size + '] = 最新 size 条',
JSON.stringify(idsOf(st)) === JSON.stringify(tail));
check('初始窗口 [' + mode + '/' + size + '] hasMoreOlder', st.hasMoreOlder === true);
check('初始窗口 [' + mode + '/' + size + '] hiddenOlder=' + (400 - size),
st.hiddenOlder === 400 - size);
check('初始窗口 [' + mode + '/' + size + '] hiddenNewer=0', st.hiddenNewer === 0);
RW.clear(st);
check('clear 后模式/大小保持 [' + mode + '/' + size + ']',
st.mode === mode && st.size === size);
check('clear 后窗口/缓存/计数清空', st.order.length === 0 &&
Object.keys(st.indexById).length === 0 &&
st.hiddenOlder === 0 && st.hiddenNewer === 0);
});
});
// ============ 3) 双向连续换页(size=10400 条链)============
{
var st = RW.create({ render_window_size: 10 });
var chain = makeChain(400);
initChain(st, chain);
// ---- 连续向上,直到头部边界 ----
var pages = 0;
while (RW.canRequest(st, 'older')) {
var req = RW.beginRequest(st, 'older');
check('向上换页边界=窗口最旧条 p' + pages,
req && req.boundaryId === st.order[0]);
var res = RW.applyPage(st, req, pageFor(chain, st, req));
check('向上 p' + pages + ' 非 stale', res.stale !== true);
check('向上 p' + pages + ' 窗口≤size', st.order.length <= 10, 'len=' + st.order.length);
check('向上 p' + pages + ' 加一端裁另一端', res.removedIds.length === 10,
'removed=' + res.removedIds.length);
check('向上 p' + pages + ' 加入数=10', res.addedIds.length === 10);
// 顺序性:窗口内 id 在链中连续且递增
var idx = chainIds(chain).indexOf(st.order[0]);
var okSeq = true;
for (var i = 1; i < st.order.length; i++) {
if (chainIds(chain).indexOf(st.order[i]) !== idx + i) { okSeq = false; break; }
}
check('向上 p' + pages + ' 窗口连续无重复', okSeq);
pages++;
if (pages > 50) { check('向上换页终止', false, '死循环'); break; }
}
check('向上换页到达头部边界', !st.hasMoreOlder && !RW.canRequest(st, 'older'));
check('头部窗口=链首 10 条', JSON.stringify(idsOf(st)) === JSON.stringify(
chainIds(chain).slice(0, 10)));
check('头部 hiddenOlder=0', st.hiddenOlder === 0);
check('头部 hiddenNewer=390', st.hiddenNewer === 390);
check('向上总换页数=39(窗口 10400 条)', pages === 39, 'pages=' + pages);
// ---- 连续向下,直到尾部边界 ----
var down = 0;
while (RW.canRequest(st, 'newer')) {
var req2 = RW.beginRequest(st, 'newer');
var res2 = RW.applyPage(st, req2, pageFor(chain, st, req2));
check('向下 p' + down + ' 非 stale', res2.stale !== true);
check('向下 p' + down + ' 窗口≤size', st.order.length <= 10);
down++;
if (down > 50) { check('向下换页终止', false, '死循环'); break; }
}
check('向下换页到达尾部边界', !st.hasMoreNewer && !RW.canRequest(st, 'newer'));
check('尾部窗口=链尾 10 条', JSON.stringify(idsOf(st)) === JSON.stringify(
chainIds(chain).slice(390)));
check('向下总换页数=39', down === 39, 'down=' + down);
check('往返后窗口无重复', new Set(idsOf(st)).size === idsOf(st).length);
}
// ============ 4) 短链边界(链 ≤ size============
{
var st = RW.create({ render_window_size: 40 });
var chain = makeChain(7);
initChain(st, chain);
check('短链: 全链入窗', st.order.length === 7);
check('短链: 无向上/向下', !st.hasMoreOlder && !st.hasMoreNewer);
check('短链: 不可请求', !RW.canRequest(st, 'older') && !RW.canRequest(st, 'newer'));
}
// ============ 5) 过期响应 ============
{
var st = RW.create({ render_window_size: 10 });
var chain = makeChain(60);
initChain(st, chain);
// 5a) 切会话(sessionId 变化)
var req = RW.beginRequest(st, 'older');
var payload = pageFor(chain, st, req);
st.sessionId = 'sess-2'; // 模拟切会话(未走 clear 的代次变化也需被拒)
var r = RW.applyPage(st, req, payload);
check('切会话后旧响应 → stale', r.stale === true);
check('stale 后 pending 清空', st.pending === null);
// 5b) clear 递增代次 → 旧载荷(旧代次)失效
initChain(st, chain);
req = RW.beginRequest(st, 'older');
payload = pageFor(chain, st, req);
RW.clear(st);
r = RW.applyPage(st, req, payload);
check('clear 后旧代次响应 → stale', r.stale === true);
// 5c) 边界不匹配(竞态:窗口已变化才到达的旧页)
initChain(st, chain);
req = RW.beginRequest(st, 'older');
var reqB = RW.beginRequest(st, 'older');
check('pending 时不可重复请求', reqB === null);
var bogus = { sessionId: st.sessionId, generation: st.generation,
boundaryId: 'm0', direction: 'older', chainLen: chain.length, items: [] };
r = RW.applyPage(st, { direction: 'older', boundaryId: 'm0' }, bogus);
check('边界不匹配 → stale', r.stale === true);
// 5d) 同一页重复投递(幂等)
req = RW.beginRequest(st, 'older');
var p1 = pageFor(chain, st, req);
r = RW.applyPage(st, req, p1);
check('正常页投递成功', r.stale !== true);
r = RW.applyPage(st, req, p1);
check('同页重复投递 → stalepending 已消费)', r.stale === true);
}
// ============ 6) 活动流:计入上限、永不裁剪 ============
{
var st = RW.create({ render_window_size: 40 });
var chain = makeChain(600);
initChain(st, chain);
var streamId = st.order[st.order.length - 1]; // 最新条即活动流
RW.noteStream(st, streamId);
// 默认 size=40 + 1 条流 → 最多另外 39 条
var req = RW.beginRequest(st, 'older');
var res = RW.applyPage(st, req, pageFor(chain, st, req));
check('流在窗时向上换页非 stale', res.stale !== true);
check('流在窗时窗口恒≤size', st.order.length <= 40, 'len=' + st.order.length);
check('流在窗时窗口含流', st.order.indexOf(streamId) >= 0);
var others = st.order.filter(function (id) { return id !== streamId; }).length;
check('流在窗时另外 ≤39 条', others <= 39, 'others=' + others);
check('流未被裁剪(removed 不含流)', res.removedIds.indexOf(streamId) < 0);
// 继续向上:流始终存活
for (var i = 0; i < 4 && RW.canRequest(st, 'older'); i++) {
var rq = RW.beginRequest(st, 'older');
var rs = RW.applyPage(st, rq, pageFor(chain, st, rq));
check('流持续存活 p' + i, st.order.indexOf(streamId) >= 0 &&
rs.removedIds.indexOf(streamId) < 0);
}
// 流结束后不再受保护
RW.streamFinished(st, streamId);
var rq2 = RW.beginRequest(st, 'older');
var rs2 = RW.applyPage(st, rq2, pageFor(chain, st, rq2));
check('流结束后可被正常裁剪', rs2.removedIds.indexOf(streamId) >= 0);
// 流在窗口最旧端 + 向下换页:trimHead 保护(手工构造中部窗口)
var st2 = RW.create({ render_window_size: 10 });
initChain(st2, chain);
st2.order = ['m140', 'm141', 'm142', 'm143', 'm144', 'm145', 'm146', 'm147', 'm148', 'm149'];
st2.indexById = {};
for (var k = 0; k < st2.order.length; k++) st2.indexById[st2.order[k]] = 140 + k;
st2.hasMoreNewer = true; // 模拟窗口不在链尾
st2.hiddenNewer = 100;
RW.noteStream(st2, st2.order[0]);
var rq3 = RW.beginRequest(st2, 'newer');
var rs3 = RW.applyPage(st2, rq3, pageFor(chain, st2, rq3));
check('流在最旧端时向下换页仍受保护', st2.order.indexOf(st2.order[0]) >= 0 &&
rs3.removedIds.indexOf('m140') < 0);
check('流在最旧端时窗口恒≤size', st2.order.length <= 10);
check('流在最旧端时裁掉 10 条并补新页 10 条', rs3.removedIds.length === 10 &&
rs3.addedIds.length === 10);
}
// ============ 7) clear 语义(保留配置模式/大小)============
{
var st = RW.create({ render_window_mode: 'manual', render_window_size: 10 });
var chain = makeChain(60);
initChain(st, chain);
var req = RW.beginRequest(st, 'older');
var g0 = st.generation;
RW.clear(st);
check('clear: 代次+1', st.generation === g0 + 1);
check('clear: pending 清空', st.pending === null);
check('clear: 模式/大小保留', st.mode === 'manual' && st.size === 10);
check('clear: 不可请求', !RW.canRequest(st, 'older') && !RW.canRequest(st, 'newer'));
}
// ============ 8) 锚点几何(纯数学)============
{
var entries = [
{ id: 'a', top: 0, height: 100 },
{ id: 'b', top: 100, height: 100 },
{ id: 'c', top: 200, height: 100 }
];
var an = RW.computeAnchor(entries, 50, 150);
check('锚点: 首个可见=a, offset=50', an && an.msgId === 'a' && an.offset === 50,
JSON.stringify(an));
an = RW.computeAnchor(entries, 101, 200);
check('锚点: 首个可见=b, offset=1', an && an.msgId === 'b' && an.offset === 1,
JSON.stringify(an));
an = RW.computeAnchor(entries, 100, 200);
check('锚点: 视口顶恰在 b 起点, offset=0', an && an.msgId === 'b' && an.offset === 0);
an = RW.computeAnchor(entries, 500, 600);
check('锚点: 无可见 → null', an === null);
check('滚动增量: 350-100=250', RW.scrollDeltaFromRects(100, 350) === 250);
check('滚动增量: 无位移=0', RW.scrollDeltaFromRects(100, 100) === 0);
}
// ============ 9) 一次换页的原子性(加一端 + 裁另一端 + 计数同步)============
{
var st = RW.create({ render_window_size: 10 });
var chain = makeChain(100);
initChain(st, chain);
var before = st.hiddenOlder; // 90
var req = RW.beginRequest(st, 'older');
var res = RW.applyPage(st, req, pageFor(chain, st, req));
check('加 10 条', res.addedIds.length === 10);
check('裁 10 条', res.removedIds.length === 10);
check('hiddenOlder 递减=加入数', st.hiddenOlder === before - 10,
'before=' + before + ' now=' + st.hiddenOlder);
check('返回计数与状态一致', res.hiddenOlder === st.hiddenOlder &&
res.hiddenNewer === st.hiddenNewer &&
res.hasMoreOlder === st.hasMoreOlder && res.hasMoreNewer === st.hasMoreNewer);
}
// ============ 10) 活动消息追加(noteLive):计入上限、裁旧端 ============
{
var st = RW.create({ render_window_size: 10 });
var chain = makeChain(60);
initChain(st, chain); // 窗口 m50..m59
var r1 = RW.noteLive(st, 'live-user', -1); // 用户消息(未持久化)
check('noteLive: 追加到较新一端', r1.added === true &&
st.order[st.order.length - 1] === 'live-user');
check('noteLive: 窗口≤size(裁掉最旧 1 条)', st.order.length <= 10 &&
st.order.indexOf('m50') < 0);
check('noteLive: 裁掉的 id 同步清除下标', st.indexById['m50'] === undefined);
RW.noteStream(st, 'live-ai');
var r2 = RW.noteLive(st, 'live-ai', -1); // 助手占位(活动流)
check('noteLive: 流占位入窗', r2.added === true && st.activeStreamId === 'live-ai');
check('noteLive: 流计入上限后仍≤size', st.order.length <= 10);
var r3 = RW.noteLive(st, 'live-ai', 61); // 持久化后补下标(幂等)
check('noteLive: 重复 id 幂等补下标', r3.added === false &&
st.indexById['live-ai'] === 61);
// hidden 计数:trimHead 两次后窗口最旧=m52;链尾未知条计入 hiddenNewer(截断≥0
check('noteLive 后 hiddenOlder=52', st.hiddenOlder === 52, 'got=' + st.hiddenOlder);
check('noteLive 后 hiddenNewer=0(链尾无已持久化更新消息)', st.hiddenNewer === 0 &&
st.hasMoreNewer === false, 'got=' + st.hiddenNewer);
}
console.log('\n===== ' + pass + ' passed, ' + fail + ' failed =====');
process.exit(fail ? 1 : 0);