using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// FixedTree injection — the BEHAVIOURAL half of the runtime node-add model-change announcement. When a /// driver discovers FixedTree nodes AFTER the server is up and they are materialised into the served /// Equipment address space, already-connected OPC UA clients won't see them unless the server emits a /// Part 3 GeneralModelChangeEvent (verb NodeAdded) under the affected parent so subscribed /// clients refresh their browse. is that seam /// (Tasks 4/5 call it after materialising discovered nodes); this test asserts: /// /// the built event announces the affected parent with verb NodeAdded (the runtime-add /// counterpart of 's DataTypeChanged case); /// raising it is tolerant — callable before AND after nodes exist, and never throws even when the /// event path is disabled / has no monitored items. /// /// /// Coverage boundary (deliberate, mirrors ): the /// model-change event is asserted via its builder /// () in isolation, not its end-to-end /// Server.ReportEvent dispatch — observing that would require a subscribed event monitored-item. /// The single in-lock report call-site is covered by inspection (it mirrors the shape-changed reporter). /// /// public sealed class NodeManagerModelChangeOnAddTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-modelchange-add-{Guid.NewGuid():N}"); /// The built model-change event announces the affected parent with verb NodeAdded and the parent's /// TypeDefinition as AffectedType — what model-aware clients consume to re-browse the new children. [Trait("Category", "Unit")] [Fact] public async Task Built_nodes_added_event_announces_the_affected_parent_with_NodeAdded_verb() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7"); nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false); var parent = nm.TryGetFolder("eq-7")!; var e = nm.BuildNodesAddedModelChange("eq-7"); e.ShouldNotBeNull(); e.Changes.ShouldNotBeNull(); var changes = e.Changes.Value; changes.Length.ShouldBe(1); changes[0].Affected.ShouldBe(parent.NodeId); changes[0].AffectedType.ShouldBe(ObjectTypeIds.FolderType); changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeAdded); await host.DisposeAsync(); } /// For an affected id that is not (yet) materialised, the built event still announces NodeAdded but /// its AffectedType falls back to (a valid Part 3 "type not applicable") — the /// documented fallback of , locked in as an /// invariant. [Trait("Category", "Unit")] [Fact] public async Task Built_event_for_unknown_id_falls_back_to_null_AffectedType() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; // No EnsureFolder/EnsureVariable for this id — it is not in the node maps. var e = nm.BuildNodesAddedModelChange("eq-unknown"); e.ShouldNotBeNull(); e.Changes.ShouldNotBeNull(); var changes = e.Changes.Value; changes.Length.ShouldBe(1); changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeAdded); changes[0].AffectedType.ShouldBe(NodeId.Null); await host.DisposeAsync(); } /// Raising the announcement is tolerant: callable before any nodes exist (unknown affected id ⇒ /// AffectedType defaults to null, still a valid Part 3 change) AND after they are materialised, and never /// throws even when the event path reaches no monitored items (same tolerance as the write-revert path). [Trait("Category", "Unit")] [Fact] public async Task Raising_nodes_added_is_tolerant_before_and_after_nodes_exist() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; // Before any nodes exist under the parent — must not throw. Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9"); nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false); // After the nodes are materialised — still must not throw. Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); await host.DisposeAsync(); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.ModelChangeOnAddTest", ApplicationUri = $"urn:OtOpcUa.ModelChangeOnAddTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var server = new OtOpcUaSdkServer(); await host.StartAsync(server, Ct); return (host, server); } private static int AllocateFreePort() { using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }