Auto: s7-e1 — CPU diagnostic buffer / SZL reads

Closes #302
This commit is contained in:
Joseph Doherty
2026-04-26 10:30:43 -04:00
parent f7e0d9a9e7
commit 108f69d198
14 changed files with 1701 additions and 3 deletions

View File

@@ -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();
}
}
}

View 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");
}
}

View 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);
}
}
}