8b2c3fa04c
- Rename FocasEquipmentTagParser -> FocasTagDefinitionFactory; TryParse ->
FromTagConfig(tagConfig, rawPath, out def). Drop leading-{ heuristic + the
deviceHostAddress blob key; def.Name = rawPath. Add inverse ToTagConfig.
writable: absent -> read-only, explicit true honoured (note preserved).
- FocasDriverOptions.Tags -> RawTags (IReadOnlyList<RawTagEntry>).
- FocasDriver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
RawTags via FromTagConfig; multi-device routing threads RawTagEntry.DeviceName
-> ResolveDeviceHost match against options.Devices -> def.DeviceHostAddress
(TODO v3 WaveC: live host from Device row DeviceConfig). Tolerant per-tag
skip+log (mapper/address/matrix miss -> BadNodeIdUnknown); unknown device
fails init fast.
- Factory: bind List<RawTagEntry> RawTags; retire FocasTagDto/ParseDataType.
- CLI FocasCommandBase.BuildOptions -> RawTags via ToTagConfig + DeviceName.
- Tests: FocasRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
FromTagConfig; rewrite retired blob-ref tests (equipment-tag/capability-gate/
resolve-host) onto authored RawPath. 272 driver + 52 CLI green.
303 lines
13 KiB
C#
303 lines
13 KiB
C#
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 FocasReadWriteTests
|
|
{
|
|
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(params FocasTagDefinition[] tags)
|
|
{
|
|
var factory = new FakeFocasClientFactory();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
RawTags = FocasRawTags.Entries(tags),
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
return (drv, factory);
|
|
}
|
|
|
|
// ---- Read ----
|
|
|
|
/// <summary>Verifies that an unknown reference maps to BadNodeIdUnknown.</summary>
|
|
[Fact]
|
|
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
|
{
|
|
var (drv, _) = NewDriver();
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
|
}
|
|
|
|
/// <summary>Verifies that a successful PMC read returns a Good status value.</summary>
|
|
[Fact]
|
|
public async Task Successful_PMC_read_returns_Good_value()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5 } };
|
|
|
|
var snapshots = await drv.ReadAsync(["Run"], CancellationToken.None);
|
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
|
snapshots.Single().Value.ShouldBe((sbyte)5);
|
|
}
|
|
|
|
/// <summary>Verifies that parameter reads route through the FocasAddress Parameter kind.</summary>
|
|
[Fact]
|
|
public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("Accel", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient { Values = { ["PARAM:1820"] = 1500 } };
|
|
|
|
var snapshots = await drv.ReadAsync(["Accel"], CancellationToken.None);
|
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
|
snapshots.Single().Value.ShouldBe(1500);
|
|
}
|
|
|
|
/// <summary>Verifies that macro reads route through the FocasAddress Macro kind.</summary>
|
|
[Fact]
|
|
public async Task Macro_read_routes_through_FocasAddress_Macro_kind()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient { Values = { ["MACRO:500"] = 3.14159 } };
|
|
|
|
var snapshots = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
|
|
snapshots.Single().Value.ShouldBe(3.14159);
|
|
}
|
|
|
|
/// <summary>Verifies that repeated reads reuse the connection.</summary>
|
|
[Fact]
|
|
public async Task Repeat_read_reuses_connection()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
|
|
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
factory.Clients.Count.ShouldBe(1);
|
|
factory.Clients[0].ConnectCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that FOCAS error statuses map correctly via the status mapper.</summary>
|
|
[Fact]
|
|
public async Task FOCAS_error_status_maps_via_status_mapper()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("Ghost", "focas://10.0.0.5:8193", "R999", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () =>
|
|
{
|
|
var c = new FakeFocasClient();
|
|
c.ReadStatuses["R999"] = FocasStatusMapper.BadNodeIdUnknown;
|
|
return c;
|
|
};
|
|
|
|
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
|
}
|
|
|
|
/// <summary>Verifies that a read exception surfaces BadCommunicationError.</summary>
|
|
[Fact]
|
|
public async Task Read_exception_surfaces_BadCommunicationError()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient { ThrowOnRead = true };
|
|
|
|
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
}
|
|
|
|
/// <summary>Verifies that a connection failure disposes the client and surfaces BadCommunicationError.</summary>
|
|
[Fact]
|
|
public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true };
|
|
|
|
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
|
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
|
factory.Clients[0].DisposeCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that batched reads preserve order across different address areas.</summary>
|
|
[Fact]
|
|
public async Task Batched_reads_preserve_order_across_areas()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
|
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32),
|
|
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient
|
|
{
|
|
Values =
|
|
{
|
|
["R100"] = (sbyte)5,
|
|
["PARAM:1820"] = 1500,
|
|
["MACRO:500"] = 2.718,
|
|
},
|
|
};
|
|
|
|
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
|
snapshots[0].Value.ShouldBe((sbyte)5);
|
|
snapshots[1].Value.ShouldBe(1500);
|
|
snapshots[2].Value.ShouldBe(2.718);
|
|
}
|
|
|
|
// ---- Write ----
|
|
|
|
/// <summary>Verifies that a non-writable tag write is rejected with BadNotWritable.</summary>
|
|
[Fact]
|
|
public async Task Non_writable_tag_rejected_with_BadNotWritable()
|
|
{
|
|
var (drv, _) = NewDriver(
|
|
new FocasTagDefinition("RO", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var results = await drv.WriteAsync(
|
|
[new WriteRequest("RO", 1)], CancellationToken.None);
|
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
|
}
|
|
|
|
/// <summary>Verifies that a successful write logs the address, type, and value.</summary>
|
|
[Fact]
|
|
public async Task Successful_write_logs_address_type_value()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var results = await drv.WriteAsync(
|
|
[new WriteRequest("Speed", (short)1800)], CancellationToken.None);
|
|
|
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
|
var write = factory.Clients[0].WriteLog.Single();
|
|
write.addr.Canonical.ShouldBe("R100");
|
|
write.type.ShouldBe(FocasDataType.Int16);
|
|
write.value.ShouldBe((short)1800);
|
|
}
|
|
|
|
/// <summary>Verifies that write status codes map correctly via the FocasStatusMapper.</summary>
|
|
[Fact]
|
|
public async Task Write_status_code_maps_via_FocasStatusMapper()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () =>
|
|
{
|
|
var c = new FakeFocasClient();
|
|
c.WriteStatuses["R100"] = FocasStatusMapper.BadNotWritable;
|
|
return c;
|
|
};
|
|
|
|
var results = await drv.WriteAsync(
|
|
[new WriteRequest("Protected", (sbyte)1)], CancellationToken.None);
|
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
|
}
|
|
|
|
/// <summary>Verifies that batched writes preserve order across different outcomes.</summary>
|
|
[Fact]
|
|
public async Task Batch_write_preserves_order_across_outcomes()
|
|
{
|
|
var factory = new FakeFocasClientFactory();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
RawTags = FocasRawTags.Entries(
|
|
[
|
|
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
|
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
|
|
]),
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var results = await drv.WriteAsync(
|
|
[
|
|
new WriteRequest("A", (sbyte)1),
|
|
new WriteRequest("B", (sbyte)2),
|
|
new WriteRequest("Unknown", (sbyte)3),
|
|
], CancellationToken.None);
|
|
|
|
results[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
|
results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
|
results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
|
}
|
|
|
|
/// <summary>Verifies that cancellation signals are propagated.</summary>
|
|
[Fact]
|
|
public async Task Cancellation_propagates()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
factory.Customise = () => new FakeFocasClient
|
|
{
|
|
ThrowOnRead = true,
|
|
Exception = new OperationCanceledException(cts.Token),
|
|
};
|
|
|
|
// A CANCELLATION of the caller's token must propagate (abort the read). This is distinct
|
|
// from a per-call timeout — an OCE raised while the caller's token is still live is swallowed
|
|
// to a per-tag BadCommunicationError (see Swallows_a_spurious_read_OCE_when_caller_not_cancelled).
|
|
await Should.ThrowAsync<OperationCanceledException>(
|
|
() => drv.ReadAsync(["X"], cts.Token));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An OperationCanceledException from the wire read while the CALLER'S token is NOT cancelled
|
|
/// (e.g. a per-call timeout firing) must be turned into a per-tag BadCommunicationError, not
|
|
/// propagated — otherwise one stalled tag would abort the whole poll batch.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Swallows_a_spurious_read_OCE_when_caller_not_cancelled()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient
|
|
{
|
|
ThrowOnRead = true,
|
|
Exception = new OperationCanceledException(),
|
|
};
|
|
|
|
var snap = (await drv.ReadAsync(["X"], CancellationToken.None)).Single();
|
|
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
|
}
|
|
|
|
/// <summary>Verifies that ShutdownAsync disposes the client.</summary>
|
|
[Fact]
|
|
public async Task ShutdownAsync_disposes_client()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
|
|
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
|
|
factory.Clients[0].DisposeCount.ShouldBe(1);
|
|
}
|
|
}
|