Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Joseph Doherty d5034c40f7 Phase 3 PR 63 -- S7AddressParser for DB/M/I/Q/T/C address strings. Adds S7AddressParser + S7ParsedAddress + S7Area + S7Size to the Driver.S7 project. Grammar follows driver-specs.md \u00A75 + Siemens TIA Portal / STEP 7 Classic convention: (1) Data blocks: DB{n}.DB{X|B|W|D}{offset}[.bit] where X=bit (requires .bit suffix 0-7), B=byte, W=word (16-bit), D=dword (32-bit). (2) Merkers: MB{n}, MW{n}, MD{n}, or M{n}.{bit} for bit access. (3) Inputs + Outputs: same {B|W|D} prefix or {n}.{bit} pattern as M. (4) Timers: T{n}. (5) Counters: C{n}. Output is an immutable S7ParsedAddress record struct with Area (DataBlock / Memory / Input / Output / Timer / Counter), DbNumber (only meaningful for DataBlock), Size (Bit / Byte / Word / DWord), ByteOffset (also timer/counter number when Area is Timer/Counter), BitOffset (0-7 for Size=Bit; 0 otherwise). Case-insensitive via ToUpperInvariant, whitespace trimmed on entry. Parse throws FormatException with the offending input echoed in the message; TryParse returns bool for config-validation callers that can't afford exceptions (e.g. Admin UI tag-editor live validation). Strict rejection policy -- 16 garbage cases covered in the theory test: empty/whitespace input, unknown area letter (Z0), DB without number/tail, DB bit size without .bit suffix, bit offset 8+, word/dword with .bit suffix, DB number 0 (must be >=1), non-numeric DB number, unknown size letter (Q), M without offset, M bit access without .bit, bit 8, negative offset, non-digit offset, non-numeric timer. Strict rejection surfaces config errors at driver-init time rather than as BadInternalError on every Read against the bad tag. No driver code wires through yet -- PR 64 is where IReadable/IWritable consume S7ParsedAddress and translate into S7netplus Plc.ReadAsync calls (the S7.Net address grammar is a strict subset of what we accept, and the parser's S7ParsedAddress is the bridge). Unit tests (S7AddressParserTests, 50 facts): parse-valid theories for DB/M/I/Q/T/C covering all size variants + edge bit offsets 0 and 7; case-insensitive + whitespace-trim theory; reject-invalid theory with 16 garbage cases; TryParse round-trip for valid and invalid inputs. 50/50 pass, dotnet build clean.
2026-04-19 00:06:24 -04:00

120 lines
4.4 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
[Trait("Category", "Unit")]
public sealed class S7AddressParserTests
{
// --- Data blocks ---
[Theory]
[InlineData("DB1.DBX0.0", 1, S7Size.Bit, 0, 0)]
[InlineData("DB1.DBX0.7", 1, S7Size.Bit, 0, 7)]
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
{
var r = S7AddressParser.Parse(input);
r.Area.ShouldBe(S7Area.DataBlock);
r.DbNumber.ShouldBe(db);
r.Size.ShouldBe(size);
r.ByteOffset.ShouldBe(byteOff);
r.BitOffset.ShouldBe(bitOff);
}
[Theory]
[InlineData("db1.dbw0", 1, S7Size.Word, 0)]
[InlineData(" DB1.DBW0 ", 1, S7Size.Word, 0)] // trim whitespace
public void Parse_is_case_insensitive_and_trims(string input, int db, S7Size size, int off)
{
var r = S7AddressParser.Parse(input);
r.Area.ShouldBe(S7Area.DataBlock);
r.DbNumber.ShouldBe(db);
r.Size.ShouldBe(size);
r.ByteOffset.ShouldBe(off);
}
// --- M / I / Q ---
[Theory]
[InlineData("MB0", S7Area.Memory, S7Size.Byte, 0, 0)]
[InlineData("MW10", S7Area.Memory, S7Size.Word, 10, 0)]
[InlineData("MD4", S7Area.Memory, S7Size.DWord, 4, 0)]
[InlineData("M0.0", S7Area.Memory, S7Size.Bit, 0, 0)]
[InlineData("M255.7", S7Area.Memory, S7Size.Bit, 255, 7)]
[InlineData("IB0", S7Area.Input, S7Size.Byte, 0, 0)]
[InlineData("IW0", S7Area.Input, S7Size.Word, 0, 0)]
[InlineData("I0.0", S7Area.Input, S7Size.Bit, 0, 0)]
[InlineData("QB0", S7Area.Output, S7Size.Byte, 0, 0)]
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
{
var r = S7AddressParser.Parse(input);
r.Area.ShouldBe(area);
r.DbNumber.ShouldBe(0);
r.Size.ShouldBe(size);
r.ByteOffset.ShouldBe(byteOff);
r.BitOffset.ShouldBe(bitOff);
}
// --- Timers / counters ---
[Theory]
[InlineData("T0", S7Area.Timer, 0)]
[InlineData("T15", S7Area.Timer, 15)]
[InlineData("C0", S7Area.Counter, 0)]
[InlineData("C10", S7Area.Counter, 10)]
public void Parse_timer_and_counter(string input, S7Area area, int number)
{
var r = S7AddressParser.Parse(input);
r.Area.ShouldBe(area);
r.ByteOffset.ShouldBe(number);
r.Size.ShouldBe(S7Size.Word, "timers + counters are 16-bit opaque");
}
// --- Reject garbage ---
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("Z0")] // unknown area
[InlineData("DB")] // no number or tail
[InlineData("DB1")] // no tail
[InlineData("DB1.")] // empty tail
[InlineData("DB1.DBX0")] // bit size without .bit
[InlineData("DB1.DBX0.8")] // bit 8 out of range
[InlineData("DB1.DBW0.0")] // word with bit suffix
[InlineData("DB0.DBW0")] // db 0 invalid
[InlineData("DBA.DBW0")] // non-numeric db
[InlineData("DB1.DBQ0")] // invalid size letter
[InlineData("M")] // no offset
[InlineData("M0")] // bit access needs .bit
[InlineData("M0.8")] // bit 8
[InlineData("MB-1")] // negative offset
[InlineData("MW")] // no offset digits
[InlineData("TA")] // non-numeric timer
public void Parse_rejects_invalid(string bad)
=> Should.Throw<FormatException>(() => S7AddressParser.Parse(bad));
[Fact]
public void TryParse_returns_false_for_garbage_without_throwing()
{
S7AddressParser.TryParse("not-an-address", out var r).ShouldBeFalse();
r.ShouldBe(default);
}
[Fact]
public void TryParse_returns_true_for_valid_address()
{
S7AddressParser.TryParse("DB1.DBW0", out var r).ShouldBeTrue();
r.DbNumber.ShouldBe(1);
r.Size.ShouldBe(S7Size.Word);
}
}