@@ -0,0 +1,139 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 / #302 — integration scaffold for SZL (System Status List) reads against
|
||||
/// a real S7-1500 CPU. snap7 (the simulator that backs <see cref="Snap7ServerFixture"/>)
|
||||
/// does not implement SZL — every <c>@System.*</c> read returns <c>BadNotSupported</c>
|
||||
/// against the simulator — so the asserts here verify the not-supported semantics
|
||||
/// when running against snap7, and the live-firmware tests are gated on a real-PLC
|
||||
/// env-var (<c>S7_LIVE_HOST</c>) the same way other PR-S7-* live tests are.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why scaffolding rather than full live verification?</b> The plan section
|
||||
/// calls for a "live-firmware test against dev-box S7-1500"; that's a hardware-
|
||||
/// gated test and it is parked behind an env-var so the CI pipeline + a fresh
|
||||
/// developer checkout both stay green. The not-supported assertion against snap7
|
||||
/// is the "always-runs" piece — proves the dispatch path lights up + surfaces
|
||||
/// the right StatusCode without a live CPU.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500SzlTests(Snap7ServerFixture sim)
|
||||
{
|
||||
/// <summary>OPC UA <c>BadNotSupported</c> status code — same constant the driver uses.</summary>
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
|
||||
/// <summary>
|
||||
/// Re-build the simulator profile with <c>ExposeSystemTags = true</c>. <see cref="S7DriverOptions"/>
|
||||
/// is a class (not a record), so we copy fields manually rather than using a <c>with</c> expression.
|
||||
/// </summary>
|
||||
private static S7DriverOptions BuildOptionsWithSystemTags(string host, int port, int diagBufferDepth = 10)
|
||||
{
|
||||
var baseOpts = S7_1500Profile.BuildOptions(host, port);
|
||||
return new S7DriverOptions
|
||||
{
|
||||
Host = baseOpts.Host,
|
||||
Port = baseOpts.Port,
|
||||
CpuType = baseOpts.CpuType,
|
||||
Rack = baseOpts.Rack,
|
||||
Slot = baseOpts.Slot,
|
||||
Timeout = baseOpts.Timeout,
|
||||
Probe = baseOpts.Probe,
|
||||
Tags = baseOpts.Tags,
|
||||
ExposeSystemTags = true,
|
||||
DiagBufferDepth = diagBufferDepth,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_CpuType_returns_BadNotSupported_against_snap7_simulator()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = BuildOptionsWithSystemTags(sim.Host, sim.Port);
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-szl-cputype");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var snaps = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||
snaps.Count.ShouldBe(1);
|
||||
// snap7 doesn't implement SZL; the production S7NetSzlReader returns null too
|
||||
// (S7netplus 0.20 has no public ReadSzlAsync surface). Both paths converge on
|
||||
// BadNotSupported.
|
||||
snaps[0].StatusCode.ShouldBe(StatusBadNotSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_diagnostics_folder_against_real_simulator_when_opted_in()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = BuildOptionsWithSystemTags(sim.Host, sim.Port, diagBufferDepth: 5);
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-szl-discover");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new TestAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
builder.Folders.ShouldContain(S7SystemTags.FolderName);
|
||||
builder.Folders.ShouldContain("DiagBuffer");
|
||||
// 6 scalars + 5 buffer entries = 11 system variables.
|
||||
builder.Variables
|
||||
.Count(v => v.FullName.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal))
|
||||
.ShouldBe(11);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live-firmware gate: when the env-var <c>S7_LIVE_HOST</c> points at a real
|
||||
/// S7-1500, this test runs end-to-end against the live CPU and expects a
|
||||
/// non-empty CpuType / Firmware / OrderNo. Hardware-gated; CI skips it.
|
||||
/// Currently parked at <see cref="Assert.Skip"/> because S7netplus 0.20 doesn't
|
||||
/// expose a public SZL surface — even against a real CPU the production
|
||||
/// <see cref="S7NetSzlReader"/> returns null. Flip this back on once the
|
||||
/// S7netplus PR for ReadSzlAsync lands or we ship a raw-PDU helper.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Requires real S7-1500 + S7netplus public ReadSzlAsync surface; see PR-S7-E1 docs.")]
|
||||
public Task System_CpuType_against_live_S7_1500_returns_non_empty_string()
|
||||
{
|
||||
// var liveHost = Environment.GetEnvironmentVariable("S7_LIVE_HOST");
|
||||
// if (string.IsNullOrWhiteSpace(liveHost))
|
||||
// Assert.Skip("S7_LIVE_HOST not set — skipping live-firmware SZL test");
|
||||
// var options = new S7DriverOptions { Host = liveHost, ExposeSystemTags = true, ... };
|
||||
// ...
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestAddressSpaceBuilder : Core.Abstractions.IAddressSpaceBuilder
|
||||
{
|
||||
public List<string> Folders { get; } = [];
|
||||
public List<Core.Abstractions.DriverAttributeInfo> Variables { get; } = [];
|
||||
|
||||
public Core.Abstractions.IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(browseName);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Core.Abstractions.IVariableHandle Variable(
|
||||
string browseName, string displayName, Core.Abstractions.DriverAttributeInfo info)
|
||||
{
|
||||
Variables.Add(info);
|
||||
return new StubHandle();
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, Core.Abstractions.DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class StubHandle : Core.Abstractions.IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public Core.Abstractions.IAlarmConditionSink MarkAsAlarmCondition(Core.Abstractions.AlarmConditionInfo info)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
260
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/Szl/S7SystemTagsTests.cs
Normal file
260
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/Szl/S7SystemTagsTests.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.Szl;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — driver-side wiring for SZL-backed <c>@System.*</c> virtual addresses.
|
||||
/// Tests run without a real PLC by injecting an <see cref="IS7SzlReader"/> fake and
|
||||
/// calling <see cref="S7Driver.ReadAsync"/> against <c>@System.*</c> references — the
|
||||
/// driver short-circuits those before <c>RequirePlc()</c> so the read path lights up
|
||||
/// without touching the wire.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7SystemTagsTests
|
||||
{
|
||||
private sealed class FakeSzlReader(Func<ushort, ushort, byte[]?> respond) : IS7SzlReader
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
public List<(ushort SzlId, ushort SzlIndex)> Calls { get; } = new();
|
||||
|
||||
public Task<byte[]?> ReadSzlAsync(ushort szlId, ushort szlIndex, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
CallCount++;
|
||||
Calls.Add((szlId, szlIndex));
|
||||
return Task.FromResult(respond(szlId, szlIndex));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<string> Folders { get; } = new();
|
||||
public List<(string Browse, DriverAttributeInfo Info)> Variables { get; } = 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) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_no_diagnostics_folder_when_ExposeSystemTags_is_false()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Tags = [new S7TagDefinition("Setpoint", "DB1.DBW0", S7DataType.Int16)],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-disco-no-system");
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
builder.Folders.ShouldNotContain(S7SystemTags.FolderName);
|
||||
builder.Variables.Select(v => v.Info.FullName)
|
||||
.ShouldNotContain(n => n.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_diagnostics_folder_with_six_scalars_and_ten_buffer_entries()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
ExposeSystemTags = true,
|
||||
// Default DiagBufferDepth = 10 — six scalars + ten entries = 16 system variables.
|
||||
Tags = [],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-disco-with-system");
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
builder.Folders.ShouldContain(S7SystemTags.FolderName);
|
||||
builder.Folders.ShouldContain("DiagBuffer");
|
||||
|
||||
var systemVars = builder.Variables
|
||||
.Where(v => v.Info.FullName.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
systemVars.Count.ShouldBe(16); // 6 scalars + 10 buffer entries
|
||||
|
||||
// CpuType / Firmware / OrderNo project as String; CycleMs.* as Float64.
|
||||
systemVars.ShouldContain(v => v.Info.FullName == "@System.CpuType" && v.Info.DriverDataType == DriverDataType.String);
|
||||
systemVars.ShouldContain(v => v.Info.FullName == "@System.CycleMs.Avg" && v.Info.DriverDataType == DriverDataType.Float64);
|
||||
|
||||
// Diagnostics tags are ViewOnly — never writable.
|
||||
systemVars.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_honours_custom_DiagBufferDepth()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
ExposeSystemTags = true,
|
||||
DiagBufferDepth = 3,
|
||||
Tags = [],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-disco-custom-depth");
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
var bufferEntries = builder.Variables
|
||||
.Where(v => v.Info.FullName.StartsWith(S7SystemTags.DiagBufferEntryPrefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
bufferEntries.Count.ShouldBe(3);
|
||||
bufferEntries[0].Info.FullName.ShouldBe("@System.DiagBuffer.Entry[0]");
|
||||
bufferEntries[2].Info.FullName.ShouldBe("@System.DiagBuffer.Entry[2]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_parsed_CpuType_via_injected_reader()
|
||||
{
|
||||
var info = new S7CpuInfo("CPU 1215C", "V4.5.0", "6ES7 215-1AG40-0XB0");
|
||||
var reader = new FakeSzlReader((id, idx) =>
|
||||
id == S7SzlIds.ModuleIdentification ? S7SzlParser.EncodeCpuInfo(info) : null);
|
||||
|
||||
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
|
||||
using var drv = new S7Driver(opts, "s7-cputype") { SzlReader = reader };
|
||||
|
||||
var snaps = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||
snaps.Count.ShouldBe(1);
|
||||
snaps[0].StatusCode.ShouldBe(0u);
|
||||
snaps[0].Value.ShouldBe("CPU 1215C");
|
||||
reader.CallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadNotSupported_when_reader_returns_null()
|
||||
{
|
||||
var reader = new FakeSzlReader((_, _) => null); // snap7 / S7netplus 0.20 path
|
||||
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
|
||||
using var drv = new S7Driver(opts, "s7-not-supported") { SzlReader = reader };
|
||||
|
||||
var snaps = await drv.ReadAsync(["@System.CpuType", "@System.Firmware"], TestContext.Current.CancellationToken);
|
||||
snaps.Count.ShouldBe(2);
|
||||
snaps[0].StatusCode.ShouldBe(0x803D0000u, "BadNotSupported");
|
||||
snaps[0].Value.ShouldBeNull();
|
||||
snaps[1].StatusCode.ShouldBe(0x803D0000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_caches_SZL_payload_within_TTL()
|
||||
{
|
||||
var info = new S7CpuInfo("CPU 1516", "V2.9.4", "6ES7 516-3AN01-0AB0");
|
||||
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
|
||||
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
ExposeSystemTags = true,
|
||||
// 1-hour TTL so the second read inside the test definitely hits the cache.
|
||||
SzlCacheTtl = TimeSpan.FromHours(1),
|
||||
Tags = [],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-cache-hit") { SzlReader = reader };
|
||||
|
||||
var first = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||
var second = await drv.ReadAsync(["@System.Firmware"], TestContext.Current.CancellationToken);
|
||||
|
||||
first[0].Value.ShouldBe("CPU 1516");
|
||||
second[0].Value.ShouldBe("V2.9.4");
|
||||
// Both projections come from the same SZL 0x0011 payload — exactly one wire call.
|
||||
reader.CallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_misses_cache_when_TTL_is_zero()
|
||||
{
|
||||
var info = new S7CpuInfo("CPU 1516", "V2.9.4", "6ES7 516-3AN01-0AB0");
|
||||
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
|
||||
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
ExposeSystemTags = true,
|
||||
SzlCacheTtl = TimeSpan.Zero,
|
||||
Tags = [],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-cache-miss") { SzlReader = reader };
|
||||
|
||||
await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||
await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||
|
||||
// TTL=Zero means every read goes to the wire.
|
||||
reader.CallCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadNodeIdUnknown_for_unrecognised_system_address()
|
||||
{
|
||||
var reader = new FakeSzlReader((_, _) => null);
|
||||
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
|
||||
using var drv = new S7Driver(opts, "s7-bad-system") { SzlReader = reader };
|
||||
|
||||
var snaps = await drv.ReadAsync(["@System.NotARealField"], TestContext.Current.CancellationToken);
|
||||
snaps.Count.ShouldBe(1);
|
||||
snaps[0].StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown");
|
||||
// No SZL wire call — the address didn't resolve to a known descriptor.
|
||||
reader.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_diag_buffer_entry_string_when_reader_supplies_payload()
|
||||
{
|
||||
var entries = new[]
|
||||
{
|
||||
new S7DiagBufferEntry(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), 0xCAFE, 3, "Event 0xCAFE (priority 3)"),
|
||||
new S7DiagBufferEntry(new DateTimeOffset(2024, 1, 1, 0, 0, 5, TimeSpan.Zero), 0xBEEF, 4, "Event 0xBEEF (priority 4)"),
|
||||
};
|
||||
var reader = new FakeSzlReader((id, _) =>
|
||||
id == S7SzlIds.DiagnosticBuffer ? S7SzlParser.EncodeDiagBuffer(entries) : null);
|
||||
|
||||
var opts = new S7DriverOptions { ExposeSystemTags = true, DiagBufferDepth = 5, Tags = [] };
|
||||
using var drv = new S7Driver(opts, "s7-diag") { SzlReader = reader };
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
["@System.DiagBuffer.Entry[0]", "@System.DiagBuffer.Entry[1]"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snaps.Count.ShouldBe(2);
|
||||
snaps[0].StatusCode.ShouldBe(0u);
|
||||
((string)snaps[0].Value!).ShouldContain("0xCAFE");
|
||||
((string)snaps[1].Value!).ShouldContain("0xBEEF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_mixes_system_tags_with_unknown_regular_tags_returning_status_per_request()
|
||||
{
|
||||
var info = new S7CpuInfo("CPU 1500", "V2.9", "6ES7");
|
||||
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
|
||||
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
|
||||
using var drv = new S7Driver(opts, "s7-mixed") { SzlReader = reader };
|
||||
|
||||
// SystemTag should resolve via the short-circuit; "NoSuchTag" should never reach
|
||||
// the Plc gate because the only other request was a system tag — but if it does,
|
||||
// the test must not hang (RequirePlc would throw). Both are short-circuited so
|
||||
// the call returns BadNodeIdUnknown for "NoSuchTag" via the system-prefix check
|
||||
// failing. To exercise that, request both as @System.* references — one valid,
|
||||
// one bogus.
|
||||
var snaps = await drv.ReadAsync(
|
||||
["@System.CpuType", "@System.Bogus"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snaps[0].Value.ShouldBe("CPU 1500");
|
||||
snaps[0].StatusCode.ShouldBe(0u);
|
||||
snaps[1].StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown for unrecognised @System.* address");
|
||||
}
|
||||
}
|
||||
213
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/Szl/S7SzlParserTests.cs
Normal file
213
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/Szl/S7SzlParserTests.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.Szl;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — golden-byte tests for <see cref="S7SzlParser"/>. Each test hand-crafts a
|
||||
/// structurally-valid SZL response payload (matching the layout in the Siemens function
|
||||
/// manual, §"SSL-IDs") and asserts the parser projects every field the driver surfaces
|
||||
/// through <c>@System.*</c>. Round-trip tests prove encode-then-decode is the identity
|
||||
/// so test fixtures stay self-consistent without leaning on real PLC traffic.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7SzlParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseCpuInfo_decodes_module_identification_records()
|
||||
{
|
||||
// Hand-craft a SZL 0x0011 response with three records:
|
||||
// index 0x0001 — MLFB "6ES7 516-3AN01-0AB0 " (20 bytes ASCII)
|
||||
// index 0x0006 — firmware Vmajor.minor.patch encoded in Ausbg1/Ausbg2
|
||||
// index 0x0007 — friendly CPU name "CPU 1516-3 PN/DP"
|
||||
var info = new S7CpuInfo(
|
||||
CpuType: "CPU 1516-3 PN/DP",
|
||||
Firmware: "V2.9.4",
|
||||
OrderNo: "6ES7 516-3AN01-0AB0");
|
||||
var payload = S7SzlParser.EncodeCpuInfo(info);
|
||||
|
||||
var parsed = S7SzlParser.ParseCpuInfo(payload);
|
||||
parsed.OrderNo.ShouldBe("6ES7 516-3AN01-0AB0");
|
||||
parsed.CpuType.ShouldBe("CPU 1516-3 PN/DP");
|
||||
parsed.Firmware.ShouldBe("V2.9.4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCpuInfo_handles_missing_records_with_unknown_fallback()
|
||||
{
|
||||
// Header claims zero records — every field falls back to "(unknown)" rather than throwing.
|
||||
var buf = new byte[S7SzlParser.HeaderLength];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.ModuleIdentification);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 28); // record length valid, count = 0
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 0);
|
||||
|
||||
var parsed = S7SzlParser.ParseCpuInfo(buf);
|
||||
parsed.CpuType.ShouldBe("(unknown)");
|
||||
parsed.Firmware.ShouldBe("(unknown)");
|
||||
parsed.OrderNo.ShouldBe("(unknown)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCycleStats_decodes_min_max_avg_milliseconds()
|
||||
{
|
||||
// Hand-craft SZL 0x0132 with avg=10ms, min=5ms, max=42ms.
|
||||
var stats = new S7CycleStats(MinMs: 5, MaxMs: 42, AvgMs: 10);
|
||||
var payload = S7SzlParser.EncodeCycleStats(stats);
|
||||
|
||||
var parsed = S7SzlParser.ParseCycleStats(payload);
|
||||
parsed.MinMs.ShouldBe(5);
|
||||
parsed.MaxMs.ShouldBe(42);
|
||||
parsed.AvgMs.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDiagBuffer_decodes_five_entries_with_timestamps_and_event_ids()
|
||||
{
|
||||
var entries = new List<S7DiagBufferEntry>
|
||||
{
|
||||
new(new DateTimeOffset(2024, 1, 15, 8, 30, 0, TimeSpan.Zero), 0x113A, 1, "Event 0x113A (priority 1)"),
|
||||
new(new DateTimeOffset(2024, 1, 15, 8, 31, 5, TimeSpan.Zero), 0x4302, 5, "Event 0x4302 (priority 5)"),
|
||||
new(new DateTimeOffset(2024, 1, 15, 8, 32, 17, TimeSpan.Zero), 0x4308, 5, "Event 0x4308 (priority 5)"),
|
||||
new(new DateTimeOffset(2024, 1, 15, 8, 33, 42, TimeSpan.Zero), 0x39C0, 26, "Event 0x39C0 (priority 26)"),
|
||||
new(new DateTimeOffset(2024, 1, 15, 8, 34, 59, TimeSpan.Zero), 0x4505, 1, "Event 0x4505 (priority 1)"),
|
||||
};
|
||||
var payload = S7SzlParser.EncodeDiagBuffer(entries);
|
||||
|
||||
var parsed = S7SzlParser.ParseDiagBuffer(payload, maxEntries: 10);
|
||||
parsed.Count.ShouldBe(5);
|
||||
parsed[0].EventId.ShouldBe((ushort)0x113A);
|
||||
parsed[0].Priority.ShouldBe((byte)1);
|
||||
parsed[0].OccurrenceUtc.Year.ShouldBe(2024);
|
||||
parsed[0].OccurrenceUtc.Month.ShouldBe(1);
|
||||
parsed[0].OccurrenceUtc.Day.ShouldBe(15);
|
||||
parsed[0].OccurrenceUtc.Hour.ShouldBe(8);
|
||||
parsed[0].OccurrenceUtc.Minute.ShouldBe(30);
|
||||
|
||||
parsed[3].EventId.ShouldBe((ushort)0x39C0);
|
||||
parsed[3].Priority.ShouldBe((byte)26);
|
||||
parsed[3].OccurrenceUtc.Second.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDiagBuffer_caps_entries_to_caller_supplied_max()
|
||||
{
|
||||
var entries = new List<S7DiagBufferEntry>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
entries.Add(new(DateTimeOffset.UnixEpoch, (ushort)(0x1000 + i), 1, ""));
|
||||
var payload = S7SzlParser.EncodeDiagBuffer(entries);
|
||||
|
||||
var parsed = S7SzlParser.ParseDiagBuffer(payload, maxEntries: 3);
|
||||
parsed.Count.ShouldBe(3);
|
||||
parsed[0].EventId.ShouldBe((ushort)0x1000);
|
||||
parsed[2].EventId.ShouldBe((ushort)0x1002);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCpuInfo_throws_on_truncated_payload()
|
||||
{
|
||||
// Header claims 28-byte records × 3 but body is only 4 bytes — should reject.
|
||||
var buf = new byte[S7SzlParser.HeaderLength + 4];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.ModuleIdentification);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 28);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 3);
|
||||
|
||||
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCpuInfo(buf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCycleStats_throws_on_short_record()
|
||||
{
|
||||
// Record length advertised as 8 bytes — too short for the cycle-time payload.
|
||||
var buf = new byte[S7SzlParser.HeaderLength + 8];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.CpuStatusData);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 8);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 1);
|
||||
|
||||
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCycleStats(buf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDiagBuffer_throws_on_wrong_record_length()
|
||||
{
|
||||
// SZL 0x00A0 records are exactly 20 bytes; 16 should be rejected.
|
||||
var buf = new byte[S7SzlParser.HeaderLength + 16];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.DiagnosticBuffer);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 16);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 1);
|
||||
|
||||
Should.Throw<ArgumentException>(() => S7SzlParser.ParseDiagBuffer(buf, maxEntries: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_throws_on_header_only_truncation()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCpuInfo(new byte[4]));
|
||||
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCycleStats(new byte[2]));
|
||||
Should.Throw<ArgumentException>(() => S7SzlParser.ParseDiagBuffer(new byte[3], maxEntries: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_encode_then_parse_preserves_cpu_info()
|
||||
{
|
||||
var original = new S7CpuInfo("CPU 1215C", "V4.5.0", "6ES7 215-1AG40-0XB0");
|
||||
var enc = S7SzlParser.EncodeCpuInfo(original);
|
||||
var dec = S7SzlParser.ParseCpuInfo(enc);
|
||||
dec.CpuType.ShouldBe(original.CpuType);
|
||||
dec.Firmware.ShouldBe(original.Firmware);
|
||||
dec.OrderNo.ShouldBe(original.OrderNo);
|
||||
|
||||
// Re-encode the parsed result and parse again — must equal the first decode.
|
||||
var reenc = S7SzlParser.EncodeCpuInfo(dec);
|
||||
var redec = S7SzlParser.ParseCpuInfo(reenc);
|
||||
redec.ShouldBe(dec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_encode_then_parse_preserves_cycle_stats()
|
||||
{
|
||||
var original = new S7CycleStats(MinMs: 1, MaxMs: 999, AvgMs: 7);
|
||||
var enc = S7SzlParser.EncodeCycleStats(original);
|
||||
var dec = S7SzlParser.ParseCycleStats(enc);
|
||||
dec.ShouldBe(original);
|
||||
|
||||
var reenc = S7SzlParser.EncodeCycleStats(dec);
|
||||
var redec = S7SzlParser.ParseCycleStats(reenc);
|
||||
redec.ShouldBe(dec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_encode_then_parse_preserves_diag_buffer_event_ids_and_priority()
|
||||
{
|
||||
// Use UTC midnight aligned timestamps so the BCD encoder's second-precision rounding
|
||||
// (ms isn't round-tripped) doesn't cause a comparison miss.
|
||||
var original = new[]
|
||||
{
|
||||
new S7DiagBufferEntry(new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero), 0xAAAA, 5, "Event 0xAAAA (priority 5)"),
|
||||
new S7DiagBufferEntry(new DateTimeOffset(2024, 6, 2, 0, 30, 15, TimeSpan.Zero), 0xBBBB, 10, "Event 0xBBBB (priority 10)"),
|
||||
};
|
||||
var enc = S7SzlParser.EncodeDiagBuffer(original);
|
||||
var dec = S7SzlParser.ParseDiagBuffer(enc, maxEntries: 10);
|
||||
dec.Count.ShouldBe(2);
|
||||
|
||||
// EventId / Priority round-trip exactly; OccurrenceUtc round-trips at second precision.
|
||||
dec[0].EventId.ShouldBe(original[0].EventId);
|
||||
dec[0].Priority.ShouldBe(original[0].Priority);
|
||||
dec[0].OccurrenceUtc.ShouldBe(original[0].OccurrenceUtc);
|
||||
dec[1].EventId.ShouldBe(original[1].EventId);
|
||||
dec[1].OccurrenceUtc.ShouldBe(original[1].OccurrenceUtc);
|
||||
|
||||
// Re-encode + re-parse should yield the same decoded list.
|
||||
var reenc = S7SzlParser.EncodeDiagBuffer(dec);
|
||||
var redec = S7SzlParser.ParseDiagBuffer(reenc, maxEntries: 10);
|
||||
redec.Count.ShouldBe(dec.Count);
|
||||
for (var i = 0; i < dec.Count; i++)
|
||||
{
|
||||
redec[i].EventId.ShouldBe(dec[i].EventId);
|
||||
redec[i].Priority.ShouldBe(dec[i].Priority);
|
||||
redec[i].OccurrenceUtc.ShouldBe(dec[i].OccurrenceUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user