using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// FB-7 — the BEHAVIOURAL half of the surgical DataType/array-shape in-place write (the applier-level /// eligibility + dispatch decisions live in ). Boots a real /// through (the same harness /// uses) so /// runs against a live node manager (real Lock + SystemContext + Server), and asserts: /// /// a DataType / ValueRank / array-length change is applied IN PLACE on the existing node and /// RESETS its value to BadWaitingForInitialData (no stale wrong-typed value), while the node identity /// (and therefore client subscriptions) survive; /// a Writable / Historizing-only change (shape unchanged) does NOT reset the value — the /// original surgical behaviour is preserved byte-for-byte; /// the built GeneralModelChangeEvent carries Changes=[{Affected=node, Verb=DataTypeChanged}]. /// /// /// 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. /// /// public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-surgical-shape-{Guid.NewGuid():N}"); // ───────────────────────────── Shape swap (DataType / ValueRank / array length) ───────────────────────────── /// A DataType change swaps the node's DataType in place and resets the value (the old Float value /// must not survive on a now-Int32 node); the node id is unchanged, so client subscriptions are preserved. [Fact] public async Task DataType_change_swaps_in_place_and_resets_value() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/sp")!; node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/sp", writable: false, historianTagname: null, dataType: "Int32", isArray: false, arrayLength: null); applied.ShouldBeTrue(); node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place node.ValueRank.ShouldBe(ValueRanks.Scalar); node.Value.ShouldBeNull(); // stale Float value dropped node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); await host.DisposeAsync(); } /// A scalar → array flip swaps ValueRank to OneDimension + ArrayDimensions=[len] in place and /// resets the value. [Fact] public async Task Scalar_to_array_flip_swaps_value_rank_and_resets_value() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false); nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/buf")!; node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, dataType: "Int16", isArray: true, arrayLength: 8u); applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.OneDimension); node.ArrayDimensions.ShouldNotBeNull(); node.ArrayDimensions[0].ShouldBe(8u); node.Value.ShouldBeNull(); // stale scalar value dropped node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); await host.DisposeAsync(); } /// An array-to-array LENGTH change (rank stays OneDimension, only ArrayDimensions[0] differs) is /// still treated as a shape change — the dimension is updated and the (now wrong-length) value reset. [Fact] public async Task Array_length_change_swaps_dimension_and_resets_value() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, historianTagname: null, isArray: true, arrayLength: 4u); nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/buf")!; node.ArrayDimensions![0].ShouldBe(4u); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, dataType: "Int16", isArray: true, arrayLength: 8u); applied.ShouldBeTrue(); node.ArrayDimensions[0].ShouldBe(8u); node.Value.ShouldBeNull(); node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData); await host.DisposeAsync(); } // ───────────────────────────── Backward compatibility (shape unchanged) ───────────────────────────── /// A Writable-only change (DataType + array-ness identical to the live node) must NOT reset the /// value — the original surgical behaviour stands. AccessLevel flips to ReadWrite; the prior Good value /// survives untouched. [Fact] public async Task Writable_only_change_keeps_value_and_does_not_reset() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow); var node = nm.TryGetVariable("eq-1/sp")!; // Same DataType ("Float") + same scalar shape — only Writable flips false → true. var applied = nm.UpdateTagAttributes("eq-1/sp", writable: true, historianTagname: null, dataType: "Float", isArray: false, arrayLength: null); applied.ShouldBeTrue(); node.Value.ShouldBe(7.0f); // value preserved (NOT reset) node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // status preserved node.DataType.ShouldBe(DataTypeIds.Float); // ReadWrite ⇒ CurrentRead | CurrentWrite. node.AccessLevel.ShouldBe((byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite)); await host.DisposeAsync(); } /// An unknown node id (rebuilt/removed in the interim) returns false so the caller falls back to a /// full rebuild; it must not throw. [Fact] public async Task Missing_node_returns_false() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; bool result = true; Should.NotThrow(() => result = nm.UpdateTagAttributes("eq-1/gone", writable: false, historianTagname: null, dataType: "Int32", isArray: false, arrayLength: null)); result.ShouldBeFalse(); await host.DisposeAsync(); } // ───────────────────────────── GeneralModelChangeEvent builder ───────────────────────────── /// The built model-change event announces the affected node with verb DataTypeChanged and the /// node's TypeDefinition as AffectedType — what model-aware clients consume to re-read the definition. [Fact] public async Task Built_model_change_event_reflects_the_affected_node() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); var node = nm.TryGetVariable("eq-1/sp")!; var e = nm.BuildNodeShapeChangedEvent(node); e.ShouldNotBeNull(); e.Changes.ShouldNotBeNull(); var changes = e.Changes.Value; changes.Length.ShouldBe(1); changes[0].Affected.ShouldBe(node.NodeId); changes[0].AffectedType.ShouldBe(VariableTypeIds.BaseDataVariableType); changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.DataTypeChanged); await host.DisposeAsync(); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.SurgicalShapeTest", ApplicationUri = $"urn:OtOpcUa.SurgicalShapeTest:{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 */ } } } }