@@ -19,7 +19,15 @@
|
||||
{ "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0",
|
||||
"offset": 100, "type": "u16", "value": 0 },
|
||||
{ "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)",
|
||||
"offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 }
|
||||
"offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 },
|
||||
{ "_desc": "PR-S7-D2: DB1.MyUdt[400].Pressure — Real (Float32) at byte 400",
|
||||
"offset": 400, "type": "f32", "value": 12.5 },
|
||||
{ "_desc": "PR-S7-D2: DB1.MyUdt[400].Status — Int16 at byte 404",
|
||||
"offset": 404, "type": "i16", "value": 7 },
|
||||
{ "_desc": "PR-S7-D2: DB1.MyUdt[400].Enabled — Bool at byte 406 bit 0 (true)",
|
||||
"offset": 406, "type": "bool", "value": true, "bit": 0 },
|
||||
{ "_desc": "PR-S7-D2: DB1.MyUdt[400] meta — udt_layout marker for the seed reader (3 members, 7 bytes total)",
|
||||
"offset": 407, "type": "u8", "value": 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,10 +45,39 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
|
||||
"""Poke seed values into the area buffer at declared byte offsets.
|
||||
|
||||
Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
|
||||
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is
|
||||
big-endian (Siemens wire format).
|
||||
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii, udt_layout}.
|
||||
Endianness is big-endian (Siemens wire format).
|
||||
|
||||
PR-S7-D2: ``udt_layout`` is a meta-seed-type that flattens an ordered list
|
||||
of UDT members into per-member primitive seeds at member-byte offsets
|
||||
relative to the parent's ``offset``. Shape:
|
||||
|
||||
{
|
||||
"offset": 400, "type": "udt_layout",
|
||||
"members": [
|
||||
{"name": "Pressure", "offset": 0, "type": "f32", "value": 12.5},
|
||||
{"name": "Status", "offset": 4, "type": "i16", "value": 7},
|
||||
{"name": "Enabled", "offset": 6, "type": "bool", "value": true, "bit": 0}
|
||||
]
|
||||
}
|
||||
|
||||
Members reuse the same primitive seed types so the simulator stays
|
||||
one-pass — ``udt_layout`` is sugar that lets the JSON profile read like
|
||||
the UDT layout the .NET driver fan-outs into.
|
||||
"""
|
||||
for seed in seeds:
|
||||
# PR-S7-D2: expand udt_layout meta-seeds inline before the per-type
|
||||
# dispatch so members hit the same primitive paths as a flat seed list.
|
||||
if seed.get("type") == "udt_layout":
|
||||
base = int(seed["offset"])
|
||||
members = seed.get("members", [])
|
||||
expanded = []
|
||||
for m in members:
|
||||
child = dict(m)
|
||||
child["offset"] = base + int(m.get("offset", 0))
|
||||
expanded.append(child)
|
||||
seed_buffer(buf, expanded)
|
||||
continue
|
||||
off = int(seed["offset"])
|
||||
t = seed["type"]
|
||||
v = seed["value"]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — UDT fan-out integration test against the python-snap7 S7-1500 fixture.
|
||||
/// Seeds a 3-member UDT (Real + Int + Bool) into <c>DB1.MyUdt[400]</c> via
|
||||
/// <c>Docker/profiles/s7_1500.json</c>'s <c>udt_layout</c> meta-seed, declares the
|
||||
/// same layout in driver options, and verifies the fanned-out leaf reads return the
|
||||
/// seeded values end-to-end through real S7comm. Build-only by default — the
|
||||
/// simulator fixture skips when python-snap7 isn't running, so this test contributes
|
||||
/// to the CI matrix without requiring docker locally.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500UdtFanOutTests(Snap7ServerFixture sim)
|
||||
{
|
||||
private const string ParentTagName = "MyUdt";
|
||||
|
||||
/// <summary>
|
||||
/// UDT layout matching the <c>udt_layout</c> meta-seed in the JSON profile.
|
||||
/// Pressure (Real) at byte 0, Status (Int16) at byte 4, Enabled (Bool) at byte 6.
|
||||
/// </summary>
|
||||
private static readonly S7UdtDefinition MyUdt = new(
|
||||
Name: "MyUdt",
|
||||
Members:
|
||||
[
|
||||
new S7UdtMember("Pressure", 0, S7DataType.Float32),
|
||||
new S7UdtMember("Status", 4, S7DataType.Int16),
|
||||
new S7UdtMember("Enabled", 6, S7DataType.Bool),
|
||||
],
|
||||
SizeBytes: 7);
|
||||
|
||||
private static S7DriverOptions BuildUdtOptions(string host, int port) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
CpuType = S7NetCpuType.S71500,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
Tags =
|
||||
[
|
||||
// Parent UDT tag — base address points at byte 400 in DB1, where the
|
||||
// simulator seeded the UDT contents. Fan-out emits three scalar leaves:
|
||||
// MyUdt.Pressure -> DB1.DBD400 (Real 12.5)
|
||||
// MyUdt.Status -> DB1.DBW404 (Int16 7)
|
||||
// MyUdt.Enabled -> DB1.DBX406.0 (Bool true)
|
||||
new S7TagDefinition(ParentTagName, "DB1.DBX400.0", S7DataType.Byte, UdtName: "MyUdt"),
|
||||
],
|
||||
Udts = [MyUdt],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_fans_out_udt_into_member_tags()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = BuildUdtOptions(sim.Host, sim.Port);
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-udt-fanout");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// After fan-out the parent UDT name is gone from the tag map; only the leaves
|
||||
// are readable. Reading the parent should surface BadNodeIdUnknown.
|
||||
var parent = await drv.ReadAsync([ParentTagName], TestContext.Current.CancellationToken);
|
||||
parent[0].StatusCode.ShouldNotBe(0u, "parent UDT tag must be replaced by its leaves");
|
||||
|
||||
// Read the three leaves and assert the seeded values come back.
|
||||
var leaves = await drv.ReadAsync(
|
||||
["MyUdt.Pressure", "MyUdt.Status", "MyUdt.Enabled"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
leaves.Count.ShouldBe(3);
|
||||
foreach (var s in leaves)
|
||||
s.StatusCode.ShouldBe(0u, "every UDT leaf read must succeed end-to-end");
|
||||
|
||||
Convert.ToSingle(leaves[0].Value).ShouldBe(12.5f, tolerance: 0.0001f);
|
||||
Convert.ToInt32(leaves[1].Value).ShouldBe(7);
|
||||
Convert.ToBoolean(leaves[2].Value).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
363
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7UdtFanOutTests.cs
Normal file
363
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7UdtFanOutTests.cs
Normal file
@@ -0,0 +1,363 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="S7Driver.InitializeAsync"/> time, that the depth cap (4 levels) is
|
||||
/// enforced, and that misordered / overlapping / unknown UDT references fail with
|
||||
/// the expected error shape.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7UdtFanOutTests
|
||||
{
|
||||
private static S7DriverOptions OptionsWith(IReadOnlyList<S7TagDefinition> tags, IReadOnlyList<S7UdtDefinition> 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 },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static async Task<Exception> InitAndCaptureFanOutErrorAsync(S7DriverOptions opts)
|
||||
{
|
||||
using var drv = new S7Driver(opts, "s7-udt-test");
|
||||
return await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<S7TagDefinition> FanOut(
|
||||
S7TagDefinition parent, IReadOnlyList<S7UdtDefinition> 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<S7DriverFactoryExtensions.S7DriverConfigDto>(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<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[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<InvalidOperationException>();
|
||||
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<FormatException>();
|
||||
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<InvalidOperationException>(() => FanOut(parent, [udt]));
|
||||
ex.Message.ShouldContain("SizeBytes", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user