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.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
// -*- 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=10,400 条链)============
|
||||
{
|
||||
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(窗口 10,400 条)', 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('同页重复投递 → stale(pending 已消费)', 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);
|
||||
Reference in New Issue
Block a user