docs(tests): explain input ownership and incremental query assertions
This commit is contained in:
@@ -51,6 +51,7 @@ test('scene query converts screen pixels once and returns nested display path at
|
||||
|
||||
// 预览A移到x=5000,显式previewIds补入候选并排除旧位置;registry/文档来源不匹配和退化zoom均拒绝。
|
||||
test('preview nodes outside committed grid remain hittable; stale provenance is rejected',()=>{
|
||||
// committed 网格仍在 x=0;只有显式提供 previewIds 才把网格外预览补入候选。
|
||||
const document=doc([node('A')]),p=draw(document,registry,'P',()=>({...document.elements.get('A').geometry,x:5000}));assert.equal(query(p,registry,{x:5050,y:50}).hits.length,0);assert.equal(query(p,registry,{x:5050,y:50},{},{previewIds:new Set(['A'])}).hits[0].target.id,'A');assert.equal(query(p,registry,{x:50,y:50},{},{previewIds:new Set(['A'])}).hits.length,0);
|
||||
assert.throws(()=>hitProjectedCanvas(p,createDocumentQueries(document,registry),createRegistry(builtinComponents()),'view',{space:'scene',point:{x:0,y:0},zoom:1}),/query_registry_changed/);assert.throws(()=>projectCanvas(document,'P',createRegistry(builtinComponents()),new Set(),()=>undefined,null,undefined,{queries:createDocumentQueries(document,registry)}),/query_registry_changed/);
|
||||
const other=doc([node('A')]);assert.throws(()=>hitProjectedCanvas(p,createDocumentQueries(other,registry),registry,'view',{space:'scene',point:{x:0,y:0},zoom:1}),/stale_query_snapshot/);assert.throws(()=>query(p,registry,{x:0,y:0},{zoom:Number.MIN_VALUE}),/invalid_hit_query/);
|
||||
@@ -70,6 +71,7 @@ test('bounded cubic refinement hits actual curve, rejects chord and preserves am
|
||||
test('connection query follows projected curve and collapsed endpoints, with explicit unavailable custom geometry',()=>{
|
||||
const path={type:'bezier-chain',knots:[],startControl:{x:0,y:200},endControl:{x:0,y:200}},document=doc([node('A'),node('B','P',{x:500}),edge('E','A','B','P',path)]),p=draw(document),from=compatConnector(p.nodes[0],undefined,'right'),to=compatConnector(p.nodes[1],undefined,'left'),point=cubicPoint(bezierChainSegments(from,to,path)[0],.5);
|
||||
for(const zoom of [.1,1,4]){const result=query(p,registry,point,{zoom,tolerancePx:6});assert.equal(result.complete,true);assert.equal(result.hits.find(hit=>hit.target.id==='E').certainty,'confirmed');}
|
||||
// 有限细分步骤耗尽时保留 possible 并标记不完整,不能把候选当作已确认结果。
|
||||
const budget=query(p,registry,point,{}, {},{maxCurveSteps:1});assert.equal(budget.complete,false);assert.equal(budget.hits.find(hit=>hit.target.id==='E').certainty,'possible');assert.ok(budget.diagnostics.some(item=>item.code==='geometry_query_budget'));
|
||||
const invalid=query(p,registry,point,{}, {edgeGeometry:()=>[[{x:NaN,y:0}]]});assert.equal(invalid.complete,false);assert.ok(invalid.diagnostics.some(item=>item.code==='invalid_geometry_response'));
|
||||
const collapsed=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),node('F','P',{x:500},'frame',{presentation:{collapsed:true}}),node('A','S'),edge('E','C','A')]),c=draw(collapsed),f=compatConnector(c.nodes.find(n=>n.id==='C'),undefined,'right'),t=compatConnector(c.nodes.find(n=>n.id==='F'),undefined,'left'),at=cubicPoint(bezierChainSegments(f,t)[0],.5),hit=query(c,registry,at).hits.find(hit=>hit.target.id==='E');assert.deepEqual(hit.displayedEndpoints,{source:'C',target:'F'});assert.equal(hit.ownerCanvasId,'P');assert.equal(collapsed.elements.get('E').target.elementId,'A');
|
||||
@@ -84,6 +86,7 @@ test('measured native handles use outer edges and loose target fallback without
|
||||
// 自定义圆节点与粗线使用模型协议及服务锚点;缺锚点/非法线宽/handler抛错分别报告诊断,opaque path保持原样。
|
||||
test('registered custom renderers provide model hit shapes, measured anchors, display-space curves and stroke width',()=>{
|
||||
let context;
|
||||
// 模型协议给出圆形命中与 20 单位粗线;renderer catalog 只负责匹配组件,锚点由下方服务替身提供。
|
||||
const definition={id:'custom',dataVersion:1,structure:'node',features:[],validate:()=>true,geometry:{...rectangleGeometry,hit:({geometry:g,point,tolerance})=>Math.hypot(point.x-g.x-g.width/2,point.y-g.y-g.height/2)<=g.width/2+tolerance}},connection={id:'custom-edge',dataVersion:1,structure:'connection',features:[],validate:()=>true,geometry:{kind:'connection',version:1,strokeWidth:()=>20,segments:input=>{context=input;return[[input.source.point,{x:200,y:200},{x:300,y:200},input.target.point]];}}},r=createRegistry([...builtinComponents(),definition,connection]),catalog=createRendererCatalog([...builtinRendererDefinitions,{componentId:'custom',dataVersion:1,structure:'node',rendererId:'circle'},{componentId:'custom-edge',dataVersion:1,structure:'connection',rendererId:'custom-edge'}]),document=doc([node('A','P',{},'custom'),node('B','P',{x:400},'custom'),{...edge('E','A','B'),componentId:'custom-edge',path:{type:'opaque-custom',value:7}}]),p=projectCanvas(document,'P',r,new Set(),()=>undefined,null,catalog),anchors={anchor:(node,_handle,end)=>({point:{x:node.position.x+(end==='source'?100:0),y:50},normal:{x:end==='source'?1:-1,y:0}})};
|
||||
assert.equal(query(p,r,{x:1,y:1},{tolerancePx:0},anchors).hits.some(hit=>hit.target.id==='A'),false);
|
||||
const absent=query(p,r,{x:250,y:160},{tolerancePx:0});assert.ok(absent.diagnostics.some(item=>item.code==='geometry_anchor_unavailable'));
|
||||
|
||||
@@ -11,6 +11,7 @@ test('gesture then tool then shortcut claim once; declining handlers fall throug
|
||||
|
||||
// 用伪closest模拟select和注册浮层,并传空/无效路径项,确认上下文识别不依赖全局Element。
|
||||
test('DOM input context recognizes registered overlays and native selects without browser globals',()=>{
|
||||
// closest 只识别测试关心的选择器,不实现真实祖先遍历;验证的是路由如何消费识别结果。
|
||||
const select={closest:selector=>selector.split(',').includes('select')?select:null};
|
||||
const overlay={closest:selector=>selector.includes('[data-canvas-overlay-view]')?overlay:null};
|
||||
assert.deepEqual(canvasKeyContext({target:select}),{textEditing:true,overlay:false});
|
||||
@@ -19,6 +20,7 @@ test('DOM input context recognizes registered overlays and native selects withou
|
||||
});
|
||||
// 事件target已被Shadow DOM重定向到host,composedPath仍含textarea;Escape必须留给内部文本而非取消画布手势。
|
||||
test('retargeted shadow events retain their inner text owner and never reach a gesture',()=>{
|
||||
// 输入元素放在路径首项而 target 指向 host,隔离事件重定向之后仍需识别内部编辑者的情况。
|
||||
const input={closest:selector=>selector.split(',').includes('textarea')?input:null},host={closest:()=>null};
|
||||
const context=canvasKeyContext({target:host,composedPath:()=>[input,host]});let canceled=false;
|
||||
assert.equal(routeCanvasKey({key:'Escape',...context},{gesture:()=>{canceled=true;return true;}}),'text');assert.equal(canceled,false);
|
||||
|
||||
@@ -7,6 +7,7 @@ const value=(incarnation=1,x=0)=>({placement:'title',target:{id:'a',incarnation}
|
||||
// 两个view可用同portal ID而独立;屏幕外普通项隐藏,focused/composing保留,但身份失效仍必须排除。
|
||||
test('screen placements are scoped by view, reject stale incarnations and preserve focused or composing offscreen editors',()=>{const a=createOverlayIndex('view-a'),b=createOverlayIndex('view-b');a.register('portal',value());b.register('portal',value(2));assert.equal(a.visible(viewport,target=>target.incarnation===1).length,1);assert.equal(b.visible(viewport,target=>target.incarnation===1).length,0);const lease=a.register('portal',value(1,1000));assert.equal(a.visible(viewport,()=>true).length,0);lease.update({...value(1,1000),composing:true});assert.equal(a.visible(viewport,()=>true).length,1);lease.update({...value(1,1000),focused:true});assert.equal(a.visible(viewport,()=>true).length,1);assert.equal(a.visible(viewport,()=>false).length,0);});
|
||||
// 同注册ID替换后旧lease.release/update无权改新项,相同测量也不换快照或通知,只有fresh释放才移除。
|
||||
// baseline 在两次注册之后记录,排除注册本身的通知;strictEqual 检查相等更新复用原快照对象。
|
||||
test('late old-instance updates or cleanup cannot remove a replacement overlay; unchanged measurements do not notify',()=>{const index=createOverlayIndex('v');let changes=0;const stop=index.subscribe(()=>changes++),old=index.register('same',value()),fresh=index.register('same',value(2));const snapshot=index.getSnapshot(),baseline=changes;old.release();old.update(value());fresh.update(value(2));assert.strictEqual(index.getSnapshot(),snapshot);assert.equal(changes,baseline);fresh.release();assert.equal(index.getSnapshot().length,0);stop();});
|
||||
// NaN矩形替换被拒绝,原快照与可见项保留;显式clear才清空。
|
||||
test('invalid overlay replacement leaves the prior placement and index intact',()=>{const index=createOverlayIndex('v');index.register('x',value());const before=index.getSnapshot();assert.throws(()=>index.register('x',{...value(),bounds:{x:NaN,y:0,width:1,height:1}}));assert.strictEqual(index.getSnapshot(),before);assert.equal(index.visible(viewport,()=>true).length,1);index.clear();assert.equal(index.visible(viewport,()=>true).length,0);});
|
||||
|
||||
@@ -6,5 +6,6 @@ import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from '../../src/lib/canvas-bindings/pointer-capture.ts';
|
||||
// move/up/cancel 检查 pointerId;lostpointercapture 还检查事件 target 与当前捕获状态,空 owner 不匹配。
|
||||
test('pointer move/up/cancel only match their owner and old lost-capture cannot cancel a replacement target',()=>{const capture={},other={},owner={pointerId:7,capture};for(const type of ['pointermove','pointerup','pointercancel']){assert.equal(ownsPointerCapture(owner,{pointerId:8,type}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type}),true);}assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:other}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:capture}),true);assert.equal(ownsPointerCapture(null,{pointerId:7}),false);const recaptured={hasPointerCapture:()=>true};assert.equal(ownsPointerCapture({pointerId:7,capture:recaptured},{pointerId:7,type:'lostpointercapture',target:recaptured}),false);});
|
||||
// 成功捕获只释放一次;节点脱离导致的 set/hasPointerCapture 异常作为捕获失败或安全清理处理,不升级为业务错误。
|
||||
// held 是平台捕获状态替身:第一次释放令它变 false,第二次释放必须跳过 releasePointerCapture。
|
||||
// set/hasPointerCapture 抛出的 detached 由助手容错;这里不验证 editor 手势取消或文档修改。
|
||||
test('failed capture and detached release stay transport failures, while a held capture releases once',()=>{let held=false,releases=0;const target={setPointerCapture(){held=true;},hasPointerCapture(){return held;},releasePointerCapture(){held=false;releases++;}};assert.equal(acquirePointerCapture(target,1),true);releasePointerCapture(target,1);releasePointerCapture(target,1);assert.equal(releases,1);assert.equal(acquirePointerCapture({...target,setPointerCapture(){throw Error('detached');}},2),false);assert.doesNotThrow(()=>releasePointerCapture({...target,hasPointerCapture(){throw Error('detached');}},2));});
|
||||
|
||||
@@ -13,6 +13,7 @@ const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{ca
|
||||
/** Map<id,Set>规范为排序数组,只消除集合遍历次序差异,不改变实际成员。 */
|
||||
const sets=map=>[...map].map(([id,ids])=>[id,[...ids].sort()]).sort(([a],[b])=>a.localeCompare(b));
|
||||
/** 全量比较关系表、入口映射、opaque集合、每canvas成员及空间entries/cells/oversized;不只比查询条数。 */
|
||||
// 此对照器只比较下面列出的关系/空间结构;文档来源、revision、覆盖完整度由各用例另行断言。
|
||||
function parity(actual,expected){for(const key of ['ownerMembers','canvasChildren','incidentEdges','elementReferrers','canvasReferrers'])assert.deepEqual(sets(actual.relations[key]),sets(expected.relations[key]),key);assert.deepEqual([...actual.relations.childCanvasByEntry].sort(),[...expected.relations.childCanvasByEntry].sort());assert.deepEqual([...actual.relations.opaqueReferences].sort(),[...expected.relations.opaqueReferences].sort());for(const id of actual.document.canvases.keys()){assert.deepEqual(actual.members(id),expected.members(id));assert.deepEqual(actual.childCanvases(id),expected.childCanvases(id));assert.deepEqual([...actual.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)),[...expected.spatial.get(id)?.entries??[]].sort(([a],[b])=>a.localeCompare(b)));assert.deepEqual(sets(actual.spatial.get(id)?.cells??new Map()),sets(expected.spatial.get(id)?.cells??new Map()));assert.deepEqual([...(actual.spatial.get(id)?.oversized??[])].sort(),[...(expected.spatial.get(id)?.oversized??[])].sort());}}
|
||||
|
||||
// 检查父子成员、入边和port引用,集合无写方法;未知组件标记引用不完整,缺canvas与更换registry明确拒绝。
|
||||
@@ -31,14 +32,17 @@ test('title and metadata commits reuse spatial tables; listeners observe matchin
|
||||
// 40轮几何/port编辑穿插跨owner移动,每步重建对照;删除恢复、ACK顺序重排也保持等价,同内容再协调复用查询对象。
|
||||
test('incremental queries match rebuilt indexes through transactions, cross-owner repair, deletion/restore and canonical reordering',()=>{
|
||||
let next=0;const editor=createEditor({document,registry,createId:()=>`operation-${++next}`,runtime:0}),check=()=>parity(editor.getQueries(),createDocumentQueries(editor.getSnapshot().document,registry,editor.getSnapshot().revision));
|
||||
// 每轮同时改几何和 port,随后每五轮跨 owner 移动;每个操作后以当前文档完整重建索引作对照。
|
||||
for(let i=0;i<40;i++){const target=editor.getSnapshot().document.elements.get(i%2?'A':'B');assert.equal(editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:i*27,rotation:(i*11)%360}});tx.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{ports:[{id:'p',nodeId:target.id}]}});}).status,'applied');check();if(i%5===0){assert.equal(editor.dispatch({type:'element.move',target,canvasId:target.ownerCanvasId==='S'?'P':'S'}).status,'applied');check();}}
|
||||
assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');check();assert.equal(editor.history.undo().status,'applied');check();assert.equal(editor.history.redo().status,'applied');check();assert.equal(editor.history.undo().status,'applied');check();
|
||||
// 只逆转 Map 插入顺序,不改实体内容;协调后检查重建一致,再用同内容新文档检查查询复用。
|
||||
const root=editor.getSnapshot(),canonical=createDocument(root.document.projectId,[...root.document.canvases.values()].reverse(),[...root.document.elements.values()].reverse(),root.document.tombstones);assert.equal(editor.reconcile(canonical,1,root.rootRevision).status,'applied');check();const after=editor.getQueries(),same=createDocument(canonical.projectId,[...canonical.canvases.values()],[...canonical.elements.values()],canonical.tombstones);assert.equal(editor.reconcile(same,2,editor.getSnapshot().rootRevision).status,'applied');assert.equal(editor.getQueries(),after);
|
||||
});
|
||||
|
||||
// 巨大或溢出几何走oversized保守路径;旋转命中检查内外点与角点,非法查询范围拒绝,不把不完整结果说成完整。
|
||||
test('owner-local grid conservatively selects rotated/huge nodes and precisely hits corners with finite query budgets',()=>{
|
||||
const source=createDocument('project',[{id:'P',parent:null}],[node('rotated','P','text',{x:0,y:0,width:100,height:10,rotation:45}),node('huge','P','text',{x:-1000000,y:-1000000,width:2000000,height:2000000}),node('overflow','P','text',{x:Number.MAX_VALUE,y:0,width:Number.MAX_VALUE,height:100})]),q=createDocumentQueries(source,registry);assert.ok(q.spatial.get('P').oversized.has('huge'));assert.ok(q.spatial.get('P').oversized.has('overflow'));assert.deepEqual(q.hitNodeBounds('P',{x:50,y:5}).ids,['rotated','huge']);assert.equal(q.hitNodeBounds('P',{x:50,y:5}).complete,false);assert.ok(!q.hitNodeBounds('P',{x:10,y:40}).ids.includes('rotated'));assert.ok(q.candidateNodes('P',{x:-1e12,y:-1e12,width:2e12,height:2e12}).includes('rotated'));assert.throws(()=>q.candidateNodes('P',{x:0,y:0,width:-1,height:10}),/invalid_query_bounds/);
|
||||
// 正向旋转局部右下角得到边界点,避免只在轴对齐包围盒中心验证命中。
|
||||
const g=source.elements.get('rotated').geometry,angle=Math.PI/4,x=g.x+g.width/2+(g.width/2*Math.cos(angle)-g.height/2*Math.sin(angle)),y=g.y+g.height/2+(g.width/2*Math.sin(angle)+g.height/2*Math.cos(angle));assert.ok(q.hitNodeBounds('P',{x,y}).ids.includes('rotated'));
|
||||
});
|
||||
|
||||
@@ -57,6 +61,7 @@ test('reverse element/canvas references remove obsolete subjects and mark malfor
|
||||
// runtime切完整覆盖时unknown由unloaded变absent,旧查询不变;foreign完整canvas声明被拒绝且根/查询不发布。
|
||||
test('coverage changes share the metadata publication boundary and invalid coverage cannot partially publish a root',()=>{
|
||||
const editor=createEditor({registry,document,runtime:{complete:false},queryCoverage:snapshot=>({projectComplete:snapshot.runtime.complete,topologyComplete:true,completeCanvasIds:snapshot.runtime.complete?['P','S']:[]})});
|
||||
// 先保存旧查询,更新 runtime 后检查新查询可判 absent,而已发布的旧查询仍保持 unloaded。
|
||||
const before=editor.getSnapshot(),queries=editor.getQueries();assert.equal(queries.lookupElement('unknown').status,'unloaded');assert.equal(queries.membersResult('P').complete,false);let observed;
|
||||
editor.subscribe((_commit,snapshot)=>observed={root:snapshot,queries:editor.getQueries()});assert.equal(editor.updateRuntime(()=>({complete:true})).status,'applied');assert.equal(observed.root,editor.getSnapshot());assert.equal(observed.queries.lookupElement('unknown').status,'absent');assert.equal(observed.root.revision,before.revision);assert.equal(queries.lookupElement('unknown').status,'unloaded');
|
||||
const invalid=createEditor({registry,document,runtime:{bad:false},queryCoverage:snapshot=>({projectComplete:false,topologyComplete:false,completeCanvasIds:snapshot.runtime.bad?['foreign']:[]})}),root=invalid.getSnapshot(),query=invalid.getQueries();assert.equal(invalid.updateRuntime(()=>({bad:true})).code,'invalid_query_coverage');assert.strictEqual(invalid.getSnapshot(),root);assert.strictEqual(invalid.getQueries(),query);
|
||||
|
||||
@@ -21,6 +21,7 @@ test('latest query methods do not keep discarded query/document generations aliv
|
||||
function editorChain(){const editor=createEditor({registry,document:createDocument('p',[{id:'p',parent:null}],[{id:'a',incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:'a',text:''}}]),queryCoverage:()=>({projectComplete:true,topologyComplete:true,completeCanvasIds:['p']})}),weakDocument=new WeakRef(editor.getSnapshot().document),weakQuery=new WeakRef(editor.getQueries());for(let i=1;i<=30;i++)editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{x:i}});return{editor,weakDocument,weakQuery};}
|
||||
const dropped=chain(false),kept=chain(true),owned=editorChain();for(let i=0;i<12;i++){await setImmediate();global.gc();}
|
||||
console.log(JSON.stringify({droppedDocument:!!dropped.weakDocument.deref(),droppedQuery:!!dropped.weakQuery.deref(),keptDocument:!!kept.weakDocument.deref(),keptQuery:!!kept.weakQuery.deref(),oldX:kept.held.document.elements.get('a').geometry.x,newX:dropped.last.document.elements.get('a').geometry.x,members:dropped.last.members('p'),editorDocument:!!owned.weakDocument.deref(),editorQuery:!!owned.weakQuery.deref(),editorX:owned.editor.getSnapshot().document.elements.get('a').geometry.x}));`;
|
||||
// JSON 中显式读取 held/last/editor,确保这些对象在观测点仍被强引用;只让未保留的旧代次回收。
|
||||
// 子进程限时15秒,先要求正常退出,再比JSON;旧丢弃代次应回收,显式held应存活且oldX仍0,最新x=30。
|
||||
const child=spawnSync(process.execPath,['--expose-gc','--input-type=module','-e',script],{encoding:'utf8',timeout:15000});assert.equal(child.status,0,child.stderr||String(child.error));assert.deepEqual(JSON.parse(child.stdout),{droppedDocument:false,droppedQuery:false,keptDocument:true,keptQuery:true,oldX:0,newX:30,members:['a'],editorDocument:false,editorQuery:false,editorX:30});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@ import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||
function source(initial){let state=initial;const listeners=new Set();return{getSnapshot:()=>state,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},set(next){state=next;for(const fn of [...listeners])fn();},get size(){return listeners.size;}};}
|
||||
// selector只取a,自定义比较器忽略新对象引用;改b不通知,改a才发布,重复读取不重复选择计算。
|
||||
test('selector store caches immutable reads and notifies only changed selections',()=>{const s=source({a:1,b:1});let selections=0,notifications=0;const store=createSelectorStore(s,value=>{selections++;return{a:value.a};},(a,b)=>a.a===b.a),first=store.getSnapshot();assert.strictEqual(store.getSnapshot(),first);assert.equal(selections,1);const stop=store.subscribe(()=>notifications++);s.set({a:1,b:2});assert.strictEqual(store.getSnapshot(),first);assert.equal(notifications,0);s.set({a:2,b:2});assert.equal(notifications,1);assert.equal(store.getSnapshot().a,2);stop();assert.equal(s.size,0);});
|
||||
// 负值使selector抛错,仍通知读者重读;恢复2再次通知,不能用旧成功值掩盖错误。
|
||||
// 负值使 selector 抛错,通知计数递增,随后 getSnapshot 必须重新抛出错误;恢复 2 后读到新值。
|
||||
// 旧成功缓存可以仍在内部保留,但不能作为失败输入的成功返回;这里没有实际挂载 React 错误边界。
|
||||
test('selector errors notify React to retry the snapshot and retain no invalid cached value',()=>{const s=source({value:1});const store=createSelectorStore(s,next=>{if(next.value<0)throw Error('invalid');return next.value;});let notifications=0;const stop=store.subscribe(()=>notifications++);s.set({value:-1});assert.equal(notifications,1);assert.throws(store.getSnapshot,/invalid/);s.set({value:2});assert.equal(store.getSnapshot(),2);assert.equal(notifications,2);stop();});
|
||||
// A在第一次通知中退订,B仍收到两次更新;最后退订上游无监听残留。
|
||||
test('distinct subscribers observe changes independently and unsubscribe during notification',()=>{const s=source(0),store=createSelectorStore(s,n=>n);let a=0,b=0,stopA;stopA=store.subscribe(()=>{a++;stopA();});const stopB=store.subscribe(()=>b++);s.set(1);s.set(2);assert.equal(a,1);assert.equal(b,2);stopB();assert.equal(s.size,0);});
|
||||
|
||||
Reference in New Issue
Block a user