5d7c53bb19
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
432 lines
22 KiB
C#
432 lines
22 KiB
C#
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||
|
||
/// <summary>
|
||
/// Task #177 — tests covering ALMD projection detection, feature-flag gate,
|
||
/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
|
||
/// </summary>
|
||
[Trait("Category", "Unit")]
|
||
public sealed class AbCipAlarmProjectionTests
|
||
{
|
||
private const string Device = "ab://10.0.0.5/1,0";
|
||
|
||
private static AbCipTagDefinition AlmdTag(string name) => new(
|
||
name, Device, name, AbCipDataType.Structure, Members:
|
||
[
|
||
new AbCipStructureMember("InAlarm", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
|
||
new AbCipStructureMember("Acked", AbCipDataType.DInt),
|
||
new AbCipStructureMember("Severity", AbCipDataType.DInt),
|
||
new AbCipStructureMember("In", AbCipDataType.DInt),
|
||
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // input-health flag — NOT the alarm
|
||
]);
|
||
|
||
/// <summary>Verifies that ALMD structure signature is correctly detected as an alarm.</summary>
|
||
[Fact]
|
||
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
|
||
{
|
||
var almd = AlmdTag("HighTemp");
|
||
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
|
||
|
||
var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members:
|
||
[new AbCipStructureMember("X", AbCipDataType.DInt)]);
|
||
AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse();
|
||
|
||
var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt);
|
||
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
|
||
}
|
||
|
||
/// <summary>Verifies that severity values map correctly to OPC UA alarm severity levels.</summary>
|
||
[Fact]
|
||
public void Severity_Mapping_Matches_OPC_UA_Convention()
|
||
{
|
||
// Logix severity 1–1000 — mirror the OpcUaClient ACAndC bucketing.
|
||
AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low);
|
||
AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium);
|
||
AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High);
|
||
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
|
||
}
|
||
|
||
private static AbCipDriverOptions Options(bool enabled) => new()
|
||
{
|
||
Devices = [new AbCipDeviceOptions(Device)],
|
||
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
|
||
EnableAlarmProjection = enabled,
|
||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
|
||
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
|
||
EnableDeclarationOnlyUdtGrouping = true,
|
||
};
|
||
|
||
/// <summary>Verifies that disabled alarm projection returns a valid handle but does not poll.</summary>
|
||
[Fact]
|
||
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: false), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
handle.ShouldNotBeNull();
|
||
handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");
|
||
|
||
// Positive control (#583): the same options with the flag ON poll three times — so a loop this
|
||
// subscription had started would have read (and so created) the ALMD runtime by now.
|
||
await PolledThreeTimesWithTheFlagOnAsync();
|
||
factory.Tags.ShouldNotContainKey("HighTemp");
|
||
factory.Tags.ShouldNotContainKey("HighTemp.InAlarm");
|
||
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
|
||
|
||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
}
|
||
|
||
/// <summary>Verifies that enabled alarm projection starts polling and fires raise event on 0-to-1 transition.</summary>
|
||
[Fact]
|
||
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
var events = new List<AlarmEventArgs>();
|
||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||
|
||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
|
||
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
|
||
// gets created + polled. Set InAlarm offset-value to 0 first (clear), let polls seed the
|
||
// "last-seen false" state, then flip to 1 (fault) + wait for the raise event.
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm=false at offset 0
|
||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InAlarm+Acked)
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 2);
|
||
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
|
||
await WaitForEventAsync(events, e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
|
||
&& e.Message.Contains("raised"));
|
||
|
||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Regression for the reconnect poll-loop leak (#399 sibling): the owning
|
||
/// DriverInstanceActor re-subscribes alarms on every Connected entry without first calling
|
||
/// Unsubscribe, and the driver object (and its projection) survives the in-place reconnect.
|
||
/// Each SubscribeAsync used to start a fresh, never-cancelled poll loop — so after N
|
||
/// reconnects there were N live loops all polling the device and all firing the same
|
||
/// raise/clear transition, producing DUPLICATE alarm events.
|
||
///
|
||
/// This simulates two re-subscribes (one reconnect) against the same source, then drives
|
||
/// ONE 0->1 device transition. After the collapse-to-single-loop fix exactly one loop is
|
||
/// alive so the raise fires exactly once; before the fix both loops fire it ⇒ two events.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Resubscribe_Collapses_To_Single_Loop_No_Duplicate_Raise()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
var raises = new List<AlarmEventArgs>();
|
||
drv.OnAlarmEvent += (_, e) =>
|
||
{
|
||
if (e.Message.Contains("raised")) lock (raises) raises.Add(e);
|
||
};
|
||
|
||
// First subscribe creates + starts polling the HighTemp runtime. Seed InAlarm=false +
|
||
// a severity so the loop's baseline settles on "not faulted".
|
||
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm=false at offset 0
|
||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 2); // sub1's loop has seeded its baseline
|
||
|
||
// Second subscribe = the actor re-subscribing across a reconnect. Its loop reads the same
|
||
// shared HighTemp runtime; let it poll (and so seed its own "last-seen false" baseline) too.
|
||
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
|
||
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // one 0->1 transition
|
||
await WaitForEventAsync(raises, _ => true);
|
||
// Processed-count wait (#583), not a fixed delay: six more reads of the shared runtime after the raise,
|
||
// so a leaked second loop — which reads it too — has had polls in which to fire its duplicate.
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
|
||
|
||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
|
||
lock (raises)
|
||
{
|
||
raises.Count.ShouldBe(1,
|
||
"exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// hw4 — ALMD's alarm-active output is <c>InAlarm</c> (Rockwell ALMD reference); <c>InFaulted</c> (Status.1) is
|
||
/// the INPUT-health flag. The projection used to poll InFaulted, so a live ALMD would raise on an input fault
|
||
/// and never on the alarm itself.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task An_input_fault_is_not_an_alarm_and_InAlarm_is()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
var events = new List<AlarmEventArgs>();
|
||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||
|
||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm clear
|
||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||
factory.Tags["HighTemp"].ValuesByOffset[16] = 1; // InFaulted set: input health bad, not an alarm
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
|
||
lock (events) events.ShouldBeEmpty("an input fault (InFaulted) is not an alarm");
|
||
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // the alarm condition
|
||
await WaitForEventAsync(events, e => e.Kind == AlarmTransitionKind.Raise);
|
||
|
||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
}
|
||
|
||
/// <summary>hw4 F2 — an ALMD that declared members but no InAlarm (a pre-hw4 InFaulted config) would read
|
||
/// BadNodeIdUnknown forever; it logs ONE Warning per tag instead of staying silent.</summary>
|
||
[Fact]
|
||
public async Task An_almd_without_a_declared_InAlarm_warns_once()
|
||
{
|
||
var legacy = new AbCipTagDefinition("Legacy", Device, "Legacy", AbCipDataType.Structure, Members:
|
||
[new AbCipStructureMember("InFaulted", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt),
|
||
new AbCipStructureMember("Severity", AbCipDataType.DInt)]);
|
||
var log = new CapturingLogger();
|
||
var opts = new AbCipDriverOptions
|
||
{
|
||
Devices = [new AbCipDeviceOptions(Device)],
|
||
RawTags = AbCipRawTags.From(legacy),
|
||
EnableAlarmProjection = true,
|
||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||
};
|
||
var drv = new AbCipDriver(opts, "drv-1", new FakeAbCipTagFactory(), logger: log);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
var h1 = await drv.SubscribeAlarmsAsync(["Legacy"], CancellationToken.None);
|
||
var h2 = await drv.SubscribeAlarmsAsync(["Legacy"], CancellationToken.None);
|
||
await drv.UnsubscribeAlarmsAsync(h1, CancellationToken.None);
|
||
await drv.UnsubscribeAlarmsAsync(h2, CancellationToken.None);
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
log.Warnings.Count(m => m.Contains("Legacy") && m.Contains("InAlarm")).ShouldBe(1);
|
||
}
|
||
|
||
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<AbCipDriver>
|
||
{
|
||
public List<string> Warnings { get; } = [];
|
||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||
{
|
||
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception));
|
||
}
|
||
}
|
||
|
||
/// <summary>Verifies that alarm clear event fires on 1-to-0 transition.</summary>
|
||
[Fact]
|
||
public async Task Clear_Event_Fires_On_1_to_0_Transition()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
var events = new List<AlarmEventArgs>();
|
||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||
|
||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||
await WaitForEventAsync(events, e => e.Message.Contains("raised"));
|
||
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
|
||
await WaitForEventAsync(events, e => e.Message.Contains("cleared"));
|
||
|
||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
|
||
// Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and
|
||
// projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every
|
||
// ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if
|
||
// it had routed.
|
||
AlarmEventArgs raised, cleared;
|
||
lock (events)
|
||
{
|
||
raised = events.First(e => e.Message.Contains("raised"));
|
||
cleared = events.First(e => e.Message.Contains("cleared"));
|
||
}
|
||
raised.ConditionId.ShouldBe("HighTemp");
|
||
raised.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||
cleared.ConditionId.ShouldBe("HighTemp");
|
||
cleared.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #641 review — the server carries every surviving native condition's state (a client's
|
||
/// acknowledgement included) across an address-space rebuild, and the host re-subscribes alarms on every
|
||
/// reconnect and every alarm-set change. A re-subscribe used to start from an empty "last seen" record, so
|
||
/// a still-faulted ALMD was re-RAISED, which the host projects as Active+UNacked — un-acknowledging an alarm
|
||
/// an operator had already acknowledged. The record is the driver's, kept for refs that stay in the set.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task A_resubscribe_does_not_re_raise_a_still_faulted_alarm()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
var raises = new List<AlarmEventArgs>();
|
||
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
|
||
|
||
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||
await WaitForEventAsync(raises, _ => true);
|
||
|
||
// A reconnect / alarm-set change: the same ref, still faulted.
|
||
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
|
||
|
||
lock (raises) raises.Count.ShouldBe(1, "a re-subscribe must not re-raise (and so un-acknowledge) a still-faulted alarm");
|
||
|
||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
}
|
||
|
||
/// <summary>Gitea #641 review — positive control: a ref that LEFT the subscribed set was materialised fresh
|
||
/// (inactive) when it came back, so it is raised again.</summary>
|
||
[Fact]
|
||
public async Task A_ref_that_left_the_set_and_came_back_is_raised_again()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
var raises = new List<AlarmEventArgs>();
|
||
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
|
||
|
||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||
await WaitForEventAsync(raises, _ => true);
|
||
|
||
await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForEventAsync(raises, _ => raises.Count >= 2);
|
||
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
}
|
||
|
||
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
|
||
[Fact]
|
||
public async Task Unsubscribe_Stops_The_Poll_Loop()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 1); // the loop is running
|
||
|
||
// Unsubscribe awaits the loop's exit, so no read can land after it returns.
|
||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||
var postUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
|
||
|
||
// Positive control (#583): an identical, still-subscribed driver polls three more times on the same
|
||
// interval — so the unsubscribed loop, had it survived, would have read again by now.
|
||
await PolledThreeTimesWithTheFlagOnAsync();
|
||
factory.Tags["HighTemp"].ReadCount.ShouldBe(postUnsubReadCount);
|
||
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
}
|
||
|
||
/// <summary>A control driver with the projection ON polls its ALMD runtime three times.</summary>
|
||
private static async Task PolledThreeTimesWithTheFlagOnAsync()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var control = new AbCipDriver(Options(enabled: true), "drv-control", factory);
|
||
await control.InitializeAsync("{}", CancellationToken.None);
|
||
var handle = await control.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
|
||
await control.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||
await control.ShutdownAsync(CancellationToken.None);
|
||
}
|
||
|
||
/// <summary>Waits for <paramref name="more"/> further reads of the fake runtime (a processed-count wait).</summary>
|
||
/// <summary>
|
||
/// #630 — a re-Initialize on a live driver must dispose the alarm projection's poll loop rather
|
||
/// than leave it running (it would keep raising duplicate alarm events through this driver).
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Reinitialize_Stops_The_Previous_Projection_Poll_Loop()
|
||
{
|
||
var factory = new FakeAbCipTagFactory();
|
||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||
|
||
await WaitForTagCreation(factory, "HighTemp");
|
||
var tag = factory.Tags["HighTemp"];
|
||
// Positive control: the loop is demonstrably polling before the re-init.
|
||
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
|
||
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
int Reads() => factory.Tags.Values.Sum(t => t.ReadCount); // the loop may re-create its runtime on the new device state
|
||
var frozen = Reads();
|
||
await Task.Delay(300); // ~15 poll intervals; a live loop would advance the count
|
||
Reads().ShouldBe(frozen);
|
||
|
||
await drv.ShutdownAsync(CancellationToken.None);
|
||
}
|
||
|
||
private static async Task WaitForMoreReadsAsync(FakeAbCipTagFactory factory, string tagName, int more)
|
||
{
|
||
var tag = factory.Tags[tagName];
|
||
var target = tag.ReadCount + more;
|
||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||
while (tag.ReadCount < target && DateTime.UtcNow < deadline) await Task.Delay(5);
|
||
tag.ReadCount.ShouldBeGreaterThanOrEqualTo(target, $"'{tagName}' should have been read {more} more times");
|
||
}
|
||
|
||
private static async Task WaitForEventAsync(List<AlarmEventArgs> events, Func<AlarmEventArgs, bool> match)
|
||
{
|
||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
lock (events) if (events.Any(match)) return;
|
||
await Task.Delay(5);
|
||
}
|
||
throw new TimeoutException("The expected alarm event was never raised.");
|
||
}
|
||
|
||
private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
|
||
{
|
||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
if (factory.Tags.ContainsKey(tagName)) return;
|
||
await Task.Delay(10);
|
||
}
|
||
throw new TimeoutException($"Tag {tagName} was never created by the fake factory.");
|
||
}
|
||
}
|