/** * 资源显示订阅的独立生命周期回归。通过手动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 句柄;released 记录有效释放,不记录重复调用次数,不能单靠此数组证明 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); // 故意让新句柄先完成、旧句柄后到,隔离迟到成功的身份护栏,而非只检查 abort 信号已设置。 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(); });