chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shape tests for <see cref="S7Driver"/>'s <see cref="ITagDiscovery"/>,
|
||||
/// <see cref="ISubscribable"/>, and <see cref="IHostConnectivityProbe"/> 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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DiscoveryAndSubscribeTests
|
||||
{
|
||||
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public readonly List<string> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user