const test=require('node:test'),assert=require('node:assert/strict'),m=require('./web/device-model.js'); const sites=[{id:'floor',name:'一楼'},{id:'bed',name:'卧室',parentId:'floor'},{id:'office',name:'办公室'}]; const devices=[{id:'lamp',name:'卧室台灯',deviceType:'灯',siteId:'bed',managerName:'乙',ownerName:'甲',tags:['照明','常用']},{id:'desk',name:'办公灯',deviceType:'灯',siteId:'office',managerName:'甲',ownerName:'乙',tags:['照明']},{id:'phone',name:'手机',deviceType:'手机',siteId:'bed',managerName:'乙',ownerName:'乙',tags:[]}]; test('type then area and area then type reuse the same device identities',()=>{ const a=m.groups(devices,['type','space'],sites),b=m.groups(devices,['space','type'],sites); assert.deepEqual(a.find(g=>g.items.some(x=>x.id==='lamp')).labels,['灯','一楼','卧室']); assert.deepEqual(b.find(g=>g.items.some(x=>x.id==='lamp')).labels,['一楼','卧室','灯']); assert.equal(a.flatMap(g=>g.items).length,3);assert.equal(b.flatMap(g=>g.items).length,3); }); test('responsible person differs from owner and supports multitag grouping',()=>{ assert.equal(m.groups(devices,['manager'],sites).find(g=>g.labels[0]==='乙').items.length,2); assert.equal(m.groups(devices,['owner'],sites).find(g=>g.labels[0]==='乙').items.length,2); assert.equal(m.groups(devices,['tag'],sites).find(g=>g.labels[0]==='照明').items.length,2); assert.equal(m.groups(devices,['tag'],sites).find(g=>g.labels[0]==='未标记').items[0].id,'phone'); }); test('spaces have no fixed depth and traversal handles corrupt cycles safely',()=>{ const deep=Array.from({length:2500},(_,i)=>({id:String(i),name:'Area '+i,parentId:i?String(i-1):''})); assert.equal(m.path(deep,'2499').length,2500);assert.equal(m.descendants(deep,'0').size,2500); assert.equal(m.path([{id:'a',parentId:'b'},{id:'b',parentId:'a'}],'a').length,2); assert.equal(m.descendants([{id:'a',parentId:'b'},{id:'b',parentId:'a'}],'a').size,2); }); test('identical area names remain separate groups by stable identifiers',()=>{ const areas=[{id:'a',name:'卧室'},{id:'b',name:'卧室'}]; assert.equal(m.groups([{siteId:'a',id:1},{siteId:'b',id:2}],['space'],areas).length,2); }); test('camera recorder and general device records appear in one catalog without mutation',()=>{ const state={assets:[{id:'a',name:'Camera'}],recorders:[{id:'b',name:'Recorder'}],devices:[{id:'c',deviceType:'冰箱'}]},before=JSON.stringify(state); assert.deepEqual(m.catalog(state).map(d=>d.deviceType),['摄像机','录像机','冰箱']);assert.equal(JSON.stringify(state),before); assert.equal(m.groups(devices,[],sites).length,1); }); test('same-name people with different references form separate responsibility groups',()=>{ const rows=[{id:'a',managerName:'张伟',managerRef:'user:one'},{id:'b',managerName:'张伟',managerRef:'user:two'}]; assert.equal(m.groups(rows,['manager'],[]).length,2); }); test('network topology preserves explicit uplinks and remains usable with missing or cyclic parents',()=>{ const data=[{id:'r'},{id:'c',uplinkId:'r'},{id:'p',uplinkId:'hidden'}]; const tree=m.topology(data);assert.deepEqual(tree.children.get('r').map(x=>x.id),['c']);assert.equal(tree.parents.get('p'),''); assert.equal(m.topology([{id:'a',uplinkId:'b'},{id:'b',uplinkId:'a'}]).children.get('').length,2); }); test('network summary distinguishes recorder stream source from physical camera address',()=>{ const n=m.network({id:'a',managementHost:'192.168.8.160',networkAccess:'tailscale'},{cameras:[{assetId:'a',host:'192.168.3.135',sourceKind:'recorder',recorderId:'r',archiveSource:'recorder',archiveRecorderId:'r'}]}); assert.deepEqual(n.hosts,['192.168.8.160']);assert.deepEqual(n.via,['r']);assert.deepEqual(n.archiveVia,['r']);assert.equal(n.access,'tailscale'); }); test('configured camera address overrides an old inventory address and is searchable',()=>{ const rows=m.catalog({assets:[{id:'a',managementHost:'192.168.1.20'}],cameras:[{assetId:'a',host:'192.168.1.30',sourceKind:'direct'}]}); assert.deepEqual(rows[0].networkHosts,['192.168.1.30']);assert.equal(m.matches(rows[0],'192.168.1.30',[]),true); });