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; /// /// Issue #272, plan PR F5-a — unit coverage of the /// Production/LastCycleSeconds + Production/LastCycleStartUtc /// derivation. The derivation is a pure function of the /// FocasProductionInfo snapshot stream + wall-clock; these tests pin /// 's contract directly so /// the per-tick semantics stay locked even if the probe-loop wiring changes. /// /// /// The Production/LastCycle* values are surfaced through the same /// fixed-tree dispatcher the F1-b parts-count nodes use (no new wire calls /// — pure derivation). The end-to-end shape is exercised by /// /// which spins up a real + a probe-tick driven /// . /// [Trait("Category", "Unit")] public sealed class FocasCycleDeltaTests { private const string Host = "focas://10.0.0.5:8193"; private static FocasDriver.DeviceState NewState() => new(FocasHostAddress.TryParse(Host)!, new FocasDeviceOptions(Host)); private static FocasProductionInfo Snap(int parts, int cycleTimerSeconds) => new(PartsProduced: parts, PartsRequired: 0, PartsTotal: 0, CycleTimeSeconds: cycleTimerSeconds); [Fact] public void First_observation_establishes_baseline_without_publishing_LastCycle() { var state = NewState(); var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); FocasDriver.UpdateCycleDerivation(state, Snap(parts: 5, cycleTimerSeconds: 10), t0); state.LastCycleSeconds.ShouldBeNull(); state.LastCycleStartUtc.ShouldBeNull(); state.PreviousPartsCount.ShouldBe(5); state.PreviousCycleTimerSeconds.ShouldBe(10.0); } [Fact] public void No_increment_holds_LastCycle_at_null() { var state = NewState(); var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); FocasDriver.UpdateCycleDerivation(state, Snap(5, 12), t0.AddSeconds(2)); // No parts-count transition — derivation has not yet observed a complete cycle // boundary. LastCycle* stays null until the next positive transition. state.LastCycleSeconds.ShouldBeNull(); state.LastCycleStartUtc.ShouldBeNull(); } [Fact] public void First_increment_publishes_timer_delta_across_the_window() { var state = NewState(); var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); // Tick 1: parts=5, timer=10 — baseline. FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); // Tick 2: parts=5, timer=12 — no increment, no publish. FocasDriver.UpdateCycleDerivation(state, Snap(5, 12), t0.AddSeconds(2)); // Tick 3: parts=6, timer=18 — first increment. Delta = 18 - 10 = 8s // (we keep the cycle-timer baseline pinned to the LAST INCREMENT, not the // last sample, so the delta covers the true window between increments). FocasDriver.UpdateCycleDerivation(state, Snap(6, 18), t0.AddSeconds(8)); state.LastCycleSeconds.ShouldBe(8.0); state.LastCycleStartUtc.ShouldNotBeNull(); state.LastCycleStartUtc!.Value.ShouldBe(t0); } [Fact] public void Second_increment_publishes_fresh_delta() { var state = NewState(); var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); FocasDriver.UpdateCycleDerivation(state, Snap(6, 18), t0.AddSeconds(8)); // Tick 4: parts=7, timer=25 — second increment. Delta = 25 - 18 = 7s. FocasDriver.UpdateCycleDerivation(state, Snap(7, 25), t0.AddSeconds(15)); state.LastCycleSeconds.ShouldBe(7.0); state.LastCycleStartUtc!.Value.ShouldBe(t0.AddSeconds(8)); } [Fact] public void Parts_count_reset_preserves_last_known_LastCycle_values() { var state = NewState(); var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); FocasDriver.UpdateCycleDerivation(state, Snap(6, 18), t0.AddSeconds(8)); var publishedSeconds = state.LastCycleSeconds; var publishedStart = state.LastCycleStartUtc; publishedSeconds.ShouldBe(8.0); // Parts-count reset (e.g. shift change) — value goes backwards. The // derivation must NOT publish a negative LastCycleSeconds; instead it // re-baselines so the next positive transition produces a fresh delta. // The previously-published values stay live (operators reading the tag // mid-shift-change see the last known cycle, not Bad / null). FocasDriver.UpdateCycleDerivation(state, Snap(0, 0), t0.AddSeconds(20)); state.LastCycleSeconds.ShouldBe(publishedSeconds); state.LastCycleStartUtc.ShouldBe(publishedStart); state.PreviousPartsCount.ShouldBe(0); state.PreviousCycleTimerSeconds.ShouldBe(0.0); // Next positive transition produces the new delta. FocasDriver.UpdateCycleDerivation(state, Snap(1, 6), t0.AddSeconds(26)); state.LastCycleSeconds.ShouldBe(6.0); } [Fact] public void Cycle_timer_rollover_on_increment_does_not_publish_negative_delta() { var state = NewState(); var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); FocasDriver.UpdateCycleDerivation(state, Snap(5, 18), t0); // Tick 2: parts=6 (positive transition) but timer rolled to 0 (CNC reset // the cycle-time timer at part completion). Per the plan we treat this // as rollover: re-baseline without publishing a negative delta. FocasDriver.UpdateCycleDerivation(state, Snap(6, 0), t0.AddSeconds(1)); state.LastCycleSeconds.ShouldBeNull(); state.LastCycleStartUtc.ShouldBeNull(); // Subsequent increment publishes a clean delta from the post-rollover // baseline. FocasDriver.UpdateCycleDerivation(state, Snap(7, 9), t0.AddSeconds(10)); state.LastCycleSeconds.ShouldBe(9.0); } [Fact] public void Parts_count_jump_publishes_window_timer_delta_without_per_part_division() { var state = NewState(); var t0 = new DateTime(2026, 4, 25, 10, 0, 0, DateTimeKind.Utc); FocasDriver.UpdateCycleDerivation(state, Snap(5, 10), t0); // Backfill: parts jumps 5 -> 8. Per the plan we treat the timer delta // over the window as the most-recent cycle's actual duration; we do NOT // divide by the count delta. FocasDriver.UpdateCycleDerivation(state, Snap(8, 25), t0.AddSeconds(15)); state.LastCycleSeconds.ShouldBe(15.0); state.LastCycleStartUtc!.Value.ShouldBe(t0); } [Fact] public async Task ReinitializeAsync_clears_LastCycle_state() { // ReinitializeAsync = ShutdownAsync + InitializeAsync. ShutdownAsync clears // _devices entirely and InitializeAsync constructs fresh DeviceState // instances, so the F5-a derivation history is reset across reinit per // the plan ("the prior CNC connection's history doesn't apply post- // reconnect"). var fake = new ProductionAwareFakeClient { Production = new FocasProductionInfo(5, 0, 0, 10), }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var driver = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) }, }, "drv-1", factory); await driver.InitializeAsync("{}", CancellationToken.None); // Drive two increments through the probe loop so the device has live // LastCycle* state. await WaitForAsync(() => { var s = driver.GetDeviceState(Host); return Task.FromResult(s?.PreviousPartsCount == 5); }, TimeSpan.FromSeconds(3)); fake.Production = new FocasProductionInfo(6, 0, 0, 18); await WaitForAsync(() => { var s = driver.GetDeviceState(Host); return Task.FromResult(s?.LastCycleSeconds == 8.0); }, TimeSpan.FromSeconds(3)); await driver.ReinitializeAsync("{}", CancellationToken.None); // The operator-visible LastCycle* values reset across the reinit // boundary — the prior CNC session's history doesn't carry over. // Previous*/baseline state may re-populate quickly because the probe // loop restarts immediately after init, but until a NEW post-reinit // increment is observed the published LastCycle* values stay null. var post = driver.GetDeviceState(Host); post.ShouldNotBeNull(); post!.LastCycleSeconds.ShouldBeNull(); post.LastCycleStartUtc.ShouldBeNull(); await driver.ShutdownAsync(CancellationToken.None); } [Fact] public void DeviceState_initial_field_values_are_all_null() { // Direct invariant — fresh DeviceState (constructed during InitializeAsync) // has all derivation history set to null. This is the contract a reinit // relies on (ShutdownAsync clears _devices; InitializeAsync constructs // fresh DeviceState instances). var state = NewState(); state.PreviousPartsCount.ShouldBeNull(); state.PreviousCycleTimerSeconds.ShouldBeNull(); state.PreviousIncrementAtUtc.ShouldBeNull(); state.LastCycleSeconds.ShouldBeNull(); state.LastCycleStartUtc.ShouldBeNull(); } [Fact] public async Task LastCycle_round_trips_through_ReadAsync_after_two_increments() { // End-to-end: a probe-tick-driven snapshot stream into a real FocasDriver // produces Production/LastCycleSeconds + Production/LastCycleStartUtc // through the standard ReadAsync path. No additional wire calls fire — // both nodes are served from DeviceState.LastCycle*. var fake = new ProductionAwareFakeClient { Production = new FocasProductionInfo(5, 0, 0, 10), }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var driver = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(40) }, }, "drv-1", factory); await driver.InitializeAsync("{}", CancellationToken.None); // First increment baseline + flip to second value to produce a delta. await WaitForAsync(() => { var s = driver.GetDeviceState(Host); return Task.FromResult(s?.PreviousPartsCount == 5); }, TimeSpan.FromSeconds(3)); fake.Production = new FocasProductionInfo(6, 0, 0, 18); await WaitForAsync(() => { var s = driver.GetDeviceState(Host); return Task.FromResult(s?.LastCycleSeconds == 8.0); }, TimeSpan.FromSeconds(3)); var refs = new[] { $"{Host}::Production/LastCycleSeconds", $"{Host}::Production/LastCycleStartUtc", }; var snaps = await driver.ReadAsync(refs, CancellationToken.None); snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good); snaps[0].Value.ShouldBe(8.0); snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good); snaps[1].Value.ShouldBeOfType(); await driver.ShutdownAsync(CancellationToken.None); } private static async Task WaitForAsync(Func> condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!await condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } private sealed class ProductionAwareFakeClient : FakeFocasClient, IFocasClient { public FocasProductionInfo? Production { get; set; } Task IFocasClient.GetProductionAsync(CancellationToken ct) => Task.FromResult(Production); } }