@@ -0,0 +1,290 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #272, plan PR F5-a — unit coverage of the
|
||||
/// <c>Production/LastCycleSeconds</c> + <c>Production/LastCycleStartUtc</c>
|
||||
/// derivation. The derivation is a pure function of the
|
||||
/// <c>FocasProductionInfo</c> snapshot stream + wall-clock; these tests pin
|
||||
/// <see cref="FocasDriver.UpdateCycleDerivation"/>'s contract directly so
|
||||
/// the per-tick semantics stay locked even if the probe-loop wiring changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>Production/LastCycle*</c> 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
|
||||
/// <see cref="LastCycle_round_trips_through_ReadAsync_after_two_increments"/>
|
||||
/// which spins up a real <see cref="FocasDriver"/> + a probe-tick driven
|
||||
/// <see cref="FakeFocasClient"/>.
|
||||
/// </remarks>
|
||||
[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<DateTime>();
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> 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<FocasProductionInfo?> IFocasClient.GetProductionAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Production);
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,12 @@ public sealed class FocasProductionFixedTreeTests
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Production" && f.DisplayName == "Production");
|
||||
var prodVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Production/")).ToList();
|
||||
prodVars.Count.ShouldBe(4);
|
||||
string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"];
|
||||
foreach (var name in expected)
|
||||
// Issue #272 (plan PR F5-a) added the two derived telemetry nodes
|
||||
// LastCycleSeconds + LastCycleStartUtc on top of the original 4 F1-b
|
||||
// wire-sourced fields, for 6 total Production/ children per device.
|
||||
prodVars.Count.ShouldBe(6);
|
||||
string[] int32Fields = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"];
|
||||
foreach (var name in int32Fields)
|
||||
{
|
||||
var node = prodVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
@@ -49,6 +52,17 @@ public sealed class FocasProductionFixedTreeTests
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Production/{name}");
|
||||
}
|
||||
// F5-a derived telemetry — LastCycleSeconds is Float64 (sub-second
|
||||
// precision is meaningful at fast cycle times); LastCycleStartUtc is
|
||||
// DateTime UTC, matching the Diagnostics/LastSuccessfulRead surface.
|
||||
var lastSec = prodVars.SingleOrDefault(v => v.BrowseName == "LastCycleSeconds");
|
||||
lastSec.BrowseName.ShouldBe("LastCycleSeconds");
|
||||
lastSec.Info.DriverDataType.ShouldBe(DriverDataType.Float64);
|
||||
lastSec.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
var lastStart = prodVars.SingleOrDefault(v => v.BrowseName == "LastCycleStartUtc");
|
||||
lastStart.BrowseName.ShouldBe("LastCycleStartUtc");
|
||||
lastStart.Info.DriverDataType.ShouldBe(DriverDataType.DateTime);
|
||||
lastStart.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #272, plan PR F5-a — series-level (would-be integration) coverage of
|
||||
/// <c>Production/LastCycleSeconds</c> + <c>Production/LastCycleStartUtc</c>.
|
||||
/// The derivation is pure (no new wire calls — see
|
||||
/// <c>docs/v2/focas-deployment.md</c> § "Derived telemetry") so the
|
||||
/// real-simulator test asserts the existing <c>cnc_rdparam(6711)</c> +
|
||||
/// cycle-timer poll that F1-b already emits drives both fields end-to-end.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Build-only today: focas-mock has not yet shipped (tracked under
|
||||
/// <c>docs/v2/implementation/focas-simulator-plan.md</c> § "Cycle-time per
|
||||
/// part / last cycle delta — F5-a"). The unit-test coverage in
|
||||
/// <see cref="FocasCycleDeltaTests"/> exercises every same-process invariant
|
||||
/// of the derivation. The gated test below materialises the
|
||||
/// <c>SimulateCycleCompletionAsync</c> + admin-endpoint contract once the
|
||||
/// simulator binary lands.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Series")]
|
||||
public sealed class CycleDeltaTests
|
||||
{
|
||||
[Fact]
|
||||
public void Derivation_contract_is_documented()
|
||||
{
|
||||
// Build-only scaffold — see FocasCycleDeltaTests for the actual fake-backed
|
||||
// assertion. The integration version of this test (gated on a focas-mock
|
||||
// simulator with a SimulateCycleCompletionAsync admin endpoint) will:
|
||||
// 1. Spin up FocasDriver pointed at the simulator with parts=5, timer=10s.
|
||||
// 2. Wait for the first probe tick (baseline established).
|
||||
// 3. Call simulator.SimulateCycleCompletionAsync(profile, parts: 6, timer: 18s).
|
||||
// 4. Wait for the next probe tick to refresh the production cache.
|
||||
// 5. Read Production/LastCycleSeconds + Production/LastCycleStartUtc through
|
||||
// the OPC UA surface; assert delta == 8.0 and the timestamp is now - 8s.
|
||||
// The driver-side derivation already locks the contract in unit tests; this
|
||||
// scaffold pins the simulator-side contract for the focas-mock implementor.
|
||||
var info = typeof(FocasDriver).GetMethod(
|
||||
nameof(FocasDriver.UpdateCycleDerivation),
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
info.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact(Skip = "Hardware-gated — requires focas-mock with the SimulateCycleCompletionAsync admin endpoint (focas-simulator-plan.md § 'Cycle-time per part / last cycle delta — F5-a').")]
|
||||
public Task Live_simulator_cycle_completion_round_trip()
|
||||
{
|
||||
// Body deliberately empty — the [Skip] attribute keeps this off the CI
|
||||
// lane. When focas-mock lands the SimulateCycleCompletionAsync helper +
|
||||
// matching admin endpoint, this test materialises a FocasDriver pointed
|
||||
// at the simulator + drives the parts-count 5 -> 6 transition through real
|
||||
// wire calls.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user