diff --git a/packages/core/src/services/model-service.ts b/packages/core/src/services/model-service.ts index 80e808e..aec0e60 100644 --- a/packages/core/src/services/model-service.ts +++ b/packages/core/src/services/model-service.ts @@ -295,15 +295,22 @@ export class ModelService { nodesById.set(nodeId, mapped); childrenById.set(nodeId, childIds); - if (mapped.kind === 'instance' && findParentAsset(srcNode) == null) { - rootIds.push(nodeId); - } if (mapped.kind === 'property') propertyToSource.set(nodeId, srcNode.sourceNodeId); if (mapped.kind === 'action' && srcNode.parentSourceNodeId != null) { actionToMethod.set(nodeId, [srcNode.parentSourceNodeId, srcNode.sourceNodeId]); } } + // A root is an instance nothing in the model points to as a child, so that its + // parentId (parentIdOf) is null. Having no Object ancestor is not enough: an + // Object under a top-level Variable (e.g. its HA Configuration) has that Variable + // as parent. + const allChildIds = new Set(); + for (const ids of childrenById.values()) for (const id of ids) allChildIds.add(id); + for (const [nodeId, node] of nodesById) { + if (node.kind === 'instance' && !allChildIds.has(nodeId)) rootIds.push(nodeId); + } + // ── Pre-fetch and map engineering units automatically ── const engUnitReqs: { propertyId: string; engUnitSourceId: string }[] = []; for (const node of nodesById.values()) { diff --git a/packages/core/tests/model-service.test.ts b/packages/core/tests/model-service.test.ts index 84068ee..fe028ea 100644 --- a/packages/core/tests/model-service.test.ts +++ b/packages/core/tests/model-service.test.ts @@ -171,6 +171,44 @@ describe('ModelService – browse path construction', () => { expect(idA).not.toBe(idB); }); + it('an Object under a top-level Variable is not a root (conformance EXP-15)', async () => { + // Objects/Bool (historized Variable in a folder) -> HA Configuration (Object) + const nodes = [ + sourceNode({ + sourceNodeId: 'i=85', + nsuQualifiedName: 'nsu=http://opcfoundation.org/UA/:Objects', + typeDefinition: 'ns=0;i=61', + }), + sourceNode({ + sourceNodeId: 'ns=2;i=1', + nsuQualifiedName: 'nsu=http://test.org/:Line', + }), + sourceNode({ + sourceNodeId: 'ns=2;i=2', + nsuQualifiedName: 'nsu=http://test.org/:Bool', + parentSourceNodeId: 'i=85', + nodeClass: 'Variable', + }), + sourceNode({ + sourceNodeId: 'ns=2;i=3', + nsuQualifiedName: 'nsu=http://opcfoundation.org/UA/:HA Configuration', + parentSourceNodeId: 'ns=2;i=2', + }), + ]; + const svc = new ModelService(mockDataSource(nodes), nullLogger); + const model = await svc.getOrBuildModel(); + + const lineId = stableI3xId('nsu=http://test.org/:Line', 'instance'); + expect(model.rootIds).toEqual([lineId]); + for (const id of model.rootIds) expect(svc.parentIdOf(model, id)).toBeNull(); + + const haConfig = [...model.nodesById.values()].find( + (n) => n.name === 'HA Configuration', + ); + expect(haConfig).toBeDefined(); + expect(model.nodesById.get(svc.parentIdOf(model, haConfig!.id)!)?.name).toBe('Bool'); + }); + it('multi-namespace tree encodes both URIs in child browse path', async () => { const rootNsu = 'nsu=http://di.org/:DeviceSet'; const childNsu = 'nsu=http://coffee.com/:Machine';