using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
///
/// PR-S7-D2 — UDT / STRUCT fan-out unit tests. Verifies the recursive flattening of
/// UDT-typed parent tags into scalar leaf member tags happens correctly at
/// time, that the depth cap (4 levels) is
/// enforced, and that misordered / overlapping / unknown UDT references fail with
/// the expected error shape.
///
[Trait("Category", "Unit")]
public sealed class S7UdtFanOutTests
{
private static S7DriverOptions OptionsWith(IReadOnlyList tags, IReadOnlyList udts)
=> new()
{
Host = "192.0.2.1", // RFC 5737 reserved — never reachable; we only exercise the parse + fan-out path.
Timeout = TimeSpan.FromMilliseconds(50),
Tags = tags,
Udts = udts,
Probe = new S7ProbeOptions { Enabled = false },
};
///
/// Run InitializeAsync against an unreachable host so the parse / fan-out validation
/// happens before the TCP connect attempt fails. Returns the exception so tests can
/// assert on its shape; throws if the unexpected exception type bubbled up.
///
private static async Task InitAndCaptureFanOutErrorAsync(S7DriverOptions opts)
{
using var drv = new S7Driver(opts, "s7-udt-test");
return await Should.ThrowAsync(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
///
/// Direct-call helper that bypasses InitializeAsync's TCP connect: invokes the
/// fan-out helper directly so the test can inspect the produced leaf tag list
/// without spinning up a Plc.
///
private static IReadOnlyList FanOut(
S7TagDefinition parent, IReadOnlyList udts)
{
var parsed = S7AddressParser.Parse(parent.Address);
return S7UdtFanOut.Expand(parent, udts, parsed);
}
[Fact]
public void Single_level_UDT_with_three_scalar_members_emits_three_tags()
{
var udt = new S7UdtDefinition("Sensor", new[]
{
new S7UdtMember("Pressure", 0, S7DataType.Float32),
new S7UdtMember("Status", 4, S7DataType.Int16),
new S7UdtMember("Enabled", 6, S7DataType.Bool),
}, SizeBytes: 8);
var parent = new S7TagDefinition("MySensor", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Sensor");
var leaves = FanOut(parent, [udt]);
leaves.Count.ShouldBe(3);
leaves[0].Name.ShouldBe("MySensor.Pressure");
leaves[0].Address.ShouldBe("DB1.DBD0");
leaves[0].DataType.ShouldBe(S7DataType.Float32);
leaves[1].Name.ShouldBe("MySensor.Status");
leaves[1].Address.ShouldBe("DB1.DBW4");
leaves[2].Name.ShouldBe("MySensor.Enabled");
leaves[2].Address.ShouldBe("DB1.DBX6.0");
}
[Fact]
public void Nested_UDT_recursively_flattens_with_dot_separated_paths()
{
var inner = new S7UdtDefinition("Telemetry", new[]
{
new S7UdtMember("Pressure", 0, S7DataType.Float32),
new S7UdtMember("Temperature", 4, S7DataType.Float32),
}, SizeBytes: 8);
var outer = new S7UdtDefinition("Pump", new[]
{
new S7UdtMember("Telemetry", 0, S7DataType.Byte, UdtName: "Telemetry"),
new S7UdtMember("Speed", 8, S7DataType.Int16),
}, SizeBytes: 10);
var parent = new S7TagDefinition("Pump1", "DB2.DBX0.0", S7DataType.Byte, UdtName: "Pump");
var leaves = FanOut(parent, [outer, inner]);
leaves.Count.ShouldBe(3);
leaves[0].Name.ShouldBe("Pump1.Telemetry.Pressure");
leaves[0].Address.ShouldBe("DB2.DBD0");
leaves[1].Name.ShouldBe("Pump1.Telemetry.Temperature");
leaves[1].Address.ShouldBe("DB2.DBD4");
leaves[2].Name.ShouldBe("Pump1.Speed");
leaves[2].Address.ShouldBe("DB2.DBW8");
}
[Fact]
public void Four_level_nesting_succeeds_at_the_cap()
{
// L4 is the deepest level the cap allows.
var l4 = new S7UdtDefinition("L4", [new S7UdtMember("Leaf", 0, S7DataType.Int16)], SizeBytes: 2);
var l3 = new S7UdtDefinition("L3", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L4")], SizeBytes: 2);
var l2 = new S7UdtDefinition("L2", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L3")], SizeBytes: 2);
var l1 = new S7UdtDefinition("L1", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L2")], SizeBytes: 2);
var parent = new S7TagDefinition("Root", "DB1.DBX0.0", S7DataType.Byte, UdtName: "L1");
var leaves = FanOut(parent, [l1, l2, l3, l4]);
leaves.Count.ShouldBe(1);
leaves[0].Name.ShouldBe("Root.X.X.X.Leaf");
}
[Fact]
public void Five_level_nesting_throws_with_clear_error()
{
// Add a fifth level — exceeds the depth cap.
var l5 = new S7UdtDefinition("L5", [new S7UdtMember("Leaf", 0, S7DataType.Int16)], SizeBytes: 2);
var l4 = new S7UdtDefinition("L4", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L5")], SizeBytes: 2);
var l3 = new S7UdtDefinition("L3", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L4")], SizeBytes: 2);
var l2 = new S7UdtDefinition("L2", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L3")], SizeBytes: 2);
var l1 = new S7UdtDefinition("L1", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L2")], SizeBytes: 2);
var parent = new S7TagDefinition("Root", "DB1.DBX0.0", S7DataType.Byte, UdtName: "L1");
var ex = Should.Throw(() => FanOut(parent, [l1, l2, l3, l4, l5]));
ex.Message.ShouldContain("nesting depth", Case.Insensitive);
ex.Message.ShouldContain("4");
}
[Fact]
public void Reference_to_unknown_UDT_throws_with_clear_error()
{
var parent = new S7TagDefinition("MissingUdtTag", "DB1.DBX0.0", S7DataType.Byte, UdtName: "DoesNotExist");
var ex = Should.Throw(() => FanOut(parent, []));
ex.Message.ShouldContain("DoesNotExist");
ex.Message.ShouldContain("MissingUdtTag");
ex.Message.ShouldContain("not declared", Case.Insensitive);
}
[Fact]
public void Misordered_member_offsets_throw_at_fan_out()
{
var udt = new S7UdtDefinition("Bad", new[]
{
new S7UdtMember("First", 4, S7DataType.Int16),
new S7UdtMember("Second", 0, S7DataType.Int16), // earlier byte than First.
}, SizeBytes: 8);
var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Bad");
var ex = Should.Throw(() => FanOut(parent, [udt]));
ex.Message.ShouldContain("misordered", Case.Insensitive);
}
[Fact]
public void Overlapping_member_offsets_throw_at_fan_out()
{
// Float32 at offset 0 occupies bytes 0..3; Int16 at offset 2 overlaps.
var udt = new S7UdtDefinition("Overlap", new[]
{
new S7UdtMember("Wide", 0, S7DataType.Float32),
new S7UdtMember("Narrow", 2, S7DataType.Int16),
}, SizeBytes: 8);
var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Overlap");
var ex = Should.Throw(() => FanOut(parent, [udt]));
ex.Message.ShouldContain("overlap", Case.Insensitive);
}
[Fact]
public void Array_member_with_three_elements_emits_three_indexed_sub_tags()
{
var udt = new S7UdtDefinition("Bank", new[]
{
new S7UdtMember("Sensors", 0, S7DataType.Float32, ArrayDim: 3),
new S7UdtMember("Count", 12, S7DataType.Int16),
}, SizeBytes: 14);
var parent = new S7TagDefinition("Bank1", "DB3.DBX0.0", S7DataType.Byte, UdtName: "Bank");
var leaves = FanOut(parent, [udt]);
leaves.Count.ShouldBe(4);
leaves[0].Name.ShouldBe("Bank1.Sensors[0]");
leaves[0].Address.ShouldBe("DB3.DBD0");
leaves[1].Name.ShouldBe("Bank1.Sensors[1]");
leaves[1].Address.ShouldBe("DB3.DBD4");
leaves[2].Name.ShouldBe("Bank1.Sensors[2]");
leaves[2].Address.ShouldBe("DB3.DBD8");
leaves[3].Name.ShouldBe("Bank1.Count");
leaves[3].Address.ShouldBe("DB3.DBW12");
}
[Fact]
public void DTO_round_trip_preserves_UdtName_and_Udts_collection()
{
var dto = new S7DriverFactoryExtensions.S7DriverConfigDto
{
Host = "10.0.0.5",
Tags =
[
new S7DriverFactoryExtensions.S7TagDto
{
Name = "Pump1",
Address = "DB1.DBX0.0",
DataType = "Byte",
UdtName = "Pump",
}
],
Udts =
[
new S7DriverFactoryExtensions.S7UdtDto
{
Name = "Pump",
SizeBytes = 8,
Members =
[
new S7DriverFactoryExtensions.S7UdtMemberDto
{
Name = "Pressure", Offset = 0, DataType = "Float32"
},
new S7DriverFactoryExtensions.S7UdtMemberDto
{
Name = "Status", Offset = 4, DataType = "Int16"
},
],
}
],
};
var json = JsonSerializer.Serialize(dto);
var back = JsonSerializer.Deserialize(json)!;
back.Tags.ShouldNotBeNull();
back.Tags!.Count.ShouldBe(1);
back.Tags[0].UdtName.ShouldBe("Pump");
back.Udts.ShouldNotBeNull();
back.Udts!.Count.ShouldBe(1);
back.Udts[0].Name.ShouldBe("Pump");
back.Udts[0].SizeBytes.ShouldBe(8);
back.Udts[0].Members!.Count.ShouldBe(2);
back.Udts[0].Members![0].Name.ShouldBe("Pressure");
back.Udts[0].Members![0].DataType.ShouldBe("Float32");
}
[Fact]
public void Tag_with_both_UdtName_and_DataType_uses_UdtName()
{
// When both are set, UdtName wins — the primitive DataType is ignored because the
// parent tag is replaced wholesale by its scalar leaves at fan-out time.
var udt = new S7UdtDefinition("Tiny", [new S7UdtMember("V", 0, S7DataType.Int16)], SizeBytes: 2);
// Parent declares DataType=Float32 and UdtName=Tiny. The leaf comes out as Int16 (per UDT),
// not Float32, proving UdtName trumps the primitive DataType.
var parent = new S7TagDefinition("Either", "DB1.DBX0.0", S7DataType.Float32, UdtName: "Tiny");
var leaves = FanOut(parent, [udt]);
leaves.Count.ShouldBe(1);
leaves[0].DataType.ShouldBe(S7DataType.Int16);
leaves[0].Name.ShouldBe("Either.V");
}
[Fact]
public async Task Initialize_with_UDT_tag_replaces_parent_with_leaves_in_address_space()
{
// End-to-end driver path: fan-out should populate the discovery folder with the
// leaves, NOT the parent. Initialize will fail at TCP connect (unreachable host),
// but only AFTER the parse + fan-out has populated the tag map.
var udt = new S7UdtDefinition("Sensor", new[]
{
new S7UdtMember("Pressure", 0, S7DataType.Float32),
new S7UdtMember("Status", 4, S7DataType.Int16),
}, SizeBytes: 6);
var opts = OptionsWith(
tags: [new S7TagDefinition("MySensor", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Sensor")],
udts: [udt]);
var ex = await InitAndCaptureFanOutErrorAsync(opts);
// The fan-out itself didn't throw (UDT was found, layout valid) — only the TCP connect
// failed with a socket / timeout exception. That's the expected shape.
ex.ShouldNotBeOfType();
}
[Fact]
public async Task Initialize_with_UDT_referencing_unknown_layout_fails_at_fan_out()
{
var opts = OptionsWith(
tags: [new S7TagDefinition("MissingUdtTag", "DB1.DBX0.0", S7DataType.Byte, UdtName: "DoesNotExist")],
udts: []);
var ex = await InitAndCaptureFanOutErrorAsync(opts);
ex.ShouldBeOfType();
ex.Message.ShouldContain("DoesNotExist");
}
[Fact]
public async Task Initialize_rejects_UDT_tag_with_ElementCount_greater_than_one()
{
var udt = new S7UdtDefinition("Tiny", [new S7UdtMember("V", 0, S7DataType.Int16)], SizeBytes: 2);
var opts = OptionsWith(
tags: [new S7TagDefinition("BadArrayUdt", "DB1.DBX0.0", S7DataType.Byte,
ElementCount: 5, UdtName: "Tiny")],
udts: [udt]);
var ex = await InitAndCaptureFanOutErrorAsync(opts);
ex.ShouldBeOfType();
ex.Message.ShouldContain("array-of-UDT", Case.Insensitive);
}
[Fact]
public void Array_of_nested_UDT_strides_member_addresses_by_inner_size()
{
// Inner UDT is 4 bytes wide (one Float32). An array of 3 of these strides 4 bytes
// per element. Outer UDT places the array at offset 0, so the leaves should be at
// bytes 0, 4, 8.
var inner = new S7UdtDefinition("Reading", [new S7UdtMember("Value", 0, S7DataType.Float32)], SizeBytes: 4);
var outer = new S7UdtDefinition("Channel", new[]
{
new S7UdtMember("Readings", 0, S7DataType.Byte, ArrayDim: 3, UdtName: "Reading"),
}, SizeBytes: 12);
var parent = new S7TagDefinition("Ch1", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Channel");
var leaves = FanOut(parent, [outer, inner]);
leaves.Count.ShouldBe(3);
leaves[0].Name.ShouldBe("Ch1.Readings[0].Value");
leaves[0].Address.ShouldBe("DB1.DBD0");
leaves[1].Name.ShouldBe("Ch1.Readings[1].Value");
leaves[1].Address.ShouldBe("DB1.DBD4");
leaves[2].Name.ShouldBe("Ch1.Readings[2].Value");
leaves[2].Address.ShouldBe("DB1.DBD8");
}
[Fact]
public void Members_extending_past_SizeBytes_throw()
{
// A 4-byte Float32 at offset 6 ends at 10, but SizeBytes claims 8.
var udt = new S7UdtDefinition("Cramped", new[]
{
new S7UdtMember("X", 6, S7DataType.Float32),
}, SizeBytes: 8);
var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Cramped");
var ex = Should.Throw(() => FanOut(parent, [udt]));
ex.Message.ShouldContain("SizeBytes", Case.Insensitive);
}
}