using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Shape tests for 's , /// , and surfaces that /// don't need a live PLC. Wire-level polling round-trips and probe transitions land in a /// follow-up PR once we have a mock S7 server. /// [Trait("Category", "Unit")] public sealed class S7DiscoveryAndSubscribeTests { private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder { public readonly List Folders = new(); public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new(); public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add(browseName); return this; } public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { Variables.Add((browseName, attributeInfo)); return new StubHandle(); } public void AddProperty(string browseName, DriverDataType dataType, object? value) { } public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { } private sealed class StubHandle : IVariableHandle { public string FullReference => "stub"; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException("S7 driver never calls this — no alarm surfacing"); } } [Fact] public async Task DiscoverAsync_projects_every_tag_into_the_address_space() { var opts = new S7DriverOptions { Host = "192.0.2.1", Tags = [ new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true), new("FaultBit", "M0.0", S7DataType.Bool, Writable: false), new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true), ], }; using var drv = new S7Driver(opts, "s7-disco"); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); builder.Folders.ShouldContain("S7"); builder.Variables.Count.ShouldBe(3); builder.Variables[0].Name.ShouldBe("TempSetpoint"); builder.Variables[0].Attr.SecurityClass.ShouldBe(SecurityClassification.Operate, "writable tags get Operate security class"); builder.Variables[1].Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly, "read-only tags get ViewOnly"); builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32); } [Fact] public async Task DiscoverAsync_propagates_WriteIdempotent_from_tag_to_attribute_info() { var opts = new S7DriverOptions { Host = "192.0.2.1", Tags = [ new("SetPoint", "DB1.DBW0", S7DataType.Int16, WriteIdempotent: true), new("StartBit", "M0.0", S7DataType.Bool), ], }; using var drv = new S7Driver(opts, "s7-idem"); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); builder.Variables.Single(v => v.Name == "SetPoint").Attr.WriteIdempotent.ShouldBeTrue(); builder.Variables.Single(v => v.Name == "StartBit").Attr.WriteIdempotent.ShouldBeFalse("default is opt-in per decision #44"); } [Fact] public void GetHostStatuses_returns_one_row_with_host_port_identity_pre_init() { var opts = new S7DriverOptions { Host = "plc1.internal", Port = 102 }; using var drv = new S7Driver(opts, "s7-host"); var rows = drv.GetHostStatuses(); rows.Count.ShouldBe(1); rows[0].HostName.ShouldBe("plc1.internal:102"); rows[0].State.ShouldBe(HostState.Unknown, "pre-init / pre-probe state is Unknown"); } [Fact] public async Task SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them() { var opts = new S7DriverOptions { Host = "192.0.2.1" }; using var drv = new S7Driver(opts, "s7-sub"); // SubscribeAsync does not itself call ReadAsync (the poll task does), so this works // even though the driver isn't initialized. The poll task catches the resulting // InvalidOperationException and the loop quietly continues — same pattern as the // Modbus driver's poll loop tolerating transient transport failures. var h1 = await drv.SubscribeAsync(["T1"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); var h2 = await drv.SubscribeAsync(["T2"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); h1.DiagnosticId.ShouldStartWith("s7-sub-"); h2.DiagnosticId.ShouldStartWith("s7-sub-"); h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId); await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken); await drv.UnsubscribeAsync(h2, TestContext.Current.CancellationToken); // UnsubscribeAsync with an unknown handle must be a no-op, not throw. await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken); } [Fact] public async Task Subscribe_publishing_interval_is_floored_at_100ms() { var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } }; using var drv = new S7Driver(opts, "s7-floor"); // 50 ms requested — the floor protects the S7 CPU from sub-scan polling that would // just queue wire-side. Test that the subscription is accepted (the floor is applied // internally; the floor value isn't exposed, so we're really just asserting that the // driver doesn't reject small intervals). var h = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); h.ShouldNotBeNull(); await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken); } }