using Shouldly; using Xunit; using S7.Net.Types; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// Unit tests for the S7 wide-type (8-byte numeric) byte-buffer codec: the pure /// / /// helpers and . These decode/encode an /// Int64/UInt64/LReal (Float64) scalar from a contiguous big-endian byte block — the /// network I/O half (Plc.ReadBytesAsync/WriteBytesAsync) has no in-process /// fake so only the codec is unit-proven (mirrors ). /// String (S7 classic STRING) decode/encode is proven here via /// ; DateTime (S7 classic DATE_AND_TIME / DT, 8-byte BCD) /// decode/encode is proven via (the 8-byte DT helper — /// NOT DTL). Timer (S5TIME → seconds double) and Counter (word → int count) /// decode is proven via / /// and is routed by the parsed AREA (so a Timer/Float64 tag reads 2 bytes, not 8); both are /// read-only this phase, so their encode throws . /// [Trait("Category", "Unit")] public sealed class S7ScalarBlockTests { // ── Helpers ────────────────────────────────────────────────────────────────────────── // Wide scalars are byte-anchored: DB{n}.DBB{offset}, parser yields S7Size.Byte. private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254) => new("WideTag", "DB1.DBB0", dt, StringLength: stringLength); private static S7ParsedAddress Addr() => new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0); // Timer / Counter addresses: the parser stores the timer/counter NUMBER in ByteOffset, // DbNumber 0, Size Word (see S7AddressParser.ParseTimerOrCounter). A Timer tag is typed // Float64 (decoded to seconds) and a Counter tag Int32 (decoded to count) — T1 guard-c. private static S7TagDefinition TimerTag(int number = 5, bool writable = false) => new("TimerTag", $"T{number}", S7DataType.Float64, Writable: writable); private static S7ParsedAddress TimerAddr(int number = 5) => new(S7Area.Timer, DbNumber: 0, S7Size.Word, ByteOffset: number, BitOffset: 0); private static S7TagDefinition CounterTag(int number = 3, bool writable = false) => new("CounterTag", $"C{number}", S7DataType.Int32, Writable: writable); private static S7ParsedAddress CounterAddr(int number = 3) => new(S7Area.Counter, DbNumber: 0, S7Size.Word, ByteOffset: number, BitOffset: 0); // S7 is big-endian: most-significant byte first. private static byte[] BeUInt64(ulong v) { var b = new byte[8]; for (var i = 0; i < 8; i++) b[i] = (byte)(v >> (56 - i * 8)); return b; } // ── ScalarByteWidth ─────────────────────────────────────────────────────────────────── /// Verifies the 8-byte numeric widths and the String/DateTime widths. [Theory] [InlineData(S7DataType.Int64, 8)] [InlineData(S7DataType.UInt64, 8)] [InlineData(S7DataType.Float64, 8)] [InlineData(S7DataType.DateTime, 8)] public void ScalarByteWidth_fixed_width_types(S7DataType dt, int expected) => S7Driver.ScalarByteWidth(Tag(dt), Addr()).ShouldBe(expected); /// Verifies String width is StringLength + 2 (S7 STRING header: max-len + actual-len). [Fact] public void ScalarByteWidth_String_is_length_plus_two() => S7Driver.ScalarByteWidth(Tag(S7DataType.String, stringLength: 10), Addr()).ShouldBe(12); /// Headline correctness assertion: a Timer tag is typed Float64 (which would otherwise /// yield width 8), but the Timer AREA must take precedence and read exactly 2 bytes (one S5TIME /// word). Without area-precedence the codec would read 8 bytes and mis-frame the timer. [Fact] public void ScalarByteWidth_Timer_is_two_despite_Float64_datatype() => S7Driver.ScalarByteWidth(TimerTag(), TimerAddr()).ShouldBe(2); /// A Counter tag is typed Int32; the Counter AREA reads exactly 2 bytes (one counter word). [Fact] public void ScalarByteWidth_Counter_is_two() => S7Driver.ScalarByteWidth(CounterTag(), CounterAddr()).ShouldBe(2); // ── DecodeScalarBlock — Int64 ───────────────────────────────────────────────────────── /// Verifies an Int64 block decodes from big-endian bytes. [Fact] public void DecodeScalarBlock_Int64_reads_big_endian() { var block = BeUInt64(0x0123456789ABCDEFUL); // First byte is the most-significant byte (0x01) — proves big-endian, not little-endian. block[0].ShouldBe((byte)0x01); var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block); result.ShouldBeOfType().ShouldBe(0x0123456789ABCDEFL); } /// Verifies a negative Int64 decodes correctly (two's complement, high bit set). [Fact] public void DecodeScalarBlock_Int64_negative() { var block = BeUInt64(unchecked((ulong)-2L)); // 0xFFFF_FFFF_FFFF_FFFE var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Int64), Addr(), block); result.ShouldBeOfType().ShouldBe(-2L); } // ── DecodeScalarBlock — UInt64 ──────────────────────────────────────────────────────── /// Verifies a UInt64 block decodes a value larger than long.MaxValue. [Fact] public void DecodeScalarBlock_UInt64_reads_value_above_long_max() { var block = BeUInt64(ulong.MaxValue); // 0xFFFF_FFFF_FFFF_FFFF var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.UInt64), Addr(), block); result.ShouldBeOfType().ShouldBe(ulong.MaxValue); } // ── DecodeScalarBlock — Float64 (LReal) ─────────────────────────────────────────────── /// Verifies a Float64 (LReal) block decodes from IEEE-754 big-endian. [Fact] public void DecodeScalarBlock_Float64_reads_ieee754_big_endian() { var bits = unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI)); var block = BeUInt64(bits); var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.Float64), Addr(), block); result.ShouldBeOfType().ShouldBe(Math.PI, tolerance: 1e-12); } // ── EncodeScalarBlock — big-endian byte production ──────────────────────────────────── /// Verifies Int64 encodes to big-endian bytes (MSB first). [Fact] public void EncodeScalarBlock_Int64_writes_big_endian() { var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Int64), 0x0123456789ABCDEFL); bytes.Length.ShouldBe(8); bytes.ShouldBe(BeUInt64(0x0123456789ABCDEFUL)); bytes[0].ShouldBe((byte)0x01); // MSB first — little-endian regression guard. } /// Verifies UInt64 encodes to big-endian bytes. [Fact] public void EncodeScalarBlock_UInt64_writes_big_endian() { var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.UInt64), ulong.MaxValue); bytes.ShouldBe(BeUInt64(ulong.MaxValue)); } /// Verifies Float64 encodes to IEEE-754 big-endian bytes. [Fact] public void EncodeScalarBlock_Float64_writes_ieee754_big_endian() { var bytes = S7Driver.EncodeScalarBlock(Tag(S7DataType.Float64), Math.PI); bytes.ShouldBe(BeUInt64(unchecked((ulong)BitConverter.DoubleToInt64Bits(Math.PI)))); } // ── Round-trip identity (encode → decode) ───────────────────────────────────────────── /// Verifies Int64 round-trips through encode→decode for positive, negative and edge values. [Theory] [InlineData(0L)] [InlineData(1L)] [InlineData(-1L)] [InlineData(long.MaxValue)] [InlineData(long.MinValue)] [InlineData(0x0123456789ABCDEFL)] public void Int64_round_trips(long value) { var tag = Tag(S7DataType.Int64); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); decoded.ShouldBeOfType().ShouldBe(value); } /// Verifies UInt64 round-trips, including a large value above long.MaxValue. [Theory] [InlineData(0UL)] [InlineData(70_000UL)] [InlineData(ulong.MaxValue)] [InlineData(0x8000_0000_0000_0001UL)] public void UInt64_round_trips(ulong value) { var tag = Tag(S7DataType.UInt64); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); decoded.ShouldBeOfType().ShouldBe(value); } /// Verifies Float64 (LReal) round-trips for representative doubles, including /// the IEEE-754 specials (NaN / ±Infinity) — these pass through BinaryPrimitives unchanged. [Theory] [InlineData(0.0)] [InlineData(3.141592653589793)] [InlineData(-2.5e-300)] [InlineData(1.7976931348623157e308)] [InlineData(double.NaN)] [InlineData(double.PositiveInfinity)] [InlineData(double.NegativeInfinity)] public void Float64_round_trips(double value) { var tag = Tag(S7DataType.Float64); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); // double.NaN != double.NaN, so compare via Shouldly's IsNaN for that case. if (double.IsNaN(value)) decoded.ShouldBeOfType().ShouldBe(double.NaN); else decoded.ShouldBeOfType().ShouldBe(value); } // ── DecodeScalarBlock — String (S7 classic STRING) ──────────────────────────────────── /// Verifies an S7 STRING block decodes to its C# string, ignoring the /// two-byte [maxLen][curLen] header and any reserved padding past curLen. [Fact] public void DecodeScalarBlock_String_reads_header_and_chars() { // S7 classic STRING layout: [maxLen=10][curLen=5]['H']['E']['L']['L']['O'][pad…]. var block = S7String.ToByteArray("HELLO", 10); block.Length.ShouldBe(12); // StringLength(10) + 2-byte header. block[0].ShouldBe((byte)10); // maxLen. block[1].ShouldBe((byte)5); // curLen. var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block); result.ShouldBeOfType().ShouldBe("HELLO"); } /// Verifies a hand-built STRING block (not produced by S7.Net) still decodes, /// pinning the on-the-wire layout we depend on. [Fact] public void DecodeScalarBlock_String_decodes_hand_built_block() { var block = new byte[12]; block[0] = 10; // maxLen. block[1] = 5; // curLen. block[2] = (byte)'H'; block[3] = (byte)'E'; block[4] = (byte)'L'; block[5] = (byte)'L'; block[6] = (byte)'O'; // bytes 7..11 stay zero (reserved padding). var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block); result.ShouldBeOfType().ShouldBe("HELLO"); } /// Verifies an empty S7 STRING decodes to the empty string. [Fact] public void DecodeScalarBlock_String_empty() { var block = S7String.ToByteArray("", 10); var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block); result.ShouldBeOfType().ShouldBe(""); } // ── EncodeScalarBlock — String (S7 classic STRING) ──────────────────────────────────── /// Verifies a C# string encodes to a STRING block sized to the reserved field /// (StringLength + 2), with header [maxLen=StringLength][curLen=value.Length] and /// the chars laid out after it. [Fact] public void EncodeScalarBlock_String_writes_header_chars_and_pads_to_reserved() { var tag = Tag(S7DataType.String, stringLength: 10); var block = S7Driver.EncodeScalarBlock(tag, "HELLO"); block.Length.ShouldBe(12); // StringLength(10) + 2 — full reserved field, not curLen-sized. block[0].ShouldBe((byte)10); // maxLen == declared StringLength. block[1].ShouldBe((byte)5); // curLen == value.Length. block[2].ShouldBe((byte)'H'); block[6].ShouldBe((byte)'O'); block[7].ShouldBe((byte)0); // reserved padding is zeroed. block[11].ShouldBe((byte)0); } /// Verifies a null value encodes as an empty STRING (curLen 0), not a throw. [Fact] public void EncodeScalarBlock_String_null_value_is_empty_string() { var tag = Tag(S7DataType.String, stringLength: 10); var block = S7Driver.EncodeScalarBlock(tag, value: null); block.Length.ShouldBe(12); block[0].ShouldBe((byte)10); // maxLen. block[1].ShouldBe((byte)0); // curLen 0. } /// Verifies a non-string value coerces via Convert.ToString before encoding. [Fact] public void EncodeScalarBlock_String_coerces_non_string_value() { var tag = Tag(S7DataType.String, stringLength: 10); var block = S7Driver.EncodeScalarBlock(tag, 1234); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), block); decoded.ShouldBeOfType().ShouldBe("1234"); } /// Verifies a string longer than the reserved length throws (S7.Net rejects it; /// we do NOT silently truncate). Pins the overflow behaviour. [Fact] public void EncodeScalarBlock_String_overflow_throws() { var tag = Tag(S7DataType.String, stringLength: 5); // 6 chars into a 5-char reserved field — S7.Net throws ArgumentException. Should.Throw(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF")); } // ── String round-trip identity (encode → decode) ────────────────────────────────────── /// Verifies String round-trips through encode→decode, incl. empty and max-length. [Theory] [InlineData("")] [InlineData("A")] [InlineData("Hello, S7!")] // 10 chars == StringLength (at max). [InlineData("Tag_Value 42")] public void String_round_trips(string value) { // StringLength sized to fit the longest sample (12) so none overflow. var tag = Tag(S7DataType.String, stringLength: 12); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); decoded.ShouldBeOfType().ShouldBe(value); } /// Verifies a string exactly at the reserved length round-trips (boundary, no overflow). [Fact] public void String_at_max_length_round_trips() { var tag = Tag(S7DataType.String, stringLength: 5); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, "ABCDE")); decoded.ShouldBeOfType().ShouldBe("ABCDE"); } // ── DecodeScalarBlock — DateTime (S7 classic DATE_AND_TIME / DT) ─────────────────────── /// Verifies an 8-byte S7 DT block decodes to its value /// via (the 8-byte BCD DT helper, not the 12-byte DTL). [Fact] public void DecodeScalarBlock_DateTime_reads_dt_bcd_block() { var expected = new System.DateTime(2026, 6, 17, 12, 34, 56); // Fixture built by S7.Net's own DT encoder so the block matches the on-the-wire DT layout. var block = global::S7.Net.Types.DateTime.ToByteArray(expected); block.Length.ShouldBe(8); var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.DateTime), Addr(), block); result.ShouldBeOfType().ShouldBe(expected); } // ── EncodeScalarBlock — DateTime ─────────────────────────────────────────────────────── /// Verifies a encodes to an 8-byte DT block. [Fact] public void EncodeScalarBlock_DateTime_writes_eight_byte_block() { var value = new System.DateTime(2026, 6, 17, 12, 34, 56); var block = S7Driver.EncodeScalarBlock(Tag(S7DataType.DateTime), value); block.Length.ShouldBe(8); // Matches S7.Net's own DT encoder exactly (same on-the-wire bytes). block.ShouldBe(global::S7.Net.Types.DateTime.ToByteArray(value)); } /// Verifies a string/ISO timestamp coerces via Convert.ToDateTime before encoding. [Fact] public void EncodeScalarBlock_DateTime_coerces_string_value() { var tag = Tag(S7DataType.DateTime); var block = S7Driver.EncodeScalarBlock(tag, "2026-06-17T12:34:56"); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), block); decoded.ShouldBeOfType().ShouldBe(new System.DateTime(2026, 6, 17, 12, 34, 56)); } /// Verifies a year outside the S7 DT range (1990–2089) throws — S7.Net's DT encoder /// validates the range and raises ; we surface it. [Theory] [InlineData(1980)] [InlineData(2100)] public void EncodeScalarBlock_DateTime_out_of_range_year_throws(int year) { var tag = Tag(S7DataType.DateTime); var value = new System.DateTime(year, 1, 1, 0, 0, 0); Should.Throw(() => S7Driver.EncodeScalarBlock(tag, value)); } // ── DateTime round-trip identity (encode → decode) ──────────────────────────────────── /// Verifies DateTime round-trips through encode→decode. S7 DT preserves full /// millisecond precision (the 8-byte BCD packs ms-tens/hundreds), so the identity holds to /// the millisecond — no precision loss to document below the second. [Theory] [InlineData(1990, 1, 1, 0, 0, 0, 0)] // DT minimum. [InlineData(2026, 6, 17, 12, 34, 56, 0)] // no sub-second. [InlineData(2026, 6, 17, 12, 34, 56, 789)] // milliseconds preserved. [InlineData(2089, 12, 31, 23, 59, 59, 999)] // DT maximum, ms boundary. public void DateTime_round_trips_to_the_millisecond( int y, int mo, int d, int h, int mi, int s, int ms) { var value = new System.DateTime(y, mo, d, h, mi, s, ms); var tag = Tag(S7DataType.DateTime); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); decoded.ShouldBeOfType().ShouldBe(value); } // ── DecodeScalarBlock — Timer (S5TIME → seconds, read-only) ─────────────────────────── /// /// Verifies a 2-byte S5TIME block decodes to a duration in SECONDS as a double, /// routed by the Timer AREA (NOT the tag's Float64 DataType — that would mis-read the /// 2-byte block as an 8-byte LReal). The fixture is a hand-built S5TIME word: bits [15:14] /// of the 16-bit word carry the time base (0=×10 ms, 1=×100 ms, 2=×1 s, 3=×10 s), bits /// [13:12] are unused (always 0), and bits [11:0] carry the 3-digit BCD value (hundreds in /// byte0 low nibble, tens in byte1 high nibble, ones in byte1 low nibble). Here base=2 /// (×1 s) and BCD=100 → 100.0 s (verified against S7.Net's /// S5TIME decode). /// [Fact] public void DecodeScalarBlock_Timer_decodes_s5time_to_seconds() { // [0x21,0x00]: byte0 high nibble 0x2 = base bits (×1 s), byte0 low nibble 0x1 = BCD hundreds, // byte1 0x00 = BCD tens+ones → value 100 × 1 s = 100.0 s. var block = new byte[] { 0x21, 0x00 }; var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block); result.ShouldBeOfType().ShouldBe(100.0, tolerance: 1e-9); } /// Verifies a sub-second S5TIME decodes correctly (base = 100 ms, BCD = 250 → 25.0 s). [Fact] public void DecodeScalarBlock_Timer_decodes_fractional_base() { // [0x12,0x50]: base bits 0x1 (×0.1 s), BCD 250 → 250 × 0.1 s = 25.0 s. var block = new byte[] { 0x12, 0x50 }; var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block); result.ShouldBeOfType().ShouldBe(25.0, tolerance: 1e-9); } /// Verifies a zero S5TIME word decodes to 0.0 seconds. [Fact] public void DecodeScalarBlock_Timer_zero() { var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), new byte[] { 0x00, 0x00 }); result.ShouldBeOfType().ShouldBe(0.0); } /// /// Covers the two remaining S5TIME time bases to complete all four (0–3). S5TIME word /// layout: bits[15:14]=base, bits[13:12]=0, bits[11:8]=BCD-hundreds, bits[7:4]=BCD-tens, /// bits[3:0]=BCD-ones. Bases 1 and 2 are covered by the Fact tests above; base 0 /// (×0.01 s) and base 3 (×10 s) are added here. /// /// Byte derivation: /// /// Base 0 (×0.01 s) / BCD=015: byte0=[base=0<<6|0<<4|hundreds=0]=0x00, /// byte1=[tens=1<<4|ones=5]=0x15 → 15 × 0.01 s = 0.15 s. /// Base 3 (×10 s) / BCD=005: byte0=[base=3<<6|0<<4|hundreds=0]=0x30, /// byte1=[tens=0<<4|ones=5]=0x05 → 5 × 10 s = 50.0 s. /// /// /// [Theory] [InlineData(new byte[] { 0x00, 0x15 }, 0.15)] // base 0 (×0.01 s), BCD=015 → 15×0.01=0.15 s [InlineData(new byte[] { 0x30, 0x05 }, 50.0)] // base 3 (×10 s), BCD=005 → 5×10=50.0 s public void DecodeScalarBlock_Timer_all_time_bases(byte[] block, double expectedSeconds) { var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block); result.ShouldBeOfType().ShouldBe(expectedSeconds, tolerance: 1e-9); } // ── DecodeScalarBlock — Counter (BCD/raw word → count, read-only) ────────────────────── /// /// Verifies a 2-byte counter block decodes to a COUNT as an int, routed by the /// Counter AREA (the tag is typed Int32). S7.Net's /// reads the word big-endian; we surface /// it as a (non-negative) int. /// [Fact] public void DecodeScalarBlock_Counter_decodes_to_int_count() { // Big-endian word 0x0042 = 66. var block = new byte[] { 0x00, 0x42 }; var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block); result.ShouldBeOfType().ShouldBe(66); } /// Verifies a larger counter value decodes (0x0123 = 291) — pins big-endian order. [Fact] public void DecodeScalarBlock_Counter_decodes_big_endian() { var block = new byte[] { 0x01, 0x23 }; var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block); result.ShouldBeOfType().ShouldBe(0x0123); } /// Verifies a zero counter decodes to 0. [Fact] public void DecodeScalarBlock_Counter_zero() { var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), new byte[] { 0x00, 0x00 }); result.ShouldBeOfType().ShouldBe(0); } // ── EncodeScalarBlock — Timer/Counter are read-only this phase ───────────────────────── /// Verifies a Timer write throws NotSupportedException — Timer/Counter are read-only. /// This backstop is still exercised even though now /// short-circuits to BadNotWritable before reaching EncodeScalarBlock — a mis-route (e.g. /// a future refactor that bypasses the WriteAsync guard) must still fail loudly here. [Fact] public void EncodeScalarBlock_Timer_throws_read_only() => Should.Throw(() => S7Driver.EncodeScalarBlock(TimerTag(), 1.0)); /// Verifies a Counter write throws NotSupportedException — Timer/Counter are read-only. [Fact] public void EncodeScalarBlock_Counter_throws_read_only() => Should.Throw(() => S7Driver.EncodeScalarBlock(CounterTag(), 42)); // ── WriteAsync guard — Timer/Counter area precondition ──────────────────────────────── /// /// Pins the precondition for the Timer/Counter /// short-circuit: on a Timer address must /// yield and on a Counter address must yield /// . The WriteAsync guard detects the area via TryParse (for /// transient equipment-tag refs not in _parsedByName) and returns BadNotWritable without /// reaching EncodeScalarBlock. This test verifies the area detection that gate depends on /// — a pure, no-PLC assertion. ( itself requires a connected Plc /// and is therefore integration-tested only; the EncodeScalarBlock_Timer/Counter_throws /// tests above cover the defensive backstop if the guard is ever bypassed.) /// [Theory] [InlineData("T0", S7Area.Timer)] [InlineData("T5", S7Area.Timer)] [InlineData("T15", S7Area.Timer)] [InlineData("C0", S7Area.Counter)] [InlineData("C3", S7Area.Counter)] [InlineData("C10", S7Area.Counter)] public void AddressParser_yields_Timer_or_Counter_area_for_TC_addresses(string address, S7Area expectedArea) { S7AddressParser.TryParse(address, out var parsed).ShouldBeTrue(); parsed.Area.ShouldBe(expectedArea, $"WriteAsync BadNotWritable guard relies on TryParse('{address}').Area == {expectedArea}"); } }