Files
Endoleap/tests/headless/resource-view.test.mjs

57 lines
5.6 KiB
JavaScript

/**
* 资源显示订阅的独立生命周期回归。通过手动Promise控制读取完成顺序,检查逐项显示、部分失败重试、替换取消及句柄释放。
* blob字符串仅为测试URL,release计数由桩实现;不运行真实资源解码或浏览器URL仓库。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createResourceView } from '../../src/lib/canvas-bindings/resource-view.ts';
/** 暴露resolve/reject控制每次资源读取,模拟晚到成功与读取失败。 */
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
/** 等一个事件循环轮次,让资源Promise与通知完成,避免用固定墙钟延迟猜时序。 */
const tick = () => new Promise(resolve => setImmediate(resolve));
/** 收集resolve请求、onChange、onError及release序号;返回view与手动完成入口。 */
function fixture() {
const requests = [], updates = [], errors = [], released = [];
const view = createResourceView({ resolve(resource, signal) { const gate = deferred(); requests.push({ resource, signal, gate }); return gate.promise; }, onChange(sources, failures) { updates.push({ sources, failures }); }, onError: error => errors.push(error) });
/** 解析指定请求为URL句柄,release幂等记录一次,便于核对替换/卸载是否漏清理或重复释放。 */
const finish = (index, url = `blob:${index}`) => { let disposed = false; requests[index].gate.resolve({ url, release() { if (!disposed) { disposed = true; released.push(index); } } }); };
return { view, requests, updates, errors, released, finish, latest: () => updates.at(-1) };
}
// r1先就绪就显示,不等待r2;新增r3不重读旧资源或释放已就绪句柄,最终dispose各释放一次。
test('ready images display individually; adding a node preserves existing handles and reads', async () => {
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); await tick();
assert.deepEqual([...f.latest().sources], [['a', { resourceId: 'r1', url: 'blob:0' }]]);
f.view.sync([['a', 'r1'], ['b', 'r2'], ['c', 'r3']]); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r3']); assert.deepEqual(f.released, []);
f.finish(2); await tick(); assert.equal(f.latest().sources.size, 2); f.finish(1); await tick(); assert.equal(f.latest().sources.size, 3); f.view.dispose(); assert.deepEqual(f.released.sort(), [0, 1, 2]);
});
// r2失败时r1仍在,retry只请求r2并保留r1句柄;成功后failures清零。
test('a failed reader does not hide other ready images and retry touches only failures', async () => {
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.requests[1].gate.reject(Error('read_failed')); await tick();
assert.equal(f.latest().sources.get('a').url, 'blob:0'); assert.equal(f.latest().failures, 1); assert.equal(f.errors.length, 1);
f.view.retry(); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r2']); assert.deepEqual(f.released, []);
f.finish(2); await tick(); assert.equal(f.latest().failures, 0); assert.equal(f.latest().sources.size, 2); f.view.dispose();
});
// 同节点由old切new,旧signal被取消;旧实现忽略abort后才返回,也只释放旧句柄,不能覆盖新URL。
test('replacement aborts the old reader and fences a late result that ignored cancellation', async () => {
const f = fixture(); f.view.sync([['a', 'old']]); await tick(); f.view.sync([['a', 'new']]); await tick(); assert.equal(f.requests[0].signal.aborted, true);
f.finish(1, 'blob:new'); await tick(); f.finish(0, 'blob:old'); await tick();
assert.deepEqual(f.latest().sources.get('a'), { resourceId: 'new', url: 'blob:new' }); assert.deepEqual(f.released, [0]); f.view.dispose(); assert.deepEqual(f.released, [0, 1]);
});
// 删a只释放a,b保持;两次dispose只各清一次,已dispose的retry明确拒绝。
test('removing one loaded node retains the other; repeated disposal does not release twice', async () => {
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.finish(1); await tick();
f.view.sync([['b', 'r2']]); assert.deepEqual(f.released, [0]); assert.equal(f.latest().sources.get('b').url, 'blob:1');
f.view.dispose(); f.view.dispose(); assert.deepEqual(f.released, [0, 1]); assert.equal(f.latest().sources.size, 0); assert.throws(() => f.view.retry(), { code: 'resource_view_disposed' });
});
// dispose后资源才到,仍释放句柄但不再onChange/onError,避免旧视图收到取消噪音。
test('disposal cleans late handles without publishing into a retired view or reporting cancellation as an error', async () => {
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.view.dispose(); const count = f.updates.length; f.finish(0); await tick();
assert.equal(f.updates.length, count); assert.deepEqual(f.released, [0]); assert.deepEqual(f.errors, []);
});
// 重复节点ID列表拒绝前不改现有订阅;随后同r1同步不新增读取或释放已用资源。
test('invalid target lists reject before changing active subscriptions', async () => {
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.finish(0); await tick();
assert.throws(() => f.view.sync([['a', 'r2'], ['a', 'r3']]), { code: 'invalid_resource_view' }); assert.deepEqual(f.released, []);
f.view.sync([['a', 'r1']]); await tick(); assert.equal(f.requests.length, 1); assert.equal(f.latest().sources.get('a').resourceId, 'r1'); f.view.dispose();
});