using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; [Trait("Category", "Unit")] public sealed class FocasPmcCoalescedReadTests { private const string Host = "focas://10.0.0.5:8193"; private static (FocasDriver drv, FakeFocasClientFactory factory, FakeFocasClient client) NewDriver( params FocasTagDefinition[] tags) { var client = new FakeFocasClient(); var factory = new FakeFocasClientFactory { Customise = () => client }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = tags, Probe = new FocasProbeOptions { Enabled = false }, }, "drv-coalesce", factory); return (drv, factory, client); } private static FocasTagDefinition Tag(string name, string addr, FocasDataType type) => new(name, Host, addr, type); [Fact] public async Task Hundred_contiguous_PMC_bytes_collapse_to_one_wire_call() { // 100 contiguous R-letter byte-shaped tags → coalescer cap is 256 → one range read. var tags = new FocasTagDefinition[100]; var refs = new string[100]; for (var i = 0; i < 100; i++) { tags[i] = Tag($"r{i}", $"R{i}", FocasDataType.Byte); refs[i] = $"r{i}"; } var (drv, _, client) = NewDriver(tags); client.PmcByteRanges[("R", 1)] = new byte[200]; for (var i = 0; i < 100; i++) client.PmcByteRanges[("R", 1)][i] = (byte)(i + 1); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(refs, CancellationToken.None); snapshots.Count.ShouldBe(100); foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good); // sbyte cast: 1 → 1, 100 stays positive snapshots[0].Value.ShouldBe((sbyte)1); snapshots[99].Value.ShouldBe((sbyte)100); client.RangeReadLog.Count.ShouldBe(1); client.RangeReadLog[0].Letter.ShouldBe("R"); client.RangeReadLog[0].StartByte.ShouldBe(0); client.RangeReadLog[0].ByteCount.ShouldBe(100); } [Fact] public async Task Gap_larger_than_bridge_threshold_splits_into_two_wire_calls() { // R0, R1, then R100, R101 — gap of 98 > bridge cap of 16 → 2 ranges. var tags = new[] { Tag("r0", "R0", FocasDataType.Byte), Tag("r1", "R1", FocasDataType.Byte), Tag("r100", "R100", FocasDataType.Byte), Tag("r101", "R101", FocasDataType.Byte), }; var (drv, _, client) = NewDriver(tags); client.PmcByteRanges[("R", 1)] = new byte[200]; client.PmcByteRanges[("R", 1)][0] = 10; client.PmcByteRanges[("R", 1)][1] = 11; client.PmcByteRanges[("R", 1)][100] = 20; client.PmcByteRanges[("R", 1)][101] = 21; await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["r0", "r1", "r100", "r101"], CancellationToken.None); snapshots.Count.ShouldBe(4); foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots[0].Value.ShouldBe((sbyte)10); snapshots[3].Value.ShouldBe((sbyte)21); client.RangeReadLog.Count.ShouldBe(2); } [Fact] public async Task Different_letters_yield_separate_wire_calls() { var tags = new[] { Tag("r0", "R0", FocasDataType.Byte), Tag("r1", "R1", FocasDataType.Byte), Tag("d0", "D0", FocasDataType.Byte), Tag("d1", "D1", FocasDataType.Byte), }; var (drv, _, client) = NewDriver(tags); client.PmcByteRanges[("R", 1)] = new byte[8] { 1, 2, 3, 4, 5, 6, 7, 8 }; client.PmcByteRanges[("D", 1)] = new byte[8] { 9, 10, 11, 12, 13, 14, 15, 16 }; await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["r0", "r1", "d0", "d1"], CancellationToken.None); foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots[0].Value.ShouldBe((sbyte)1); snapshots[2].Value.ShouldBe((sbyte)9); client.RangeReadLog.Count.ShouldBe(2); client.RangeReadLog.ShouldContain(c => c.Letter == "R"); client.RangeReadLog.ShouldContain(c => c.Letter == "D"); } [Fact] public async Task Different_paths_yield_separate_wire_calls() { var tags = new[] { Tag("p1a", "R0", FocasDataType.Byte), Tag("p1b", "R1", FocasDataType.Byte), Tag("p2a", "R0@2", FocasDataType.Byte), Tag("p2b", "R1@2", FocasDataType.Byte), }; var (drv, _, client) = NewDriver(tags); client.PathCount = 2; client.PmcByteRanges[("R", 1)] = new byte[] { 1, 2 }; client.PmcByteRanges[("R", 2)] = new byte[] { 7, 8 }; await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["p1a", "p1b", "p2a", "p2b"], CancellationToken.None); foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good); snapshots[0].Value.ShouldBe((sbyte)1); snapshots[2].Value.ShouldBe((sbyte)7); client.RangeReadLog.Count.ShouldBe(2); client.RangeReadLog.ShouldContain(c => c.PathId == 1); client.RangeReadLog.ShouldContain(c => c.PathId == 2); } [Fact] public async Task Single_PMC_tag_does_not_invoke_range_path() { // Single-tag PMC reads aren't worth coalescing — the driver falls through to the // existing per-tag dispatch so connect/set-path overhead isn't paid twice. var (drv, _, client) = NewDriver(Tag("only", "R5", FocasDataType.Byte)); client.Values["R5"] = (sbyte)42; await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["only"], CancellationToken.None); snapshots.Single().Value.ShouldBe((sbyte)42); client.RangeReadLog.ShouldBeEmpty(); } [Fact] public async Task Bit_addressed_tags_share_parent_byte_range() { // R10.0 + R10.3 + R10.7 + R11.0 — all addressing R10 / R11, one coalesced range. var tags = new[] { Tag("b0", "R10.0", FocasDataType.Bit), Tag("b3", "R10.3", FocasDataType.Bit), Tag("b7", "R10.7", FocasDataType.Bit), Tag("c0", "R11.0", FocasDataType.Bit), }; var (drv, _, client) = NewDriver(tags); client.PmcByteRanges[("R", 1)] = new byte[20]; // R10 = 0b1000_1001 → bit0=1, bit3=1, bit7=1 client.PmcByteRanges[("R", 1)][10] = 0b1000_1001; // R11 = 0b0000_0000 → bit0=0 client.PmcByteRanges[("R", 1)][11] = 0; await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["b0", "b3", "b7", "c0"], CancellationToken.None); snapshots[0].Value.ShouldBe(true); snapshots[1].Value.ShouldBe(true); snapshots[2].Value.ShouldBe(true); snapshots[3].Value.ShouldBe(false); client.RangeReadLog.Count.ShouldBe(1); client.RangeReadLog[0].StartByte.ShouldBe(10); client.RangeReadLog[0].ByteCount.ShouldBe(2); } [Fact] public async Task Wider_types_decode_correctly_from_coalesced_buffer() { // R0 Int16 + R2 Int32 + R6 Byte — contiguous (R0..R6 = 7 bytes), one range. var tags = new[] { Tag("w16", "R0", FocasDataType.Int16), Tag("w32", "R2", FocasDataType.Int32), Tag("b", "R6", FocasDataType.Byte), }; var (drv, _, client) = NewDriver(tags); // Little-endian: R0..R1 = 0x1234 → bytes 0x34, 0x12; R2..R5 = 0x12345678 → 0x78,0x56,0x34,0x12; R6=0x05 client.PmcByteRanges[("R", 1)] = new byte[] { 0x34, 0x12, 0x78, 0x56, 0x34, 0x12, 0x05 }; await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["w16", "w32", "b"], CancellationToken.None); snapshots[0].Value.ShouldBe((short)0x1234); snapshots[1].Value.ShouldBe(0x12345678); snapshots[2].Value.ShouldBe((sbyte)0x05); client.RangeReadLog.Count.ShouldBe(1); client.RangeReadLog[0].ByteCount.ShouldBe(7); } [Fact] public async Task Non_PMC_tags_in_same_batch_use_per_tag_path() { // Mix PMC + Parameter + Macro — only the PMC half coalesces. var tags = new[] { Tag("r0", "R0", FocasDataType.Byte), Tag("r1", "R1", FocasDataType.Byte), Tag("p", "PARAM:1820", FocasDataType.Int32), Tag("m", "MACRO:500", FocasDataType.Float64), }; var (drv, _, client) = NewDriver(tags); client.PmcByteRanges[("R", 1)] = new byte[] { 11, 22 }; client.Values["PARAM:1820"] = 7777; client.Values["MACRO:500"] = 2.71828; await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["r0", "r1", "p", "m"], CancellationToken.None); snapshots[0].Value.ShouldBe((sbyte)11); snapshots[1].Value.ShouldBe((sbyte)22); snapshots[2].Value.ShouldBe(7777); snapshots[3].Value.ShouldBe(2.71828); client.RangeReadLog.Count.ShouldBe(1); } }