docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
@@ -14,8 +14,8 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP-16: Alarm Actor tests — value match, range violation, rate of change.
|
||||
/// WP-21: Alarm on-trigger call direction tests.
|
||||
/// Alarm Actor tests — value match, range violation, rate of change.
|
||||
/// Alarm on-trigger call direction tests.
|
||||
/// </summary>
|
||||
public class AlarmActorTests : TestKit, IDisposable
|
||||
{
|
||||
@@ -23,6 +23,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
|
||||
/// <summary>Initializes the test with a compilation service, shared script library and default runtime options.</summary>
|
||||
public AlarmActorTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
@@ -32,11 +33,13 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
_options = new SiteRuntimeOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-match alarm activates when the value matches.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_ActivatesOnMatch()
|
||||
{
|
||||
@@ -64,6 +67,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal("HighTemp", msg.AlarmName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-match alarm clears when the value no longer matches.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_ClearsOnNonMatch()
|
||||
{
|
||||
@@ -94,6 +98,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Normal, clearMsg.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a computed alarm carries the unified condition state.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_ComputedAlarm_CarriesUnifiedConditionState()
|
||||
{
|
||||
@@ -123,6 +128,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(string.Empty, msg.SourceReference); // no native source reference
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a range-violation alarm activates when the value is outside the range.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_ActivatesOutsideRange()
|
||||
{
|
||||
@@ -152,6 +158,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a range-violation alarm clears when the value is back in range.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_ClearsWhenBackInRange()
|
||||
{
|
||||
@@ -181,6 +188,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Normal, clearMsg.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the alarm actor ignores changes to attributes it does not monitor.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_IgnoresUnmonitoredAttributes()
|
||||
{
|
||||
@@ -205,6 +213,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an alarm that is already active does not trigger again.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_DoesNotReTrigger_WhenAlreadyActive()
|
||||
{
|
||||
@@ -233,6 +242,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the alarm actor starts in the normal state after a restart.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_StartsNormal_OnRestart()
|
||||
{
|
||||
@@ -257,10 +267,11 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no on-clear script runs when the alarm deactivates.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_NoClearScript_OnDeactivation()
|
||||
{
|
||||
// WP-16: On clear, NO script is executed. Only on activate.
|
||||
// On clear, NO script is executed. Only on activate.
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "ClearTest",
|
||||
@@ -320,6 +331,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", ts);
|
||||
|
||||
/// <summary>Verifies that an either-direction rate-of-change alarm activates on a rapid rise.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
|
||||
{
|
||||
@@ -339,6 +351,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an either-direction rate-of-change alarm activates on a rapid fall.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
|
||||
{
|
||||
@@ -356,6 +369,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an either-direction rate-of-change alarm does not activate when the rate is below the threshold.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
|
||||
{
|
||||
@@ -370,6 +384,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a rising rate-of-change alarm ignores falling spikes.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
|
||||
{
|
||||
@@ -384,6 +399,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a falling rate-of-change alarm ignores rising spikes.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
|
||||
{
|
||||
@@ -397,6 +413,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a falling rate-of-change alarm activates on a falling rate.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
|
||||
{
|
||||
@@ -411,6 +428,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a rate-of-change alarm does not activate on a single sample.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
|
||||
{
|
||||
@@ -424,6 +442,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rate-of-change samples that roll out of the window are discarded.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
|
||||
{
|
||||
@@ -442,6 +461,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a rate-of-change alarm clears when the rate drops back.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
|
||||
{
|
||||
@@ -465,6 +485,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
|
||||
// ── Legacy JSON aliases & not-equals prefix ────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a value-match alarm using the legacy attribute and value keys still fires.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
|
||||
{
|
||||
@@ -490,6 +511,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Active, msg.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-match alarm with a not-equals prefix fires when the value differs.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
|
||||
{
|
||||
@@ -521,6 +543,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Normal, clear.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a range-violation alarm using the legacy low and high keys still fires.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
|
||||
{
|
||||
@@ -574,6 +597,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"Good", DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>Verifies that a HiLo alarm enters the High level when the value crosses the Hi setpoint.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
|
||||
{
|
||||
@@ -591,6 +615,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a HiLo alarm escalates to HighHigh when the value climbs past the HiHi setpoint.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
|
||||
{
|
||||
@@ -609,6 +634,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.HighHigh, second.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a HiLo alarm descalates from HighHigh to High when the value drops.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
|
||||
{
|
||||
@@ -626,6 +652,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a HiLo alarm clears to Normal when the value returns to the normal band.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
|
||||
{
|
||||
@@ -643,6 +670,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a HiLo alarm enters Low when the value crosses the Lo setpoint.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
|
||||
{
|
||||
@@ -656,6 +684,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.Low, msg.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a HiLo alarm enters LowLow when the value crosses the LoLo setpoint.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
|
||||
{
|
||||
@@ -669,6 +698,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.LowLow, msg.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a per-setpoint priority overrides the alarm-level priority.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
|
||||
{
|
||||
@@ -688,6 +718,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(900, hiHi.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a setpoint without its own priority falls back to the alarm-level priority.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
|
||||
{
|
||||
@@ -701,6 +732,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(432, msg.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with only HiHi configured a HiLo alarm has no effect in the low range.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
|
||||
{
|
||||
@@ -720,6 +752,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value exactly at the HiHi setpoint resolves to HighHigh.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
|
||||
{
|
||||
@@ -735,6 +768,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a HiLo alarm that stays at the same level emits no redundant state change.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
|
||||
{
|
||||
@@ -752,6 +786,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a HiLo alarm with no setpoints configured never fires.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
|
||||
{
|
||||
@@ -769,6 +804,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
|
||||
// ── HiLo hysteresis ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies that with hysteresis a HiLo alarm stays at HighHigh until the value drops below the deadband.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
|
||||
{
|
||||
@@ -787,6 +823,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that with hysteresis a HiLo alarm drops to High only after the deadband is cleared.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
|
||||
{
|
||||
@@ -803,6 +840,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.High, msg.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Hi deadband prevents a HiLo alarm from flapping.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
|
||||
{
|
||||
@@ -822,6 +860,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that hysteresis on the low side behaves symmetrically to the high side.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
|
||||
{
|
||||
@@ -842,6 +881,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.None, clear.Level);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a per-band message flows through to the alarm state change.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
|
||||
{
|
||||
@@ -863,6 +903,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(string.Empty, clear.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that hysteresis does not delay escalation to a higher level.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
|
||||
{
|
||||
@@ -880,8 +921,9 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
|
||||
}
|
||||
|
||||
// ── M1.5: site event log `alarm` category ──────────────────────────────
|
||||
// ── Site event log `alarm` category ──────────────────────────────
|
||||
|
||||
/// <summary>Verifies that raising an alarm emits an alarm site event.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_Raise_EmitsAlarmSiteEvent()
|
||||
{
|
||||
@@ -918,6 +960,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that raising a low-priority alarm emits a warning-severity alarm site event.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_RaiseLowPriority_EmitsWarningAlarmSiteEvent()
|
||||
{
|
||||
@@ -950,6 +993,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that clearing an alarm emits an info-severity alarm site event.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_Clear_EmitsInfoAlarmSiteEvent()
|
||||
{
|
||||
@@ -986,6 +1030,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a malformed trigger configuration does not crash the alarm actor.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
||||
{
|
||||
@@ -1027,13 +1072,14 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an expression alarm evaluates off the blocking script pool and activates.</summary>
|
||||
[Fact]
|
||||
public void ExpressionAlarm_EvaluatesOffTheBlockingScriptPool_AndActivates()
|
||||
{
|
||||
// WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED.
|
||||
// Retarget of the former assertion, whose sense is deliberately INVERTED.
|
||||
//
|
||||
// P1 moved the evaluation off the actor's dispatcher and onto the dedicated
|
||||
// script-execution scheduler, and this test asserted exactly that. WP3.1 (finding #4)
|
||||
// An earlier change moved the evaluation off the actor's dispatcher and onto the dedicated
|
||||
// script-execution scheduler, and this test asserted exactly that. Later analysis
|
||||
// proved that destination wrong for alarms in particular: sharing the blocking pool
|
||||
// meant an alarm that should raise in milliseconds queued behind blocked script bodies
|
||||
// for an unbounded time, and its 2 s evaluation timeout did not even start ticking
|
||||
@@ -1063,6 +1109,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Active, change.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a fault in the expression evaluation task does not kill the alarm trigger.</summary>
|
||||
[Fact]
|
||||
public void ExpressionEvalTaskFault_DoesNotKillTheAlarmTrigger()
|
||||
{
|
||||
|
||||
+12
-1
@@ -17,7 +17,7 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M5.4 T4 — <c>ParentExecutionId</c> tag-cascade, alarm leg):
|
||||
/// <c>ParentExecutionId</c> tag-cascade, alarm leg:
|
||||
/// an alarm on-trigger script run must chain under the execution whose attribute
|
||||
/// write fired the alarm, and must stay a tree ROOT when the firing value had no
|
||||
/// audited origin.
|
||||
@@ -52,6 +52,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
private readonly TestLocalDb _localDb;
|
||||
private readonly SiteStorageService _storage;
|
||||
|
||||
/// <summary>Initializes the test class with a compilation service, shared script library and a temporary LocalDb-backed site storage.</summary>
|
||||
public AlarmCascadeParentExecutionTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
@@ -64,6 +65,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -127,6 +129,8 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
// Hop 3 — Alarm Actor → AlarmExecutionActor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a script-written value firing an alarm parents the on-trigger run only for observed (not inferred) writes.</summary>
|
||||
/// <param name="inferred">Whether the writer was inferred rather than observed.</param>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -143,6 +147,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
Assert.Equal(inferred ? (Guid?)null : writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value originating from the data connection layer leaves the on-trigger run as a tree root.</summary>
|
||||
[Fact]
|
||||
public void DclOriginatedValue_FiringAlarm_LeavesOnTriggerRunAsATreeRoot()
|
||||
{
|
||||
@@ -159,6 +164,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
Assert.Null(alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a hi/lo alarm entering a band parents its on-trigger run to the writing execution.</summary>
|
||||
[Fact]
|
||||
public void HiLoAlarm_EnteringABand_ParentsOnTriggerRunToTheWritingExecution()
|
||||
{
|
||||
@@ -181,6 +187,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an expression alarm carries the writer captured with the evaluated snapshot.</summary>
|
||||
[Fact]
|
||||
public void ExpressionAlarm_CarriesTheWriterCapturedWithTheEvaluatedSnapshot()
|
||||
{
|
||||
@@ -212,6 +219,8 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
// Hop 1 — ScriptRuntimeContext.SetAttribute stamps the running execution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that <c>SetAttribute</c> stamps the running execution id on the write command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_StampsTheRunningExecutionIdOnTheWriteCommand()
|
||||
{
|
||||
@@ -241,6 +250,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
// Hop 2 — Instance Actor carries the writer onto the published change
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that a static write publishes the writing execution id on the resulting change.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_StaticWrite_PublishesTheWritingExecutionOnTheChange()
|
||||
{
|
||||
@@ -281,6 +291,7 @@ public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||
Assert.Equal(writerExecutionId, published.SourceExecutionId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a write with no audited origin publishes no execution id.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_WriteWithNoAuditedOrigin_PublishesNoExecutionId()
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Final review M6: an alarm on-trigger run's <c>ScriptRuntimeContext</c> was built without a
|
||||
/// Regression: an alarm on-trigger run's <c>ScriptRuntimeContext</c> was built without a
|
||||
/// <c>sourceScript</c>, so the context's <c>_logTarget</c> — which names the script on every
|
||||
/// <c>Log.*</c> line and on the run's trace rows — fell back to the INSTANCE name. The launcher now
|
||||
/// passes the same <c>AlarmActor:{alarmName}</c> source key it already uses for this alarm's site
|
||||
@@ -26,12 +26,27 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable
|
||||
/// <summary>A minimal recording logger — the alarm path hands its own ILogger down to the script logger.</summary>
|
||||
private sealed class RecordingLogger<T> : ILogger<T>
|
||||
{
|
||||
/// <summary>Gets the log entries recorded so far.</summary>
|
||||
public List<Entry> Entries { get; } = [];
|
||||
|
||||
/// <summary>Returns no scope.</summary>
|
||||
/// <typeparam name="TState">The scope state type.</typeparam>
|
||||
/// <param name="state">The scope state.</param>
|
||||
/// <returns>Always null.</returns>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <returns>Always true.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Records the log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The log state type.</typeparam>
|
||||
/// <param name="level">The log level.</param>
|
||||
/// <param name="eventId">The event id.</param>
|
||||
/// <param name="state">The log state.</param>
|
||||
/// <param name="exception">The associated exception, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into a message.</param>
|
||||
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries) Entries.Add(new Entry(level, formatter(state, exception)));
|
||||
@@ -42,8 +57,10 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable
|
||||
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
|
||||
NullLogger<SharedScriptLibrary>.Instance);
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
/// <summary>Verifies that an alarm on-trigger run attributes its log lines to the alarm, not just the instance.</summary>
|
||||
[Fact]
|
||||
public void AlarmOnTriggerRun_AttributesItsLogLinesToTheALARM_NotJustTheInstance()
|
||||
{
|
||||
@@ -87,6 +104,7 @@ public class AlarmScriptLogTargetTests : TestKit, IDisposable
|
||||
},
|
||||
TimeSpan.FromSeconds(15));
|
||||
}
|
||||
/// <summary>Verifies that an alarm run resolves and disposes its scoped integration services.</summary>
|
||||
[Fact]
|
||||
public void SR2_AlarmRunResolvesAndDisposesScopedIntegrationServices()
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// arch-review 2026-08-19 P1-3 — alarm on-trigger runs used to bypass the stuck-script
|
||||
/// Alarm on-trigger runs used to bypass the stuck-script
|
||||
/// watchdog, the script-error health counter, and <see cref="ScriptRunSummaryRecorder"/>
|
||||
/// entirely: <c>ScriptRunLauncher.RunAlarmScriptAsync</c> carried none of the machinery its
|
||||
/// sibling <c>RunScriptAsync</c> (the instance-script path) carries, on the SAME bounded
|
||||
@@ -32,6 +32,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
|
||||
/// <summary>Initializes the test class with a shared script library and a closed watchdog gate.</summary>
|
||||
public AlarmScriptWatchdogTests()
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
@@ -41,6 +42,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
|
||||
AlarmWatchdogHooks.Gate = new SemaphoreSlim(0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Free any still-wedged worker before tearing down, so no test leaves a blocked thread.
|
||||
@@ -99,6 +101,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
|
||||
MaxScriptCallDepth = 10
|
||||
};
|
||||
|
||||
/// <summary>Verifies that a wedged alarm script has its worker detached, a replacement started, and the queue drains once it is freed.</summary>
|
||||
[Fact]
|
||||
public void WedgedAlarmScript_DetachesItsWorker_StartsAReplacement_AndDrainsWhenFreed()
|
||||
{
|
||||
@@ -142,6 +145,7 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
|
||||
Assert.Equal(1, scheduler.MaximumConcurrencyLevel);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an alarm run that timed out at dequeue increments the health collector's script error count.</summary>
|
||||
[Fact]
|
||||
public void AlarmRunTimedOutAtDequeue_IncrementsHealthCollectorScriptErrorCount()
|
||||
{
|
||||
@@ -196,6 +200,8 @@ public class AlarmScriptWatchdogTests : TestKit, IDisposable
|
||||
TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(25));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an alarm run completion is recorded by the script run summary recorder.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmRunCompletion_IsRecordedByScriptRunSummaryRecorder()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
private readonly string _rejectedDir;
|
||||
private readonly OpcUaGlobalOptions _options;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CertStoreActorTests"/> class.</summary>
|
||||
public CertStoreActorTests()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ScadaBridge-CertStoreTests", Guid.NewGuid().ToString("N"));
|
||||
@@ -33,6 +34,7 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -69,6 +71,7 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
return (Convert.ToBase64String(der), cert.Thumbprint);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: write, then List, then Remove, round Trips.</summary>
|
||||
[Fact]
|
||||
public void Write_ThenList_ThenRemove_RoundTrips()
|
||||
{
|
||||
@@ -106,6 +109,7 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: export Local Trusted Certs, returns Thumbprint And Der Bytes.</summary>
|
||||
[Fact]
|
||||
public void ExportLocalTrustedCerts_ReturnsThumbprintAndDerBytes()
|
||||
{
|
||||
@@ -127,6 +131,7 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
entry.DerBytes.Should().Equal(expectedDer);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: write, invalid Base64, returns Failure Ack.</summary>
|
||||
[Fact]
|
||||
public void Write_InvalidBase64_ReturnsFailureAck()
|
||||
{
|
||||
@@ -138,6 +143,7 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
ack.Error.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: remove, missing Cert, is No Op Success.</summary>
|
||||
[Fact]
|
||||
public void Remove_MissingCert_IsNoOpSuccess()
|
||||
{
|
||||
@@ -148,6 +154,7 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
ack.Success.Should().BeTrue("removing an absent cert is an idempotent no-op");
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: write, path Traversal Thumbprint, rejected Without Touching Filesystem.</summary>
|
||||
[Fact]
|
||||
public void Write_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem()
|
||||
{
|
||||
@@ -167,6 +174,7 @@ public class CertStoreActorTests : TestKit, IDisposable
|
||||
"the actor must not write outside the trusted-store directory");
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: remove, path Traversal Thumbprint, rejected Without Touching Filesystem.</summary>
|
||||
[Fact]
|
||||
public void Remove_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem()
|
||||
{
|
||||
|
||||
+22
-4
@@ -67,6 +67,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
private readonly ConcurrentQueue<ReconfigureConnectionCommand> _reconfigures = new();
|
||||
private readonly ConcurrentQueue<RemoveConnectionCommand> _removes = new();
|
||||
|
||||
/// <summary>Creates a temporary LocalDb-backed site storage and the script services the actors need.</summary>
|
||||
public DclConnectionArbitrationTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dcl-arbitration-test");
|
||||
@@ -78,6 +79,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
|
||||
@@ -190,7 +192,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
// ── Rule 1: a definition row outranks an older flattened snapshot ──
|
||||
|
||||
/// <summary>
|
||||
/// <b>THE row-89 reproduction, in its real (failover / restart) shape.</b> The store holds a
|
||||
/// <b>THE reproduction, in its real (failover / restart) shape.</b> The store holds a
|
||||
/// definition row pointing at the new endpoint — put there by a connection-only artifact
|
||||
/// deploy and, on a replicated pair, delivered to this node by LocalDb CDC — while the
|
||||
/// instance's own flattened config still carries the endpoint frozen in at ITS deploy time.
|
||||
@@ -204,6 +206,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// that applied it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DefinitionRow_WinsOverAnOlderFlattenedSnapshot_WhenTheSingletonBoots()
|
||||
{
|
||||
@@ -233,6 +236,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// guarding the flip after the fact.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DisagreeingSources_DoNotPingPongTheConnection_AcrossManySweeps()
|
||||
{
|
||||
@@ -265,6 +269,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
///
|
||||
/// <para>Passes before and after: a deploy-only regression guard.</para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_AtBoot()
|
||||
{
|
||||
@@ -287,6 +292,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
///
|
||||
/// <para>Passes before and after: a deploy-only regression guard for the sweep path.</para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_OnTheSweepPath()
|
||||
{
|
||||
@@ -322,6 +328,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// manager's create-only guard and change nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DefinitionArrivingAfterBoot_IsAdoptedByTheNextSweepTick()
|
||||
{
|
||||
@@ -352,7 +359,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
// ── Row 97: a changed configuration reaches the LIVE connection ──
|
||||
|
||||
/// <summary>
|
||||
/// <b>THE row-97 reproduction on the sender side.</b> A connection the DCL already holds gets
|
||||
/// <b>THE reproduction on the sender side.</b> A connection the DCL already holds gets
|
||||
/// a new endpoint. What must go out is a <see cref="ReconfigureConnectionCommand"/>, which the
|
||||
/// live connection actor applies in place by swapping its adapter and re-issuing its
|
||||
/// subscriptions.
|
||||
@@ -366,6 +373,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// failover, or a remove-then-add.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ChangedConfigurationForALiveConnection_IsSentAsAReconfigure_NotASecondCreate()
|
||||
{
|
||||
@@ -397,6 +405,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// a reconfigure — which drops the link and re-subscribes every tag — is safe to send on the
|
||||
/// change edge.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnchangedConfiguration_SendsNeitherACreateNorAReconfigure()
|
||||
{
|
||||
@@ -423,7 +432,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
// ── Row 90: removal, and not being resurrected by a flattened snapshot ──
|
||||
|
||||
/// <summary>
|
||||
/// <b>THE row-90 reproduction.</b> A definition row this incarnation had adopted disappears
|
||||
/// <b>THE reproduction.</b> A definition row this incarnation had adopted disappears
|
||||
/// from the store — a central delete, reconciled by an artifact deploy on the other node and
|
||||
/// replicated here as a row deletion. The sweep can PROVE that removal (the store owned the
|
||||
/// name a tick ago and does not now), so it stops the live connection actor; and the name is
|
||||
@@ -435,6 +444,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// connection actor stayed live until the node restarted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DefinitionRowDisappearing_StopsTheConnection_AndItIsNotResurrected()
|
||||
{
|
||||
@@ -483,6 +493,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// tick.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ArtifactReconcileRemoval_IsNotResurrectedByALaterInstanceStart()
|
||||
{
|
||||
@@ -533,6 +544,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// stay bad quality until the referencing instance is redeployed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReAddedDefinition_ClearsTheTombstone_SoTheNextInstanceStartRecreatesIt()
|
||||
{
|
||||
@@ -581,6 +593,7 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// without the generation guard the actor removes the connection it created a moment ago.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaleSweepSnapshot_RacingAnArtifactApply_DoesNotRemoveJustAppliedConnections()
|
||||
{
|
||||
@@ -603,10 +616,11 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The row-101 guard must not blunt row 90's legitimate disappearance detection: the same
|
||||
/// The stale-snapshot guard must not blunt the legitimate disappearance detection: the same
|
||||
/// empty snapshot stamped with the CURRENT write generation is a real answer (the store
|
||||
/// genuinely holds no rows any more) and still stops the connection.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FreshEmptySweepSnapshot_StillRemovesDisappearedConnections()
|
||||
{
|
||||
@@ -640,6 +654,10 @@ public class DclConnectionArbitrationTests : TestKit, IDisposable
|
||||
/// </summary>
|
||||
private sealed class DclConnectionSpy : ReceiveActor
|
||||
{
|
||||
/// <summary>Creates the spy that records the connection commands it receives.</summary>
|
||||
/// <param name="creates">Queue that receives create-connection commands.</param>
|
||||
/// <param name="reconfigures">Queue that receives reconfigure-connection commands.</param>
|
||||
/// <param name="removes">Queue that receives remove-connection commands.</param>
|
||||
public DclConnectionSpy(
|
||||
ConcurrentQueue<CreateConnectionCommand> creates,
|
||||
ConcurrentQueue<ReconfigureConnectionCommand> reconfigures,
|
||||
|
||||
+62
-2
@@ -15,7 +15,7 @@ using ZB.MOM.WW.Secrets.Abstractions;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Task 10, site-secrets artifact deploy: <see cref="DeploymentManagerActor.HandleDeployArtifacts"/>
|
||||
/// Site-secrets artifact deploy: <see cref="DeploymentManagerActor.HandleDeployArtifacts"/>
|
||||
/// writes shipped secrets to <c>deployed_secrets</c>, runs <see cref="DeployedSecretsMirror.ReconcileAsync"/>
|
||||
/// synchronously, and answers with <see cref="ArtifactDeploymentResponse.SecretsApplied"/> or a failure.
|
||||
/// Follows the shape of the existing artifact-apply tests in <c>DeploymentManagerActorTests</c>.
|
||||
@@ -27,6 +27,7 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes a new instance of <c>DeploymentManagerActorArtifactSecretsTests</c>.</summary>
|
||||
public DeploymentManagerActorArtifactSecretsTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dm-artifact-secrets-test");
|
||||
@@ -40,6 +41,7 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -120,6 +122,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
UpdatedUtc: new DateTimeOffset(2026, 9, 28, 1, 0, 0, TimeSpan.Zero),
|
||||
Description: "test secret");
|
||||
|
||||
/// <summary>Verifies that secrets null leaves table untouched and secrets applied null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Secrets_Null_LeavesTableUntouched_AndSecretsAppliedNull()
|
||||
{
|
||||
@@ -138,6 +142,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that secrets present full set write sync reconcile secrets applied equals shipped count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Secrets_Present_FullSetWrite_SyncReconcile_SecretsAppliedEqualsShippedCount()
|
||||
{
|
||||
@@ -158,6 +164,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
Assert.True(store.Contains("b"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that secrets empty with mirror secrets applied zero success.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Secrets_Empty_WithMirror_SecretsAppliedZero_Success()
|
||||
{
|
||||
@@ -173,6 +181,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
Assert.Equal(0, response.SecretsApplied);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that secrets dropped reply names the retired secrets.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Secrets_Dropped_ReplyNamesTheRetiredSecrets()
|
||||
{
|
||||
@@ -202,6 +212,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that secrets mirror fails one success false kek id in message secrets applied excludes failure.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Secrets_MirrorFailsOne_SuccessFalse_KekIdInMessage_SecretsAppliedExcludesFailure()
|
||||
{
|
||||
@@ -222,6 +234,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
Assert.Equal(1, response.SecretsApplied); // "good" applied+verified; "bad" failed
|
||||
}
|
||||
|
||||
/// <summary>Verifies that secrets non empty no mirror registered fails deploy.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Secrets_NonEmpty_NoMirrorRegistered_FailsDeploy()
|
||||
{
|
||||
@@ -234,6 +248,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
Assert.Equal("Secret deployment is not enabled on this site node", response.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that secrets empty no mirror registered treated as zero success.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Secrets_Empty_NoMirrorRegistered_TreatedAsZeroSuccess()
|
||||
{
|
||||
@@ -246,6 +262,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
Assert.Equal(0, response.SecretsApplied);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that secrets removal failure does not fail deploy and excluded from error message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Secrets_RemovalFailure_DoesNotFailDeploy_AndExcludedFromErrorMessage()
|
||||
{
|
||||
@@ -275,10 +293,11 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
Assert.Null(r2.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that data connections pushed to dcl only after shipped secrets are mirrored.</summary>
|
||||
[Fact]
|
||||
public void DataConnections_PushedToDcl_OnlyAfterShippedSecretsAreMirrored()
|
||||
{
|
||||
// M2 (final review): a connection whose config references a secret shipped by THIS
|
||||
// A connection whose config references a secret shipped by THIS
|
||||
// deploy must find it on its first connect, so the DCL create must not reach the
|
||||
// DCL manager before the secret is in this node's store.
|
||||
var store = new FakeSecretStore();
|
||||
@@ -310,6 +329,8 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
Assert.True(secretPresentWhenDclSawCreate);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that data connections still stored and pushed when secrets step fails.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DataConnections_StillStoredAndPushed_WhenSecretsStepFails()
|
||||
{
|
||||
@@ -336,19 +357,36 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, StoredSecret> _rows = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>When set, deleting any secret throws.</summary>
|
||||
public bool ThrowOnDelete { get; set; }
|
||||
|
||||
/// <summary>Returns whether the store currently holds a live secret with the given name.</summary>
|
||||
/// <param name="name">The secret name.</param>
|
||||
/// <returns>True when a live secret with that name exists.</returns>
|
||||
public bool Contains(string name) => _rows.TryGetValue(name, out var row) && !row.IsDeleted;
|
||||
|
||||
/// <summary>Returns the stored or configured secret.</summary>
|
||||
/// <param name="name">The secret name.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The secret, or null when absent.</returns>
|
||||
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult(_rows.TryGetValue(name.Value, out var row) ? row : null);
|
||||
|
||||
/// <summary>Stores the row, replacing any existing row of the same name.</summary>
|
||||
/// <param name="row">The secret row.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task UpsertAsync(StoredSecret row, CancellationToken ct)
|
||||
{
|
||||
_rows[row.Name.Value] = row;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes the named secret.</summary>
|
||||
/// <param name="name">The secret name.</param>
|
||||
/// <param name="actor">The acting user.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>True when a live secret was deleted; otherwise false.</returns>
|
||||
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnDelete)
|
||||
@@ -372,12 +410,23 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>Returns an empty metadata list.</summary>
|
||||
/// <param name="includeDeleted">Whether deleted secrets are included.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>An empty list.</returns>
|
||||
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SecretMetadata>>([]);
|
||||
|
||||
/// <summary>Returns an empty manifest.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>An empty manifest.</returns>
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
|
||||
/// <summary>Applies a replicated row when it is newer than the stored one.</summary>
|
||||
/// <param name="row">The replicated row.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
|
||||
{
|
||||
if (_rows.TryGetValue(row.Name.Value, out var existing)
|
||||
@@ -390,6 +439,11 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Reports that no rewrap was applied.</summary>
|
||||
/// <param name="rewrappedRow">The rewrapped row.</param>
|
||||
/// <param name="expectedCurrentWrappedDek">The wrapped key the row is expected to hold.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>Always false.</returns>
|
||||
public Task<bool> ApplyRewrapAsync(
|
||||
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
|
||||
Task.FromResult(false);
|
||||
@@ -399,8 +453,14 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly HashSet<string> _throwing = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Throws the configured fault when the named secret is requested.</summary>
|
||||
/// <param name="name">The secret name that should fail.</param>
|
||||
public void ThrowFor(string name) => _throwing.Add(name);
|
||||
|
||||
/// <summary>Returns the stored or configured secret.</summary>
|
||||
/// <param name="name">The secret name.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The secret, or null when absent.</returns>
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
||||
{
|
||||
if (_throwing.Contains(name.Value))
|
||||
|
||||
+91
-14
@@ -32,6 +32,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test with a temporary SQLite storage and an in-memory shared script library.</summary>
|
||||
public DeploymentManagerActorTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dm-test");
|
||||
@@ -45,6 +46,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -94,7 +96,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
/// Builds a config carrying a single STATIC List attribute whose canonical
|
||||
/// JSON-array default the Instance Actor decodes into a typed <c>List<int></c>
|
||||
/// in memory (InstanceActor.PreStart / DecodeAttributeValue). Used to drive a
|
||||
/// routed wait whose matched value is a collection — the shape WS-4 normalizes
|
||||
/// routed wait whose matched value is a collection — the shape that is normalized
|
||||
/// for cross-process transport.
|
||||
/// </summary>
|
||||
private static string MakeConfigWithListAttributeJson(string instanceName)
|
||||
@@ -118,7 +120,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
/// Builds a config carrying BOTH a static List attribute (whose JSON-array default
|
||||
/// the Instance Actor decodes into a typed <c>List<int></c>) and a static scalar
|
||||
/// attribute. Used to drive a routed <see cref="RouteToGetAttributesRequest"/> that
|
||||
/// exercises #162: the List value must be normalized for cross-process transport,
|
||||
/// exercises list normalization: the List value must be normalized for cross-process transport,
|
||||
/// while the scalar must pass through the normalizer unchanged.
|
||||
/// </summary>
|
||||
private static string MakeConfigWithListAndScalarAttributesJson(string instanceName)
|
||||
@@ -161,6 +163,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the deployment manager creates instance actors from the stored configurations.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs()
|
||||
{
|
||||
@@ -182,6 +186,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Pump1", response.InstanceUniqueName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the deployment manager skips disabled instances on startup.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentManager_SkipsDisabledInstances_OnStartup()
|
||||
{
|
||||
@@ -200,6 +206,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.True(response.Success);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that instance actors are created in staggered batches.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentManager_StaggeredBatchCreation()
|
||||
{
|
||||
@@ -226,6 +234,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a deploy creates a new instance.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentManager_Deploy_CreatesNewInstance()
|
||||
{
|
||||
@@ -251,6 +261,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Scripts = [new ResolvedScript { CanonicalName = "S1", Code = "this is not C# ~~~" }]
|
||||
});
|
||||
|
||||
/// <summary>Verifies that a deploy with a non-compiling script reports failure and creates no instance actor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deploy_WithNonCompilingScript_ReportsFailed_AndCreatesNoInstanceActor()
|
||||
{
|
||||
@@ -277,6 +289,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
|
||||
// ── S5: startup deployed-config load is retried on transient failure ──
|
||||
|
||||
/// <summary>Verifies that a startup load failure is retried until it succeeds.</summary>
|
||||
[Fact]
|
||||
public void StartupLoadFailure_IsRetried_UntilSuccess()
|
||||
{
|
||||
@@ -318,8 +331,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
// ── M1.6: site event log `deployment` category ─────────────────────────
|
||||
// ── Site event log `deployment` category ─────────────────────────
|
||||
|
||||
/// <summary>Verifies that a successful deploy emits a deployment site event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentManager_DeploySuccess_EmitsDeploymentSiteEvent()
|
||||
{
|
||||
@@ -345,6 +360,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disable, enable and delete each emit a deployment site event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentManager_DisableEnableDelete_EmitDeploymentSiteEvents()
|
||||
{
|
||||
@@ -382,6 +399,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an instance can be disabled, enabled and deleted through its lifecycle.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentManager_Lifecycle_DisableEnableDelete()
|
||||
{
|
||||
@@ -421,8 +440,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "LifecyclePump");
|
||||
}
|
||||
|
||||
// ── DeploymentManager-006: query-the-site-before-redeploy ──
|
||||
// ── Query-the-site-before-redeploy ──
|
||||
|
||||
/// <summary>Verifies that a state query for a deployed instance returns its applied identity.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentStateQuery_DeployedInstance_ReturnsAppliedIdentity()
|
||||
{
|
||||
@@ -444,6 +465,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal("sha256:applied", response.AppliedRevisionHash);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a state query for an unknown instance reports it as not deployed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploymentStateQuery_UnknownInstance_ReturnsNotDeployed()
|
||||
{
|
||||
@@ -461,6 +484,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Null(response.AppliedRevisionHash);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the supervision strategy resumes a child on an exception.</summary>
|
||||
[Fact]
|
||||
public void DeploymentManager_SupervisionStrategy_ResumesOnException()
|
||||
{
|
||||
@@ -475,8 +499,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal(DeploymentStatus.Success, response.Status);
|
||||
}
|
||||
|
||||
// ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ──
|
||||
// ── ParentExecutionId: inbound-API routing ──
|
||||
|
||||
/// <summary>Verifies that an inbound API call carrying a parent execution id is routed to the script successfully.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully()
|
||||
{
|
||||
@@ -504,6 +530,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal(7, Convert.ToInt32(response.ReturnValue));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an inbound API call without a parent execution id is still routed.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes()
|
||||
{
|
||||
@@ -530,6 +558,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
|
||||
// ── Spec §6 (WD-2b): routed RouteToWaitForAttributeRequest → InstanceActor ──
|
||||
|
||||
/// <summary>Verifies that a routed wait replies matched when the attribute is already at the target value.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteInboundApiWaitForAttribute_AttributeAlreadyAtTarget_RepliesMatched()
|
||||
{
|
||||
@@ -562,10 +592,12 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the require-good-quality flag is passed through to the instance actor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteInboundApiWaitForAttribute_RequireGoodQuality_IsThreadedToInstanceActor()
|
||||
{
|
||||
// #14 (full fidelity): the additive RouteToWaitForAttributeRequest.RequireGoodQuality
|
||||
// Full fidelity: the additive RouteToWaitForAttributeRequest.RequireGoodQuality
|
||||
// flag must reach the InstanceActor's wait. A static attribute is Good quality, so a
|
||||
// quality-gated wait whose target equals the current value still matches — proving the
|
||||
// flag threads through (and does not spuriously reject a Good value).
|
||||
@@ -591,6 +623,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed wait for an unknown instance replies not found.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteInboundApiWaitForAttribute_UnknownInstance_RepliesNotFound()
|
||||
{
|
||||
@@ -612,10 +646,12 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed wait matching a list value returns a serializer-safe value.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteInboundApiWaitForAttribute_ListValuedMatch_ReturnsSerializerSafeValue()
|
||||
{
|
||||
// WS-4: a routed wait whose matched attribute is a List-typed value comes
|
||||
// A routed wait whose matched attribute is a List-typed value comes
|
||||
// back from the Instance Actor as a concrete generic List<int> — a shape the
|
||||
// cross-process (Newtonsoft) serializer cannot reliably round-trip, which would
|
||||
// otherwise silently drop the reply and hang the caller's Ask. The handler must
|
||||
@@ -658,15 +694,17 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
list.Select(e => Convert.ToDouble(e, System.Globalization.CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
// ── #162: routed RouteToGetAttributesRequest → InstanceActor (List normalization) ──
|
||||
// ── Routed RouteToGetAttributesRequest → InstanceActor (List normalization) ──
|
||||
|
||||
/// <summary>Verifies that a routed get-attributes read returns a serializer-safe list value and leaves scalar values unchanged.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteInboundApiGetAttributes_ListValuedAttribute_ReturnsSerializerSafeValueAndScalarUnchanged()
|
||||
{
|
||||
// #162: a routed GetAttributes read of a List-typed static attribute comes back
|
||||
// A routed GetAttributes read of a List-typed static attribute comes back
|
||||
// from the Instance Actor as a concrete generic List<int> — the SAME non-primitive
|
||||
// shape the cross-process (Newtonsoft) serializer cannot reliably round-trip that
|
||||
// WS-4 fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and
|
||||
// was already fixed for WaitForAttribute. Un-normalized, the reply is silently dropped and
|
||||
// the caller's Route.To().GetAttributes() Ask hangs to timeout. The handler must
|
||||
// project EACH value through NormalizeRoutedReturnValue so the List survives the
|
||||
// wire — while leaving scalar/string/null values unchanged (no regression).
|
||||
@@ -714,6 +752,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Null(response.Values["DoesNotExist"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a routed get-attributes read for an unknown instance replies not found.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteInboundApiGetAttributes_UnknownInstance_RepliesNotFound()
|
||||
{
|
||||
@@ -734,8 +774,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── M2.11: Debug-view routing — unknown-instance not-found signal ──
|
||||
// ── Debug-view routing — unknown-instance not-found signal ──
|
||||
|
||||
/// <summary>Verifies that a debug snapshot request for an unknown instance sets the instance-not-found flag.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteDebugSnapshot_UnknownInstance_SetsInstanceNotFound()
|
||||
{
|
||||
@@ -755,6 +797,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Empty(snapshot.AlarmStates);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a debug view subscribe for an unknown instance sets the instance-not-found flag.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteDebugViewSubscribe_UnknownInstance_SetsInstanceNotFound()
|
||||
{
|
||||
@@ -772,6 +816,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Empty(snapshot.AlarmStates);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a debug snapshot for a known but empty instance does not set the instance-not-found flag.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteDebugSnapshot_KnownButEmptyInstance_DoesNotSetInstanceNotFound()
|
||||
{
|
||||
@@ -794,8 +840,10 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
"A deployed (but empty) instance must NOT set InstanceNotFound.");
|
||||
}
|
||||
|
||||
// ── Task 10: notify-and-fetch — RefreshDeploymentCommand → fetch → apply ──
|
||||
// ── Notify-and-fetch — RefreshDeploymentCommand → fetch → apply ──
|
||||
|
||||
/// <summary>Verifies that a refresh whose fetch succeeds applies the configuration and replies success.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RefreshDeployment_FetchSucceeds_AppliesConfigAndRepliesSuccess()
|
||||
{
|
||||
@@ -825,6 +873,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Contains(configs, c => c.InstanceUniqueName == "FetchedPump");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a refresh whose fetch fails replies failed and applies nothing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RefreshDeployment_FetchFails_RepliesFailedAndDoesNotApply()
|
||||
{
|
||||
@@ -852,6 +902,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "UnfetchedPump");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a refresh with no fetcher replies failed.</summary>
|
||||
[Fact]
|
||||
public void RefreshDeployment_NullFetcher_RepliesFailed()
|
||||
{
|
||||
@@ -868,6 +919,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Pump", response.InstanceUniqueName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the refresh reply goes to the original sender across the asynchronous fetch.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RefreshDeployment_ReplyGoesToOriginalSender_AcrossAsyncFetch()
|
||||
{
|
||||
@@ -889,13 +942,15 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal("SenderPump", response.InstanceUniqueName);
|
||||
}
|
||||
|
||||
// ── LocalDb Phase 2 / Task 12: the active-node central-only purge ──
|
||||
// ── LocalDb Phase 2: the active-node central-only purge ──
|
||||
|
||||
/// <summary>Verifies that applying artifacts purges the central-only notification configuration.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyingArtifacts_PurgesCentralOnlyNotificationConfig()
|
||||
{
|
||||
// Security cleanup. notification_lists and smtp_configurations can hold plaintext
|
||||
// SMTP passwords written by a pre-2026-07-10 build, and the ACTIVE node's artifact
|
||||
// SMTP passwords written by a build older than 2026-07-10, and the ACTIVE node's artifact
|
||||
// apply is what clears them (DeploymentManagerActor.HandleDeployArtifacts). The
|
||||
// standby used to hold a second copy of this call in SiteReplicationActor; LocalDb
|
||||
// Phase 2 deleted that actor, so this is now the ONLY call site that keeps the
|
||||
@@ -1004,6 +1059,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
/// policy was dropped on the floor here and every cached call fell back to the engine's
|
||||
/// 50 x 30 s default.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyingArtifacts_PersistsExternalSystemRetryPolicy()
|
||||
{
|
||||
@@ -1026,6 +1082,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal(45_000, reader.GetInt64(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that applying artifacts deletes external systems that are absent from the set.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyingArtifacts_DeletesExternalSystemsAbsentFromTheSet()
|
||||
{
|
||||
@@ -1049,6 +1107,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal(1, await RowCountAsync("external_systems"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that applying artifacts with a null set touches nothing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyingArtifacts_NullSet_TouchesNothing()
|
||||
{
|
||||
@@ -1067,6 +1127,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal(1, await RowCountAsync("external_systems"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a removed shared script is unregistered from the library.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyingArtifacts_RemovedSharedScript_IsUnregisteredFromLibrary()
|
||||
{
|
||||
@@ -1093,6 +1155,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.False(_sharedScriptLibrary.Contains("GoneScript"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a removed data connection stops its live connection in the data connection layer.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApplyingArtifacts_RemovedDataConnection_StopsLiveDclConnection()
|
||||
{
|
||||
@@ -1134,13 +1198,26 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
private readonly string? _result;
|
||||
private readonly Exception? _error;
|
||||
|
||||
/// <summary>Initializes the fetcher to return the given configuration JSON.</summary>
|
||||
/// <param name="result">The configuration JSON returned from every fetch.</param>
|
||||
public FakeConfigFetcher(string result) => _result = result;
|
||||
/// <summary>Initializes the fetcher to throw the given exception.</summary>
|
||||
/// <param name="error">The exception thrown from every fetch.</param>
|
||||
public FakeConfigFetcher(Exception error) => _error = error;
|
||||
|
||||
/// <summary>Gets the central base URL passed to the most recent fetch.</summary>
|
||||
public string? LastBaseUrl { get; private set; }
|
||||
/// <summary>Gets the deployment id passed to the most recent fetch.</summary>
|
||||
public string? LastDeploymentId { get; private set; }
|
||||
/// <summary>Gets the token passed to the most recent fetch.</summary>
|
||||
public string? LastToken { get; private set; }
|
||||
|
||||
/// <summary>Records the request arguments and returns the canned JSON or throws the canned exception.</summary>
|
||||
/// <param name="centralFetchBaseUrl">The central base URL being fetched from.</param>
|
||||
/// <param name="deploymentId">The deployment id being fetched.</param>
|
||||
/// <param name="token">The deployment token.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task yielding the canned configuration JSON.</returns>
|
||||
public async Task<string> FetchAsync(
|
||||
string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
|
||||
{
|
||||
|
||||
+5
@@ -49,6 +49,7 @@ akka {
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Creates the temporary local database, storage service and script services the deployment manager needs.</summary>
|
||||
public DeploymentManagerCertReconcileTests() : base(ClusterConfig, "dm-cert-reconcile")
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dm-cert-test");
|
||||
@@ -61,6 +62,7 @@ akka {
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -74,6 +76,8 @@ akka {
|
||||
/// <summary>Forwards every message it receives to a probe, preserving the original sender.</summary>
|
||||
private sealed class ForwardingActor : ReceiveActor
|
||||
{
|
||||
/// <summary>Creates a forwarder that sends every message it receives to the given target.</summary>
|
||||
/// <param name="target">The actor that receives the forwarded messages.</param>
|
||||
public ForwardingActor(IActorRef target) => ReceiveAny(msg => target.Forward(msg));
|
||||
}
|
||||
|
||||
@@ -82,6 +86,7 @@ akka {
|
||||
_storage, _compilationService, _sharedScriptLibrary,
|
||||
null, new SiteRuntimeOptions(), NullLogger<DeploymentManagerActor>.Instance)));
|
||||
|
||||
/// <summary>Verifies that when a site node joins, the local trusted certificates are pushed to the joined node.</summary>
|
||||
[Fact]
|
||||
public void SiteNodeJoined_PushesLocalTrustedCertsToJoinedNode()
|
||||
{
|
||||
|
||||
+11
-1
@@ -14,7 +14,7 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for SiteRuntime-015 — <see cref="DeploymentManagerActor"/> must
|
||||
/// Regression test: <see cref="DeploymentManagerActor"/> must
|
||||
/// reuse a single, injected <see cref="ILoggerFactory"/> for every Instance Actor it
|
||||
/// creates rather than newing (and leaking) a fresh <see cref="LoggerFactory"/> per
|
||||
/// instance.
|
||||
@@ -26,6 +26,7 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test class with an actor system.</summary>
|
||||
public DeploymentManagerLoggerFactoryTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dm-loggerfactory-test");
|
||||
@@ -39,6 +40,7 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -72,16 +74,24 @@ public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
|
||||
public int CreateLoggerCalls;
|
||||
public bool Disposed;
|
||||
|
||||
/// <summary>Counts the call and returns a null logger.</summary>
|
||||
/// <param name="categoryName">The logger category name.</param>
|
||||
/// <returns>A logger that discards entries.</returns>
|
||||
public ILogger CreateLogger(string categoryName)
|
||||
{
|
||||
Interlocked.Increment(ref CreateLoggerCalls);
|
||||
return NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Ignores the provider.</summary>
|
||||
/// <param name="provider">The provider (ignored).</param>
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
/// <summary>Records that the factory was disposed.</summary>
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that creating instance actors reuses the injected logger factory for every instance.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance()
|
||||
{
|
||||
|
||||
+14
-7
@@ -15,8 +15,8 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the Medium-severity DeploymentManagerActor findings:
|
||||
/// SiteRuntime-005 (Success reported before persistence completes) and
|
||||
/// SiteRuntime-008 (blocking shared-script load on the actor thread).
|
||||
/// success reported before persistence completes, and
|
||||
/// blocking shared-script load on the actor thread.
|
||||
/// </summary>
|
||||
public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
{
|
||||
@@ -24,6 +24,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>DeploymentManagerMediumFindingsTests</c> class.</summary>
|
||||
public DeploymentManagerMediumFindingsTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dm-medium-test");
|
||||
@@ -33,6 +34,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -98,11 +100,12 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-005: when SQLite persistence of the deployed config fails, the
|
||||
/// When SQLite persistence of the deployed config fails, the
|
||||
/// Deployment Manager must report <see cref="DeploymentStatus.Failed"/> to central,
|
||||
/// not <see cref="DeploymentStatus.Success"/>. Reporting Success on a persistence
|
||||
/// failure silently loses the deployment on the next restart/failover.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deploy_PersistenceFailure_ReportsFailedNotSuccess()
|
||||
{
|
||||
@@ -140,10 +143,11 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-005: a successful deployment must still report
|
||||
/// A successful deployment must still report
|
||||
/// <see cref="DeploymentStatus.Success"/>, and only after the config row is
|
||||
/// committed to SQLite (so a restart re-creates the instance).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deploy_Success_ReportsSuccessAndPersistsConfig()
|
||||
{
|
||||
@@ -165,7 +169,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-010: when a redeployment changes a connection's configuration
|
||||
/// When a redeployment changes a connection's configuration
|
||||
/// (here the failover retry count and endpoint), the Deployment Manager must push the new
|
||||
/// configuration to the DCL rather than leaving it serving the stale one.
|
||||
///
|
||||
@@ -177,6 +181,7 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
/// which the live connection actor actually applies in place.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureDclConnections_ConnectionConfigChanged_ReissuesReconfigureCommand()
|
||||
{
|
||||
@@ -214,9 +219,10 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-010: an unchanged connection configuration must still be skipped —
|
||||
/// An unchanged connection configuration must still be skipped —
|
||||
/// re-sending an identical create command on every deploy is wasteful.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureDclConnections_UnchangedConfig_DoesNotReissueCreateCommand()
|
||||
{
|
||||
@@ -245,10 +251,11 @@ public class DeploymentManagerMediumFindingsTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-008: startup must not block the Deployment Manager mailbox on a
|
||||
/// Startup must not block the Deployment Manager mailbox on a
|
||||
/// synchronous shared-script load. With shared scripts present, the actor must
|
||||
/// still load deployed configs, create Instance Actors, and remain responsive.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Startup_WithSharedScripts_LoadsConfigsAndStaysResponsive()
|
||||
{
|
||||
|
||||
+44
-8
@@ -17,7 +17,7 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for SiteRuntime-003: redeployment of an existing instance must
|
||||
/// Regression tests: redeployment of an existing instance must
|
||||
/// wait for the terminating Instance Actor before recreating the child, instead of
|
||||
/// relying on a fixed 500 ms reschedule that can collide on the child actor name.
|
||||
/// </summary>
|
||||
@@ -28,6 +28,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes a temporary LocalDb, site storage and script compilation services for the tests.</summary>
|
||||
public DeploymentManagerRedeployTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dm-redeploy-test");
|
||||
@@ -41,6 +42,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -95,25 +97,45 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
/// </summary>
|
||||
private sealed class CountCapturingHealthCollector : ISiteHealthCollector
|
||||
{
|
||||
/// <summary>Gets the most recently reported deployed-instance count.</summary>
|
||||
public int LastDeployedCount { get; private set; }
|
||||
/// <inheritdoc />
|
||||
public void IncrementScriptError() { }
|
||||
/// <inheritdoc />
|
||||
public void IncrementAlarmError() { }
|
||||
/// <inheritdoc />
|
||||
public void IncrementDeadLetter() { }
|
||||
/// <inheritdoc />
|
||||
public void IncrementSiteAuditWriteFailures() { }
|
||||
/// <inheritdoc />
|
||||
public void IncrementAuditRedactionFailure() { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateSiteAuditBacklog(ZB.MOM.WW.ScadaBridge.Commons.Types.SiteAuditBacklogSnapshot snapshot) { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { }
|
||||
/// <inheritdoc />
|
||||
public void RemoveConnection(string connectionName) { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateConnectionEndpoint(string connectionName, string endpoint) { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { }
|
||||
/// <inheritdoc />
|
||||
public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { }
|
||||
/// <inheritdoc />
|
||||
public void SetInstanceCounts(int deployed, int enabled, int disabled) => LastDeployedCount = deployed;
|
||||
/// <inheritdoc />
|
||||
public void SetParkedMessageCount(int count) { }
|
||||
/// <inheritdoc />
|
||||
public void SetNodeHostname(string hostname) { }
|
||||
/// <inheritdoc />
|
||||
public void SetClusterNodes(IReadOnlyList<NodeStatus> nodes) { }
|
||||
/// <inheritdoc />
|
||||
public void SetActiveNode(bool isActive) { }
|
||||
/// <inheritdoc />
|
||||
public bool IsActiveNode => true;
|
||||
/// <inheritdoc />
|
||||
public SiteHealthReport CollectReport(string siteId) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -130,6 +152,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
return JsonSerializer.Serialize(config);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that redeploying an existing instance succeeds without a child actor name collision.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Redeploy_ExistingInstance_SucceedsWithoutNameCollision()
|
||||
{
|
||||
@@ -155,10 +179,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
Assert.True(disable.Success);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that three rapid deploys do not throw an invalid-actor-name exception and the latest deploy wins.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SR020_ThreeRapidDeploys_DoNotThrowInvalidActorNameException_LatestWins()
|
||||
{
|
||||
// Regression test for SiteRuntime-020. The previous implementation tracked
|
||||
// Regression test: the previous implementation tracked
|
||||
// pending redeploys by IActorRef (_pendingRedeploys) but had no
|
||||
// name-keyed shadow, so a third DeployInstanceCommand arriving WHILE the
|
||||
// first redeploy's predecessor was still terminating saw
|
||||
@@ -184,7 +210,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
// Two rapid redeploys before the predecessor has time to fully terminate.
|
||||
// The second deploy stops the actor (watching it) and buffers itself.
|
||||
// The third deploy arrives almost immediately and must NOT crash — it
|
||||
// overwrites the buffered pending command and tells dep-2 it was superseded.
|
||||
// overwrites the buffered pending command and tells the first deployment it was superseded.
|
||||
var probe2 = CreateTestProbe();
|
||||
var probe3 = CreateTestProbe();
|
||||
|
||||
@@ -195,7 +221,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
"dep-3", "RapidPump", "h3", MakeConfigJson("RapidPump"), "admin", DateTimeOffset.UtcNow),
|
||||
probe3.Ref);
|
||||
|
||||
// dep-2 must be told it was superseded; dep-3 must succeed once the
|
||||
// The first deployment must be told it was superseded; the second must succeed once the
|
||||
// predecessor finishes terminating.
|
||||
var superseded = probe2.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("dep-2", superseded.DeploymentId);
|
||||
@@ -214,10 +240,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
Assert.True(disable.Success);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a delete during a pending redeploy leaves the instance deleted and the deployed count correct.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SR029_DeleteDuringPendingRedeploy_InstanceStaysDeleted_AndCounterIsCorrect()
|
||||
{
|
||||
// Regression test for SiteRuntime-029. A delete arriving WHILE a redeploy is
|
||||
// Regression test: a delete arriving WHILE a redeploy is
|
||||
// still terminating used to: (1) over-decrement _totalDeployedCount, and
|
||||
// (2) leave the buffered _pendingRedeploys entry intact — so when Terminated
|
||||
// fired, HandleTerminated called ApplyDeployment(isRedeploy: true) and
|
||||
@@ -241,7 +269,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
// singleton mailbox: HandleDeploy runs first (removes from _instanceActors,
|
||||
// watches + stops the predecessor, buffers the redeploy, sets the terminating
|
||||
// shadow), then HandleDelete runs while the predecessor is still terminating
|
||||
// (Terminated has not fired) — exactly the SiteRuntime-029 window.
|
||||
// (Terminated has not fired) — exactly the delete-during-redeploy window.
|
||||
var redeployProbe = CreateTestProbe();
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-2", "RaceTarget", "h2", MakeConfigJson("RaceTarget"), "admin", DateTimeOffset.UtcNow),
|
||||
@@ -270,10 +298,12 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
Assert.Equal(0, health.LastDeployedCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deleting a disabled instance decrements the deployed count.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SR032_DeleteDisabledInstance_DecrementsDeployedCount()
|
||||
{
|
||||
// Regression test for SiteRuntime-032. SiteRuntime-029 gated the deployed-count
|
||||
// Regression test: the earlier delete-during-redeploy fix gated the deployed-count
|
||||
// decrement on the instance being present in _instanceActors OR mid-redeploy in
|
||||
// _terminatingActorsByName. A DISABLED instance is in NEITHER map (disable removes
|
||||
// it from _instanceActors and never adds it to the terminating shadow) yet still has
|
||||
@@ -300,7 +330,7 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
Assert.Equal(1, health.LastDeployedCount);
|
||||
|
||||
// Delete the DISABLED instance → the deployed count must return to 0.
|
||||
// (The SiteRuntime-029 regression left it stuck at 1.)
|
||||
// (The delete-during-redeploy regression left it stuck at 1.)
|
||||
actor.Tell(new DeleteInstanceCommand("del-1", "DisablePump", DateTimeOffset.UtcNow));
|
||||
var delete = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(delete.Success);
|
||||
@@ -311,6 +341,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "DisablePump");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that redeploying an existing instance does not over-count deployed instances.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Redeploy_ExistingInstance_DoesNotOverCountDeployedInstances()
|
||||
{
|
||||
@@ -344,6 +376,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
|
||||
// ── S6: an Instance Actor that dies during init must not linger as a dead ref ──
|
||||
|
||||
/// <summary>Verifies that an Instance Actor init failure at startup evicts the dead reference and logs a site event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActorInitFailure_OnStartup_EvictsDeadRefAndLogsSiteEvent()
|
||||
{
|
||||
@@ -367,6 +401,8 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a deploy whose actor dies during initialization reports failure rather than success.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Deploy_WhoseActorDiesDuringInit_ReportsFailed_NotSuccess()
|
||||
{
|
||||
|
||||
+8
@@ -27,6 +27,7 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Creates a temporary LocalDb-backed site storage and the script services the actors need.</summary>
|
||||
public DeploymentManagerRouteTraceTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dm-route-trace-test");
|
||||
@@ -36,6 +37,7 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -67,6 +69,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
|
||||
return actor;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced route call returns the child execution id and the inline trace events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteCall_WithTrace_ReturnsChildExecutionId_AndInlineTraceEvents()
|
||||
{
|
||||
@@ -92,6 +96,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
|
||||
Assert.All(rows, r => Assert.Equal(response.ChildExecutionId, r.ExecutionId));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced route call returns the execution id but no trace events.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteCall_WithoutTrace_ReturnsExecutionId_ButNoTraceEvents()
|
||||
{
|
||||
@@ -105,6 +111,8 @@ public class DeploymentManagerRouteTraceTests : TestKit, IDisposable
|
||||
Assert.Null(response.TraceEvents);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that routing a set-attributes request forwards the trace flag on the command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RouteSetAttributes_ForwardsTraceFlag_OnTheCommand()
|
||||
{
|
||||
|
||||
+34
-4
@@ -15,12 +15,12 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 test group 9 — warm-then-gate deploys and startup batch pre-warm.
|
||||
/// Warm-then-gate deploys and startup batch pre-warm.
|
||||
///
|
||||
/// <para>The site-side compile gate (S3) must stay synchronous on the Deployment Manager's
|
||||
/// thread, because redeploy-supersede and delete-during-redeploy both depend on strict mailbox
|
||||
/// FIFO. But the Roslyn compile it performs used to hold the singleton for the whole
|
||||
/// compilation, stalling every OTHER instance's commands behind one instance's scripts. WP3.1
|
||||
/// compilation, stalling every OTHER instance's commands behind one instance's scripts. The new flow
|
||||
/// warms the compile off-thread first and then re-runs the gate as pure cache hits, with a
|
||||
/// per-instance in-flight guard preserving same-instance ordering.</para>
|
||||
///
|
||||
@@ -44,6 +44,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="DeploymentWarmThenGateTests"/> class.</summary>
|
||||
public DeploymentWarmThenGateTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dm-warm-gate-test");
|
||||
@@ -56,6 +57,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -95,6 +97,7 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
{
|
||||
private int _reports;
|
||||
|
||||
/// <summary>Gets the deployed instance count from the most recent <c>SetInstanceCounts</c> call.</summary>
|
||||
public int LastDeployedCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -107,29 +110,48 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
/// value and proves nothing about the manager having started at all.
|
||||
/// </summary>
|
||||
public int ReportCount => Volatile.Read(ref _reports);
|
||||
/// <inheritdoc />
|
||||
public void IncrementScriptError() { }
|
||||
/// <inheritdoc />
|
||||
public void IncrementAlarmError() { }
|
||||
/// <inheritdoc />
|
||||
public void IncrementDeadLetter() { }
|
||||
/// <inheritdoc />
|
||||
public void IncrementSiteAuditWriteFailures() { }
|
||||
/// <inheritdoc />
|
||||
public void IncrementAuditRedactionFailure() { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateSiteAuditBacklog(Commons.Types.SiteAuditBacklogSnapshot snapshot) { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateConnectionHealth(string connectionName, ConnectionHealth health) { }
|
||||
/// <inheritdoc />
|
||||
public void RemoveConnection(string connectionName) { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateTagResolution(string connectionName, int totalSubscribed, int successfullyResolved) { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateConnectionEndpoint(string connectionName, string endpoint) { }
|
||||
/// <inheritdoc />
|
||||
public void UpdateTagQuality(string connectionName, int good, int bad, int uncertain) { }
|
||||
/// <inheritdoc />
|
||||
public void SetStoreAndForwardDepths(IReadOnlyDictionary<string, int> depths) { }
|
||||
/// <inheritdoc />
|
||||
public void SetInstanceCounts(int deployed, int enabled, int disabled)
|
||||
{
|
||||
LastDeployedCount = deployed;
|
||||
Interlocked.Increment(ref _reports);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetParkedMessageCount(int count) { }
|
||||
/// <inheritdoc />
|
||||
public void SetNodeHostname(string hostname) { }
|
||||
/// <inheritdoc />
|
||||
public void SetClusterNodes(IReadOnlyList<Commons.Messages.Health.NodeStatus> nodes) { }
|
||||
/// <inheritdoc />
|
||||
public void SetActiveNode(bool isActive) { }
|
||||
/// <inheritdoc />
|
||||
public bool IsActiveNode => true;
|
||||
/// <inheritdoc />
|
||||
public Commons.Messages.Health.SiteHealthReport CollectReport(string siteId)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -147,6 +169,8 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
: [new ResolvedScript { CanonicalName = "Worker", Code = scriptCode, TriggerType = "Call" }]
|
||||
});
|
||||
|
||||
/// <summary>Verifies that a delete arriving during the compile warm is queued and applied after the deploy.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeleteArrivingDuringTheCompileWarm_IsQueuedAndAppliedAfterTheDeploy()
|
||||
{
|
||||
@@ -177,10 +201,12 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
// ahead of the warm it would have removed nothing and the deploy would have left the
|
||||
// count at 1. This is the ordering signal rather than the SQLite row, because the
|
||||
// deploy's store and the delete's remove are independent background tasks whose
|
||||
// completion order the actor has never guaranteed (true before WP3.1 as well).
|
||||
// completion order the actor has never guaranteed (true before warm-then-gate as well).
|
||||
AwaitAssert(() => Assert.Equal(0, health.LastDeployedCount), TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a second deploy during the warm supersedes the first and answers its deployer.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SecondDeployDuringTheWarm_SupersedesTheFirst_AndAnswersItsDeployer()
|
||||
{
|
||||
@@ -212,6 +238,8 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
Assert.Equal("h2", row.RevisionHash);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deploys for different instances do not block each other.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploysForDifferentInstances_DoNotBlockEachOther()
|
||||
{
|
||||
@@ -239,12 +267,14 @@ public class DeploymentWarmThenGateTests : TestKit, IDisposable
|
||||
Assert.Contains(configs, c => c.InstanceUniqueName == "PumpY");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that staggered startup pre-warms each batch so the Instance Actor PreStart compiles are cache hits.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaggeredStartup_PreWarmsEachBatchSoInstanceActorPreStartCompilesAreCacheHits()
|
||||
{
|
||||
// Two instances sharing one script body. The batch pre-warm compiles it once; the
|
||||
// second config's warm and BOTH Instance Actors' PreStart compiles are then hits.
|
||||
// Before WP3.1 every Instance Actor Roslyn-compiled its own scripts inside PreStart,
|
||||
// Before the batch pre-warm every Instance Actor Roslyn-compiled its own scripts inside PreStart,
|
||||
// serialising a site's whole failover recovery behind compilation.
|
||||
const string sharedCode = "return 41 + 1;";
|
||||
await _storage.StoreDeployedConfigAsync(
|
||||
|
||||
@@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36 end-to-end over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
|
||||
/// End-to-end test over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
|
||||
/// the cache still holds the previous run's <c>CompleteFlag = True</c>, the script's reset
|
||||
/// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the
|
||||
/// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until
|
||||
@@ -33,6 +33,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test with a temporary LocalDb-backed site storage and script compilation services.</summary>
|
||||
public HandshakeResetEchoTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("handshake-reset-echo");
|
||||
@@ -42,6 +43,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -90,6 +92,7 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
|
||||
/// cache — callers migrate to the reset-aware overload instead. Never "fix" the fast-path in
|
||||
/// place; delete this test only if the legacy overload itself is removed.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue()
|
||||
{
|
||||
@@ -106,6 +109,8 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
|
||||
Assert.True(await call);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the reset-aware overload holds the batch until the reset echoes and then matches the fresh true value.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue()
|
||||
{
|
||||
@@ -151,6 +156,8 @@ public class HandshakeResetEchoTests : TestKit, IDisposable
|
||||
Assert.Equal("Good", result.Quality);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the reset-aware overload times out in the reset phase when no echo arrives and never writes the batch.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch()
|
||||
{
|
||||
|
||||
+6
-2
@@ -14,7 +14,7 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for SiteRuntime-017 — the Instance Actor must not hand its
|
||||
/// Regression coverage: the Instance Actor must not hand its
|
||||
/// own live mutable <c>_attributes</c> dictionary by reference into the
|
||||
/// <see cref="ScriptActor"/> / <see cref="AlarmActor"/> constructors.
|
||||
///
|
||||
@@ -42,6 +42,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the temporary database, storage, compilation service and options used by the tests.</summary>
|
||||
public InstanceActorChildAttributeRaceTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-race-test");
|
||||
@@ -60,6 +61,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -164,6 +166,8 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
|
||||
return (Dictionary<string, object?>)field!.GetValue(instance)!;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that child actors are seeded from an isolated copy of the attributes, not the live attributes dictionary.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ChildActors_AreSeededFromAnIsolatedCopy_NotTheLiveAttributesDictionary()
|
||||
{
|
||||
@@ -192,7 +196,7 @@ public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
|
||||
// Every child Script Actor must have been seeded from a dictionary that
|
||||
// is NOT the Instance Actor's live _attributes field — otherwise the
|
||||
// child constructor would enumerate a dictionary the Instance Actor
|
||||
// mutates on another thread (SiteRuntime-017).
|
||||
// mutates on another thread.
|
||||
foreach (var name in new[] { "WorkerA", "WorkerB" })
|
||||
{
|
||||
var scriptActor = await ResolveChildInstanceAsync<ScriptActor>(testRef, $"script-{name}");
|
||||
|
||||
+5
-1
@@ -11,7 +11,7 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// PLAN-03 Task 18 (P2): the Instance Actor routes each attribute change only to the
|
||||
/// The Instance Actor routes each attribute change only to the
|
||||
/// children interested in it. A value-change / conditional script (and a monitored-attribute
|
||||
/// alarm) subscribes to a single named attribute; an Expression-triggered script/alarm hears
|
||||
/// every change (it keeps a full attribute snapshot). These tests pin the routing-map
|
||||
@@ -25,6 +25,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Creates the test class with a fresh storage service and actor system.</summary>
|
||||
public InstanceActorChildRoutingTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-routing-test");
|
||||
@@ -43,6 +44,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -101,6 +103,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable
|
||||
return testRef.UnderlyingActor;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the routing maps are built from the trigger configurations.</summary>
|
||||
[Fact]
|
||||
public void RoutingMaps_AreBuiltFromTriggerConfigs()
|
||||
{
|
||||
@@ -113,6 +116,7 @@ public class InstanceActorChildRoutingTests : TestKit, IDisposable
|
||||
Assert.Single(actor.AllChangeChildren);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a monitored-attribute alarm routes only to its attribute while an expression alarm routes to all changes.</summary>
|
||||
[Fact]
|
||||
public void MonitoredAttributeAlarm_RoutesToItsAttribute_ExpressionAlarm_RoutesToAllChanges()
|
||||
{
|
||||
|
||||
+11
-2
@@ -15,7 +15,7 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for InstanceActor with child Script/Alarm actors (WP-15, WP-16, WP-24, WP-25).
|
||||
/// Integration tests for InstanceActor with child Script/Alarm actors.
|
||||
/// </summary>
|
||||
public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
{
|
||||
@@ -25,6 +25,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Creates the temporary local database, storage service, script services and runtime options the tests share.</summary>
|
||||
public InstanceActorIntegrationTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-int-test");
|
||||
@@ -44,6 +45,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -82,6 +84,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the instance actor creates script actors from its configuration.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_CreatesScriptActors_FromConfig()
|
||||
{
|
||||
@@ -103,6 +106,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
Assert.Equal(42, result.ReturnValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that calling an unknown script returns an error.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_ScriptCallRequest_UnknownScript_ReturnsError()
|
||||
{
|
||||
@@ -114,10 +118,11 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
Assert.Contains("not found", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rapid attribute changes are serialized through the actor mailbox without corrupting state.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_WP24_StateMutationsSerializedThroughMailbox()
|
||||
{
|
||||
// WP-24: Instance Actor processes messages sequentially.
|
||||
// Instance Actor processes messages sequentially.
|
||||
// Verify that rapid attribute changes don't corrupt state.
|
||||
var actor = CreateInstanceWithScripts("Pump1");
|
||||
|
||||
@@ -143,6 +148,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
Assert.Equal("49", response.Value?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribing to the debug view returns a snapshot.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_WP25_DebugViewSubscribe_ReturnsSnapshot()
|
||||
{
|
||||
@@ -159,6 +165,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
Assert.True(snapshot.SnapshotTimestamp > DateTimeOffset.MinValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a debug view subscription no longer forwards events through the cluster client.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_WP25_DebugViewSubscribe_NoLongerForwardsEventsViaClusterClient()
|
||||
{
|
||||
@@ -179,6 +186,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing from the debug view stops notifications.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_WP25_DebugViewUnsubscribe_StopsNotifications()
|
||||
{
|
||||
@@ -199,6 +207,7 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the instance actor creates alarm actors from its configuration.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_CreatesAlarmActors_FromConfig()
|
||||
{
|
||||
|
||||
+4
@@ -29,6 +29,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options = new();
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test class with a temporary storage setup.</summary>
|
||||
public InstanceActorMesReportableSnapshotTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-mes-reportable");
|
||||
@@ -99,6 +100,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
|
||||
m => m.CorrelationId == correlationId, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the alarm snapshot stamps the MES-reportable flag on both fired and unfired alarms.</summary>
|
||||
[Fact]
|
||||
public void AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms()
|
||||
{
|
||||
@@ -136,6 +138,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
|
||||
Assert.False(unfired.IsMesReportable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unfired reportable alarm is stamped before it ever fires.</summary>
|
||||
[Fact]
|
||||
public void AlarmSnapshot_UnfiredReportableAlarm_IsStampedBeforeItEverFires()
|
||||
{
|
||||
@@ -151,6 +154,7 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
|
||||
Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
|
||||
+10
-2
@@ -18,7 +18,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Task 16: InstanceActor spawns NativeAlarmActor children from the flattened
|
||||
/// InstanceActor spawns NativeAlarmActor children from the flattened
|
||||
/// configuration and surfaces enriched native alarm state in the DebugView snapshot.
|
||||
/// </summary>
|
||||
public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
@@ -29,6 +29,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options = new();
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test class and its test doubles.</summary>
|
||||
public InstanceActorNativeAlarmTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-native");
|
||||
@@ -64,6 +65,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
]
|
||||
};
|
||||
|
||||
/// <summary>Verifies that the instance actor spawns a native alarm actor that subscribes through the DCL.</summary>
|
||||
[Fact]
|
||||
public void SpawnsNativeAlarmActor_WhichSubscribesViaDcl()
|
||||
{
|
||||
@@ -76,6 +78,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
Assert.Equal("ns=2;s=T01", req.SourceReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the debug view snapshot includes the native alarm after a transition.</summary>
|
||||
[Fact]
|
||||
public void DebugViewSnapshot_IncludesNativeAlarm_AfterTransition()
|
||||
{
|
||||
@@ -97,6 +100,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a quiet native binding emits a placeholder in the snapshot.</summary>
|
||||
[Fact]
|
||||
public void Snapshot_QuietNativeBinding_EmitsPlaceholder()
|
||||
{
|
||||
@@ -119,6 +123,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmKind.NativeOpcUa, placeholder.Kind);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a native binding with a live condition emits no placeholder.</summary>
|
||||
[Fact]
|
||||
public void Snapshot_NativeBindingWithLiveCondition_NoPlaceholder()
|
||||
{
|
||||
@@ -127,7 +132,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
|
||||
// The NativeAlarmActor emits a live condition stamped with the binding's
|
||||
// canonical name (DV-1: NativeSourceCanonicalName), so the binding is "active".
|
||||
// canonical name (NativeSourceCanonicalName), so the binding is "active".
|
||||
actor.Tell(new AlarmStateChanged("inst", "Pressure.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeOpcUa,
|
||||
@@ -151,6 +156,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
|
||||
// ── MES alarm-status API §5.2: the script-facing alarm snapshot ────────
|
||||
|
||||
/// <summary>Verifies that GetAlarmSnapshot returns the same rows as the debug view snapshot.</summary>
|
||||
[Fact]
|
||||
public void GetAlarmSnapshot_ReturnsTheSameRowsAsTheDebugViewSnapshot()
|
||||
{
|
||||
@@ -190,6 +196,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
reply.Alarms.Select(a => a.AlarmName).OrderBy(n => n));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetAlarmSnapshot with no alarms replies with only the configured placeholders.</summary>
|
||||
[Fact]
|
||||
public void GetAlarmSnapshot_WithNoAlarms_RepliesWithTheConfiguredPlaceholdersOnly()
|
||||
{
|
||||
@@ -206,6 +213,7 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
Assert.Null(placeholder.AckTime);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
|
||||
+12
-2
@@ -14,7 +14,7 @@ using System.Text.Json;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for SiteRuntime-001: Instance.SetAttribute must route writes
|
||||
/// Regression tests: Instance.SetAttribute must route writes
|
||||
/// to the Data Connection Layer for data-sourced attributes instead of persisting
|
||||
/// a local static override.
|
||||
/// </summary>
|
||||
@@ -26,6 +26,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test class with an actor system and storage.</summary>
|
||||
public InstanceActorSetAttributeTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-setattr-test");
|
||||
@@ -40,6 +41,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
|
||||
_options = new SiteRuntimeOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -87,6 +89,8 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
|
||||
]
|
||||
};
|
||||
|
||||
/// <summary>Verifies that setting a data-sourced attribute issues a DCL write and does not persist an override.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_DataSourcedAttribute_IssuesDclWriteAndDoesNotPersistOverride()
|
||||
{
|
||||
@@ -114,6 +118,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
|
||||
Assert.Empty(overrides);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting a data-sourced attribute does not optimistically update memory.</summary>
|
||||
[Fact]
|
||||
public void SetAttribute_DataSourcedAttribute_DoesNotOptimisticallyUpdateMemory()
|
||||
{
|
||||
@@ -135,6 +140,7 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
|
||||
Assert.Equal("10", get.Value?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a DCL write failure for a data-sourced attribute is returned to the caller.</summary>
|
||||
[Fact]
|
||||
public void SetAttribute_DataSourcedAttribute_DclWriteFailure_ReturnedToCaller()
|
||||
{
|
||||
@@ -153,6 +159,8 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
|
||||
Assert.Contains("device rejected write", response.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting a static attribute still persists the override and does not call the DCL.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_StaticAttribute_StillPersistsOverrideAndDoesNotCallDcl()
|
||||
{
|
||||
@@ -182,10 +190,12 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
|
||||
Assert.Equal("Backup", overrides["Label"]);
|
||||
}
|
||||
|
||||
// SiteRuntime-025: SetAttribute on an unknown attribute name must NOT
|
||||
// SetAttribute on an unknown attribute name must NOT
|
||||
// pollute the in-memory dictionary, NOT publish a synthetic
|
||||
// AttributeValueChanged, and NOT persist a durable override row.
|
||||
|
||||
/// <summary>Verifies that setting an unknown attribute returns a failure and does not persist an override.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAttribute_UnknownAttribute_ReturnsFailureAndDoesNotPersistOverride()
|
||||
{
|
||||
|
||||
+5
@@ -12,6 +12,7 @@ public class InstanceActorTagSubscribeBackoffTests
|
||||
private static readonly TimeSpan Floor = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan Max = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>Verifies the case: doubles, with No Jitter.</summary>
|
||||
[Fact]
|
||||
public void Doubles_WithNoJitter()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public class InstanceActorTagSubscribeBackoffTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), next);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: jitter, spans Plus Minus Twenty Percent.</summary>
|
||||
[Fact]
|
||||
public void Jitter_SpansPlusMinusTwentyPercent()
|
||||
{
|
||||
@@ -28,6 +30,7 @@ public class InstanceActorTagSubscribeBackoffTests
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), high); // 10 s × 1.2
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: never Exceeds Ceiling, even With Positive Jitter.</summary>
|
||||
[Fact]
|
||||
public void NeverExceedsCeiling_EvenWithPositiveJitter()
|
||||
{
|
||||
@@ -35,6 +38,7 @@ public class InstanceActorTagSubscribeBackoffTests
|
||||
Assert.Equal(Max, next);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: never Below Floor, even With Negative Jitter.</summary>
|
||||
[Fact]
|
||||
public void NeverBelowFloor_EvenWithNegativeJitter()
|
||||
{
|
||||
@@ -42,6 +46,7 @@ public class InstanceActorTagSubscribeBackoffTests
|
||||
Assert.True(next >= Floor, $"{next} < floor");
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: ceiling Below Floor, degrades To Fixed Floor.</summary>
|
||||
[Fact]
|
||||
public void CeilingBelowFloor_DegradesToFixedFloor()
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test class with an in-memory site storage and the shared actor dependencies.</summary>
|
||||
public InstanceActorTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-actor-test");
|
||||
@@ -55,6 +56,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -65,8 +67,9 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
TestLocalDb.DeleteFiles(path);
|
||||
}
|
||||
|
||||
// ── M1.6: site event log `instance_lifecycle` category ──────────────────
|
||||
// ── Site event log `instance_lifecycle` category ──────────────────
|
||||
|
||||
/// <summary>Verifies that stopping the actor releases its tag subscription under its owner identity.</summary>
|
||||
[Fact]
|
||||
public void Stop_ReleasesTagSubscriptionWithOwnerIdentity()
|
||||
{
|
||||
@@ -78,6 +81,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal(instance, dcl.LastSender);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that starting an instance actor emits an instance lifecycle site event.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_Start_EmitsInstanceLifecycleSiteEvent()
|
||||
{
|
||||
@@ -112,6 +116,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stopping an instance actor emits an instance lifecycle site event.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_Stop_EmitsInstanceLifecycleSiteEvent()
|
||||
{
|
||||
@@ -152,6 +157,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the instance actor loads its attributes from the configuration.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_LoadsAttributesFromConfig()
|
||||
{
|
||||
@@ -177,6 +183,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal("corr-1", response.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that getting an unknown attribute reports not found.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_GetAttribute_NotFound_ReturnsFalse()
|
||||
{
|
||||
@@ -196,6 +203,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Null(response.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting a static attribute updates it in memory.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_SetStaticAttribute_UpdatesInMemory()
|
||||
{
|
||||
@@ -225,6 +233,8 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal("100.0", getResponse.Value?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that setting a static attribute persists it to SQLite.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_SetStaticAttribute_PersistsToSQLite()
|
||||
{
|
||||
@@ -254,7 +264,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Perf remediation (arch-review WP1.5): a burst of rapid static-attribute writes must
|
||||
/// Perf remediation: a burst of rapid static-attribute writes must
|
||||
/// coalesce into ONE batched SQLite transaction on a short single-shot timer — mirroring
|
||||
/// NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape — rather than a write
|
||||
/// per <see cref="SetStaticAttributeCommand"/>. Pinned two ways: (1) nothing is persisted
|
||||
@@ -262,6 +272,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
/// synchronously and updated in-memory state; (2) only the LATEST value per attribute
|
||||
/// survives, across writes to two different attributes coalesced into the same flush.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_RapidStaticWrites_CoalesceIntoSingleFlush()
|
||||
{
|
||||
@@ -324,6 +335,8 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Running", afterFlush["Label"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that static overrides are loaded from SQLite.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_LoadsStaticOverridesFromSQLite()
|
||||
{
|
||||
@@ -353,6 +366,8 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal("200.0", response.Value?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a static override is reset on redeployment.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaticOverride_ResetOnRedeployment()
|
||||
{
|
||||
@@ -364,7 +379,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Single(overrides);
|
||||
|
||||
// Clear overrides BEFORE the successor exists — the ordering DeploymentManager now
|
||||
// guarantees (arch-review 2026-08-19 P1-2). It used to clear inside the deploy's
|
||||
// guarantees. It used to clear inside the deploy's
|
||||
// persistence task, i.e. after creating the actor, so this "simulation" pinned an
|
||||
// ordering the production path did not have; the end-to-end contract lives in
|
||||
// RedeployStaticOverrideResetTests.
|
||||
@@ -394,6 +409,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal("98.6", response.Value?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a data-sourced attribute starts with uncertain quality.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_DataSourcedAttribute_StartsWithUncertainQuality()
|
||||
{
|
||||
@@ -424,7 +440,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-019: the disable/enable lifecycle is owned entirely by the
|
||||
/// The disable/enable lifecycle is owned entirely by the
|
||||
/// Deployment Manager (it stops / re-creates the Instance Actor itself and
|
||||
/// replies to the caller). The Instance Actor must NOT handle
|
||||
/// <see cref="DisableInstanceCommand"/> / <see cref="EnableInstanceCommand"/>
|
||||
@@ -450,6 +466,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a static attribute starts with good quality.</summary>
|
||||
[Fact]
|
||||
public void InstanceActor_StaticAttribute_StartsWithGoodQuality()
|
||||
{
|
||||
@@ -537,7 +554,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// ── MV-8: data-sourced List attribute coercion ─────────────────────────
|
||||
// ── Data-sourced List attribute coercion ─────────────────────────
|
||||
|
||||
private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, TestProbe dcl)
|
||||
{
|
||||
@@ -558,7 +575,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: when a data-sourced attribute is declared <c>DataType.List</c>, an
|
||||
/// When a data-sourced attribute is declared <c>DataType.List</c>, an
|
||||
/// incoming OPC UA array value (a CLR array surfaces from the SDK) must be
|
||||
/// coerced into a typed <c>List<int></c> whose elements match the
|
||||
/// attribute's ElementDataType. The stored value must be a real list — not a
|
||||
@@ -598,7 +615,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: array elements coming in as a different CLR type (here, strings that
|
||||
/// Array elements coming in as a different CLR type (here, strings that
|
||||
/// are valid integers) must still coerce to the declared element type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -635,7 +652,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8: an element that cannot be coerced to the declared element type must
|
||||
/// An element that cannot be coerced to the declared element type must
|
||||
/// set the attribute quality to <c>Bad</c> and must NOT crash the actor (it
|
||||
/// stays alive and continues to answer queries).
|
||||
/// </summary>
|
||||
@@ -675,7 +692,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8 design contract: a failed coercion keeps the PRIOR value. A good
|
||||
/// Design contract: a failed coercion keeps the PRIOR value. A good
|
||||
/// array is delivered first and stored as a typed list; a subsequent array
|
||||
/// with a non-coercible element must NOT overwrite that value — the stored
|
||||
/// value stays the prior list while the quality flips to <c>Bad</c>.
|
||||
@@ -718,7 +735,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing
|
||||
/// Guard: scalar (non-List) data-sourced attributes keep the existing
|
||||
/// pass-through behaviour — a scalar value is stored unchanged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -846,10 +863,10 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
ExpectNoMsg(within);
|
||||
}
|
||||
|
||||
// ── MV-7: static (authored) List attribute decode ──────────────────────
|
||||
// ── Static (authored) List attribute decode ──────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: a STATIC List attribute carries its default as the canonical JSON
|
||||
/// A STATIC List attribute carries its default as the canonical JSON
|
||||
/// array string. On load the actor must decode it to a typed list so a
|
||||
/// script reading the attribute receives a real collection, not the raw
|
||||
/// JSON string.
|
||||
@@ -882,11 +899,12 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: a SetStaticAttribute write on a List attribute decodes the canonical
|
||||
/// A SetStaticAttribute write on a List attribute decodes the canonical
|
||||
/// JSON value into a typed list for in-memory reads, but the PERSISTED form
|
||||
/// (SQLite static override) must remain the canonical JSON string — never a
|
||||
/// CLR-list .ToString().
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_SetStaticListAttribute_ReadsTypedList_PersistsJsonString()
|
||||
{
|
||||
@@ -925,10 +943,11 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: a persisted static override for a List attribute is a canonical JSON
|
||||
/// A persisted static override for a List attribute is a canonical JSON
|
||||
/// string in SQLite; on load it must be decoded to a typed list, the same as
|
||||
/// the config default.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_StaticListOverride_LoadsAsTypedList()
|
||||
{
|
||||
@@ -961,7 +980,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7: a malformed stored List value must NOT crash the actor — it loads
|
||||
/// A malformed stored List value must NOT crash the actor — it loads
|
||||
/// with quality Bad and the actor stays alive and answering.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -995,7 +1014,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7 guard: a scalar static attribute is unaffected by the List decode
|
||||
/// Guard: a scalar static attribute is unaffected by the List decode
|
||||
/// path — it still returns its raw string value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -1021,13 +1040,14 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7 (review fix): a SetStaticAttribute write whose value fails to decode as
|
||||
/// A SetStaticAttribute write whose value fails to decode as
|
||||
/// a list (e.g. truncated JSON) on a List attribute must be REJECTED — reply
|
||||
/// Success=false with a clear error and persist NOTHING. The script path always
|
||||
/// pre-encodes valid JSON, but the Inbound API / direct-command path can submit
|
||||
/// an arbitrary value, so a malformed value must not silently null the in-memory
|
||||
/// value, publish "Good" quality, and durably persist a poison override.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_SetStaticListAttribute_Malformed_Rejected_NotPersisted()
|
||||
{
|
||||
@@ -1067,11 +1087,12 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV-7 (review fix): an empty-list value "[]" decodes to a non-null empty list
|
||||
/// An empty-list value "[]" decodes to a non-null empty list
|
||||
/// and must be accepted (NOT mistaken for a malformed value, which also decodes
|
||||
/// to null). This pins the boundary between the "clearing/empty" and "poison"
|
||||
/// cases that both surface as a null decode result.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_SetStaticListAttribute_EmptyList_Accepted()
|
||||
{
|
||||
@@ -1108,14 +1129,15 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal("[]", overrides["Labels"]);
|
||||
}
|
||||
|
||||
// ── NJ-4: old-form List static override normalization on load ────────────
|
||||
// ── Old-form List static override normalization on load ────────────
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: an OLD array-of-strings static override (<c>["10","20"]</c>) for an
|
||||
/// An OLD array-of-strings static override (<c>["10","20"]</c>) for an
|
||||
/// Int32 List attribute must be re-persisted in the native form (<c>[10,20]</c>)
|
||||
/// when the actor loads it at startup. The in-memory read still returns the
|
||||
/// typed list {10,20}; the on-disk value is normalized to native JSON.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad()
|
||||
{
|
||||
@@ -1153,10 +1175,11 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a NATIVE-form static override (<c>[10,20]</c>) is already canonical, so
|
||||
/// A NATIVE-form static override (<c>[10,20]</c>) is already canonical, so
|
||||
/// load-time normalization must be a no-op — the on-disk value is unchanged
|
||||
/// (idempotent: native → native is byte-identical, so no re-persist occurs).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad()
|
||||
{
|
||||
@@ -1191,9 +1214,10 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a scalar static override is unaffected by the List normalization path —
|
||||
/// A scalar static override is unaffected by the List normalization path —
|
||||
/// its on-disk value is left exactly as stored (no native re-encode).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization()
|
||||
{
|
||||
@@ -1222,10 +1246,11 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the
|
||||
/// A malformed stored List override (truncated JSON) must NOT crash the
|
||||
/// actor and must NOT be re-persisted — it loads with Bad quality (as today),
|
||||
/// the actor stays alive, and the poison on-disk value is left unchanged.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted()
|
||||
{
|
||||
@@ -1298,6 +1323,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
return (actor, dcl);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failed subscribe response is retried.</summary>
|
||||
[Fact]
|
||||
public void FailedSubscribeResponse_IsRetried()
|
||||
{
|
||||
@@ -1313,6 +1339,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal(first.TagPaths, retry.TagPaths);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a lost subscribe response is retried and a success stops the retrying.</summary>
|
||||
[Fact]
|
||||
public void LostSubscribeResponse_IsRetried_AndSuccessStopsRetrying()
|
||||
{
|
||||
@@ -1331,6 +1358,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
|
||||
// --- Lever 1 (2026-08-28): in-flight guard ---
|
||||
|
||||
/// <summary>Verifies that a slow subscribe does not stack duplicate requests while one is in flight.</summary>
|
||||
[Fact]
|
||||
public void SlowSubscribe_DoesNotStackDuplicates_WhileInFlight()
|
||||
{
|
||||
@@ -1344,10 +1372,11 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
var (_, dclProbe) = CreateInstanceWithDataSourcedAttribute(options);
|
||||
|
||||
dclProbe.ExpectMsg<SubscribeTagsRequest>();
|
||||
// Pre-Lever-1 this window would carry ~4 more requests (one per 200 ms tick).
|
||||
// Before the in-flight guard this window would carry ~4 more requests (one per 200 ms tick).
|
||||
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(1000));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a slow subscribe is re-issued once after the in-flight ceiling.</summary>
|
||||
[Fact]
|
||||
public void SlowSubscribe_ReissuesAfterInFlightCeiling_AndOnlyOnce()
|
||||
{
|
||||
@@ -1367,6 +1396,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a reply to a superseded request is ignored and the latest request still governs.</summary>
|
||||
[Fact]
|
||||
public void ReplyToSupersededRequest_IsIgnored_AndLatestStillGoverns()
|
||||
{
|
||||
@@ -1396,6 +1426,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
|
||||
// --- Lever 1 (2026-08-28): backoff on failure, reset on success ---
|
||||
|
||||
/// <summary>Verifies that repeated failures back off and a success resets the interval to its floor.</summary>
|
||||
[Fact]
|
||||
public void RepeatedFailures_BackOff_AndSuccessResetsToFloor()
|
||||
{
|
||||
@@ -1431,6 +1462,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(1200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failure reply re-issues the request even though the in-flight guard has not expired.</summary>
|
||||
[Fact]
|
||||
public void FailureReply_ReissuesEvenThoughGuardHasNotExpired()
|
||||
{
|
||||
@@ -1453,6 +1485,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
|
||||
// --- Lever 1 (2026-08-28): Good quality resets the backoff ---
|
||||
|
||||
/// <summary>Verifies that a flapping Good quality does not defer the retry.</summary>
|
||||
[Fact]
|
||||
public void FlappingGood_DoesNotDeferTheRetry()
|
||||
{
|
||||
@@ -1489,6 +1522,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
dclProbe.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the connection returning to Good resets the backoff and retries promptly.</summary>
|
||||
[Fact]
|
||||
public void ConnectionBackToGood_ResetsBackoff_AndRetriesPromptly()
|
||||
{
|
||||
@@ -1518,6 +1552,7 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromMilliseconds(1000), $"retry took {sw.Elapsed} — backoff not reset");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a late reply for a failed and re-issued request is ignored.</summary>
|
||||
[Fact]
|
||||
public void LateReplyForFailedAndReissuedRequest_IsIgnored()
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test with a temporary site database and the script services.</summary>
|
||||
public InstanceActorTraceArmTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-tracearm-test");
|
||||
@@ -40,6 +41,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -107,6 +109,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
|
||||
m => m.AttributeName == "Processed" && Equals(m.Value, value), TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced data write followed by its echo publishes a change stamped with the source and trace.</summary>
|
||||
[Fact]
|
||||
public void TracedDataWrite_ThenEcho_PublishesChangeStampedWithSourceAndTrace()
|
||||
{
|
||||
@@ -122,6 +125,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
|
||||
Assert.True(published.SourceExecutionInferred);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced data write followed by its echo publishes an unstamped change.</summary>
|
||||
[Fact]
|
||||
public void UntracedDataWrite_ThenEcho_PublishesUnstampedChange()
|
||||
{
|
||||
@@ -183,6 +187,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
|
||||
Assert.True(published.Trace);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the trace arm is consumed only once.</summary>
|
||||
[Fact]
|
||||
public void Arm_IsConsumedOnce()
|
||||
{
|
||||
@@ -199,6 +204,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
|
||||
Assert.False(second.Trace);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an expired trace arm is ignored.</summary>
|
||||
[Fact]
|
||||
[Trait("Category", "Slow")]
|
||||
public void Arm_Expired_IsIgnored()
|
||||
@@ -213,6 +219,7 @@ public class InstanceActorTraceArmTests : TestKit, IDisposable
|
||||
Assert.False(published.Trace);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced static write publishes the trace flag.</summary>
|
||||
[Fact]
|
||||
public void StaticWrite_Traced_PublishesTraceFlag()
|
||||
{
|
||||
|
||||
+3
-1
@@ -29,6 +29,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>InstanceActorWaitForAttributeTests</c> class.</summary>
|
||||
public InstanceActorWaitForAttributeTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-waitfor-test");
|
||||
@@ -56,6 +57,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
@@ -474,7 +476,7 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
|
||||
// The pending length>=3 waiter must NOT fire on this non-change republish.
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// A REAL change to a length-3 list resolves the still-pending waiter.
|
||||
// A REAL change to a three-element list resolves the still-pending waiter.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 7, 8, 9 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
var realChange = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("wfa-len3", realChange.CorrelationId);
|
||||
|
||||
@@ -49,6 +49,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Creates a temporary LocalDb-backed site store and the compilation services for each test.</summary>
|
||||
public InstanceSweepSelfHealTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-sweep-test");
|
||||
@@ -60,6 +61,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
|
||||
@@ -191,7 +193,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
// ── Row 78: a config that arrives with no deploy, no enable and no reconcile ──
|
||||
|
||||
/// <summary>
|
||||
/// THE row-78 reproduction. The node boots with an empty deployed-config store (so the
|
||||
/// THE reproduction of the missing-start bug. The node boots with an empty deployed-config store (so the
|
||||
/// boot-time load starts nothing), and the config for an ENABLED instance then appears in
|
||||
/// storage on its own — exactly what LocalDb's CDC sync applier does when it seeds a fresh
|
||||
/// node from its peer. No <see cref="DeployInstanceCommand"/>, no
|
||||
@@ -202,6 +204,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// snapshot still answers <c>InstanceNotFound = true</c>, the live symptom verbatim.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_StartsEnabledInstance_WhoseConfigArrivedWithoutADeployOrReconcile()
|
||||
{
|
||||
@@ -231,6 +234,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// pattern as row 67, with the trigger named so an operator can tell a sweep heal from a
|
||||
/// startup-reconcile heal.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SweepStartedInstance_EmitsAWarningSiteEvent_NamingTheSweepTrigger()
|
||||
{
|
||||
@@ -259,6 +263,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// A DISABLED instance legitimately has a config row and no running actor. The sweep must
|
||||
/// never start it — that would silently undo an operator's disable.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_NeverStarts_ADisabledInstance()
|
||||
{
|
||||
@@ -280,6 +285,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// that re-fired every tick would be a restart loop plus an unbounded stream of Warning site
|
||||
/// events.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_StartsTheInstanceExactlyOnce_AcrossManySweeps()
|
||||
{
|
||||
@@ -306,6 +312,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// A sweep that finds nothing wrong must be completely silent — no site events at all. This
|
||||
/// runs on every tick of every healthy site, forever.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_IsSilent_WhenThereIsNothingToHeal()
|
||||
{
|
||||
@@ -327,6 +334,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// site that wants the pre-fix behaviour keeps it (and the option's disable path is proven,
|
||||
/// not merely validated).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_DoesNothing_WhenTheIntervalIsZero()
|
||||
{
|
||||
@@ -355,6 +363,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_DefersToAnInFlightRedeploy()
|
||||
{
|
||||
@@ -393,17 +402,18 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
// ── Row 72: a running actor serving a revision the store has replaced ──
|
||||
|
||||
/// <summary>
|
||||
/// THE row-72 reproduction. An instance is deployed and running at <c>rev-1</c>; the durable
|
||||
/// row is then refreshed to <c>rev-2</c> out of band — what
|
||||
/// THE reproduction of the stale-revision bug. An instance is deployed and running at its first
|
||||
/// revision; the durable row is then refreshed to the second revision out of band — what
|
||||
/// <c>StoreDeployedConfigIfNewerAsync</c> does when the startup reconcile finds a STALE
|
||||
/// config, and what a replicated deploy does on the standby.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Fails before the fix</b>: the actor keeps serving <c>rev-1</c>'s config indefinitely
|
||||
/// <b>Fails before the fix</b>: the actor keeps serving the first revision's config indefinitely
|
||||
/// (the snapshot still shows the old attribute value) and nothing anywhere says the stored
|
||||
/// and running revisions differ.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_RestartsInstance_ServingAStaleRevision()
|
||||
{
|
||||
@@ -444,6 +454,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// The stale-revision restart must not fire when the stored and running revisions agree —
|
||||
/// otherwise every healthy instance on the site would be restarted once a minute.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_DoesNotRestart_WhenStoredAndRunningRevisionsMatch()
|
||||
{
|
||||
@@ -470,6 +481,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// site events for a config that can never apply, and the running instance is unharmed —
|
||||
/// the gate returns before anything is stopped, so it keeps serving the old config.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_StaleRestartRejectedByTheCompileGate_IsAnnouncedOnceAndNotRetried()
|
||||
{
|
||||
@@ -541,12 +553,12 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
StoreGenerationAtRead: generationAtRead);
|
||||
|
||||
/// <summary>
|
||||
/// <b>Deferred register row 101, the row-72 leg.</b> A sweep read dispatched before a deploy
|
||||
/// <b>Deferred register row 101, the stale-revision leg.</b> A sweep read dispatched before a deploy
|
||||
/// persisted its new row can land after that deploy has completed AND after the deploy
|
||||
/// pipeline released the instance — <see cref="DeploymentManagerActor"/>'s
|
||||
/// <c>InstanceIsOwnedByADeployPipeline</c> only covers a deploy still in flight, so it is no
|
||||
/// defence here. The snapshot then carries the revision the deploy just REPLACED while the
|
||||
/// running actor carries the new one, which is bit-for-bit the divergence the row-72 repair
|
||||
/// running actor carries the new one, which is bit-for-bit the divergence the stale-revision repair
|
||||
/// looks for — in the wrong direction.
|
||||
///
|
||||
/// <para>
|
||||
@@ -556,6 +568,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// claim marks the OLD revision as attempted, so the instance simply stays reverted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaleSweepSnapshot_CarryingASupersededRevision_DoesNotRestartBackward()
|
||||
{
|
||||
@@ -586,7 +599,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The row-101 guard must not blunt the row-72 repair it sits in front of: the SAME message
|
||||
/// The stale-snapshot guard must not blunt the stale-revision repair it sits in front of: the SAME message
|
||||
/// stamped with the CURRENT generation is a genuine answer and must still converge the
|
||||
/// runtime on the stored revision.
|
||||
///
|
||||
@@ -597,6 +610,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// meaning, which is the whole point of the guard.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FreshSweepSnapshot_CarryingANewerRevision_StillRestartsTheInstance()
|
||||
{
|
||||
@@ -630,7 +644,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Deferred register row 101, the row-78 leg.</b> A sweep read dispatched before a delete
|
||||
/// <b>Deferred register row 101, the missing-start leg.</b> A sweep read dispatched before a delete
|
||||
/// committed still shows the instance's row — enabled, with a config — while the actor has
|
||||
/// already stopped it and dropped its bookkeeping. The delete specifically clears
|
||||
/// <c>_sweepAttemptedRevisions</c>, so the once-per-revision claim is freshly available and
|
||||
@@ -641,6 +655,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
/// re-adds it to the deployed-name set, and announces the resurrection as a self-heal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaleSweepSnapshot_ShowingADeletedInstance_DoesNotResurrectIt()
|
||||
{
|
||||
@@ -667,9 +682,10 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// The mirror of the resurrection test: the same enabled-config-with-no-actor snapshot,
|
||||
/// stamped with the CURRENT generation, must still perform the row-78 start. Nothing has
|
||||
/// stamped with the CURRENT generation, must still perform the missing-start heal. Nothing has
|
||||
/// mutated the store through this actor, so <c>0</c> is current here.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FreshSweepSnapshot_ShowingAStoredEnabledInstance_StillStartsIt()
|
||||
{
|
||||
@@ -700,6 +716,7 @@ public class InstanceSweepSelfHealTests : TestKit, IDisposable
|
||||
///
|
||||
/// <para><b>Fails before the fix</b>: one read, and the pre-mutation snapshot is applied.</para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaleBootSnapshot_IsReReadRatherThanApplied()
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Task 15: NativeAlarmActor mirrors a source's native alarms — subscribe on start,
|
||||
/// NativeAlarmActor mirrors a source's native alarms — subscribe on start,
|
||||
/// emit enriched AlarmStateChanged, snapshot atomic swap, retention, persistence.
|
||||
/// </summary>
|
||||
public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
@@ -26,6 +26,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly SiteRuntimeOptions _options = new();
|
||||
|
||||
/// <summary>Initializes the test class and its test doubles.</summary>
|
||||
public NativeAlarmActorTests()
|
||||
{
|
||||
// SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the
|
||||
@@ -56,6 +57,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Source(), "inst", instanceActor, dclManager, _storage, options,
|
||||
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider)));
|
||||
|
||||
/// <summary>Verifies that stopping the actor releases the alarm subscription with the owner identity.</summary>
|
||||
[Fact]
|
||||
public void Stop_ReleasesAlarmSubscriptionWithOwnerIdentity()
|
||||
{
|
||||
@@ -68,6 +70,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(actor, dcl.LastSender);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a duplicate snapshot end does not clear a committed alarm.</summary>
|
||||
[Fact]
|
||||
public void DuplicateSnapshotEnd_DoesNotClearCommittedAlarm()
|
||||
{
|
||||
@@ -99,6 +102,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the actor sends a subscribe request for the source binding on start.</summary>
|
||||
[Fact]
|
||||
public void SubscribeOnStart_SendsRequestForSourceBinding()
|
||||
{
|
||||
@@ -111,6 +115,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal("ns=2;s=T01", req.SourceReference);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a raise emits an enriched AlarmStateChanged.</summary>
|
||||
[Fact]
|
||||
public void Raise_EmitsEnrichedAlarmStateChanged()
|
||||
{
|
||||
@@ -130,10 +135,11 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(800, emitted.Condition.Severity);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a raise stamps the native source canonical name from the configured binding.</summary>
|
||||
[Fact]
|
||||
public void Raise_StampsNativeSourceCanonicalName_FromConfiguredBinding()
|
||||
{
|
||||
// DV-1: every emitted condition carries the canonical name of the source
|
||||
// Every emitted condition carries the canonical name of the source
|
||||
// BINDING it belongs to, so the DebugView can nest live native conditions
|
||||
// under their configured binding node. It must equal the binding's
|
||||
// CanonicalName used to construct the actor (Source().CanonicalName).
|
||||
@@ -151,6 +157,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Pressure", emitted.NativeSourceCanonicalName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a snapshot completing with a missing prior alarm emits a return to normal.</summary>
|
||||
[Fact]
|
||||
public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal()
|
||||
{
|
||||
@@ -174,6 +181,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Normal, cleared.State);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an older transition is ignored.</summary>
|
||||
[Fact]
|
||||
public void OlderTransition_IsIgnored()
|
||||
{
|
||||
@@ -196,7 +204,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
// ── PLAN-03 Task 20 (P4): coalesced batched persistence ─────────────────
|
||||
// ── Coalesced batched persistence ─────────────────
|
||||
|
||||
private IActorRef SpawnWithFlush(IActorRef instanceActor, IActorRef dclManager, TimeSpan flushInterval) =>
|
||||
ActorOf(Props.Create(() => new NativeAlarmActor(
|
||||
@@ -208,6 +216,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
sourceRef, AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, severity), time)));
|
||||
|
||||
/// <summary>Verifies that an alarm storm coalesces persistence so the latest write per source reference wins.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmStorm_CoalescesPersistence_LatestPerSourceRefWins()
|
||||
{
|
||||
@@ -226,12 +236,14 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
|
||||
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
|
||||
Assert.Equal(5, rows.Count);
|
||||
// The LAST write for ref-4 carries severity 49 (i = 49, 49 % 5 == 4).
|
||||
// The LAST write for source ref 4 carries severity 49 (i = 49, 49 % 5 == 4).
|
||||
Assert.Contains(rows, r => r.ConditionJson.Contains("49"));
|
||||
}
|
||||
|
||||
// ── PLAN-03 Task 21 (UA4): rehydration restores display metadata ────────
|
||||
// ── Rehydration restores display metadata ────────
|
||||
|
||||
/// <summary>Verifies that rehydration restores the display metadata.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Rehydration_RestoresDisplayMetadata()
|
||||
{
|
||||
@@ -266,8 +278,9 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Tank overflow", emitted.Message);
|
||||
}
|
||||
|
||||
// ── M1.5: site event log `alarm` category ──────────────────────────────
|
||||
// ── Site event log `alarm` category ──────────────────────────────
|
||||
|
||||
/// <summary>Verifies that a raise emits an alarm site event.</summary>
|
||||
[Fact]
|
||||
public void Raise_EmitsAlarmSiteEvent()
|
||||
{
|
||||
@@ -293,6 +306,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a clear emits an informational alarm site event.</summary>
|
||||
[Fact]
|
||||
public void Clear_EmitsInfoAlarmSiteEvent()
|
||||
{
|
||||
@@ -323,6 +337,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rehydration does not emit a site event.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Rehydration_DoesNotEmitSiteEvent()
|
||||
{
|
||||
@@ -352,6 +368,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a snapshot swap with an existing active condition does not re-emit.</summary>
|
||||
[Fact]
|
||||
public void SnapshotSwap_ExistingActiveCondition_DoesNotReEmit()
|
||||
{
|
||||
@@ -386,6 +403,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Single(siteLog.OfType("alarm"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an acknowledge emits an informational alarm site event mentioning acknowledged.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_EmitsInfoAlarmSiteEventMentioningAcknowledged()
|
||||
{
|
||||
@@ -418,17 +436,18 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
// ── SiteRuntime-028: cap eviction emits a return-to-normal for an active drop ──
|
||||
// ── Cap eviction emits a return-to-normal for an active drop ──
|
||||
|
||||
/// <summary>Verifies that enforcing the cap evicts the oldest active condition and emits a return to normal and a drop signal.</summary>
|
||||
[Fact]
|
||||
public void EnforceCap_EvictsActiveOldestCondition_EmitsReturnToNormalAndDropSignal()
|
||||
{
|
||||
// SiteRuntime-028: a cap eviction that drops a still-Active condition without a
|
||||
// A cap eviction that drops a still-Active condition without a
|
||||
// return-to-normal leaves the Instance Actor (and central's stream / the
|
||||
// operator Alarm Summary) showing a phantom Active alarm forever. With cap=1,
|
||||
// raising a second condition evicts the oldest (still Active) one — which must
|
||||
// produce a Normal AlarmStateChanged for the evicted SourceReference, plus the
|
||||
// SiteRuntime-027 NativeAlarmDropped so the parent evicts its stale key.
|
||||
// NativeAlarmDropped so the parent evicts its stale key.
|
||||
var instance = CreateTestProbe();
|
||||
var dcl = CreateTestProbe();
|
||||
var options = new SiteRuntimeOptions { MirroredAlarmCapPerSource = 1 };
|
||||
@@ -456,12 +475,13 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "OLD.Hi");
|
||||
}
|
||||
|
||||
// ── SiteRuntime-027: terminal retention drop signals the parent to evict its key ──
|
||||
// ── Terminal retention drop signals the parent to evict its key ──
|
||||
|
||||
/// <summary>Verifies that a retention drop of a resolved condition emits a return to normal and then a drop signal.</summary>
|
||||
[Fact]
|
||||
public void RetentionDrop_ResolvedCondition_EmitsReturnToNormalThenDropSignal()
|
||||
{
|
||||
// SiteRuntime-027: when a native condition resolves (inactive AND acknowledged)
|
||||
// When a native condition resolves (inactive AND acknowledged)
|
||||
// it drops out of the mirror. The Instance Actor must be told (NativeAlarmDropped)
|
||||
// so its _latestAlarmEvents map does not retain a stale (Normal) entry forever —
|
||||
// otherwise a source that mints a fresh SourceReference per occurrence leaks one
|
||||
@@ -486,6 +506,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "T01.Hi");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Shut the actor system down FIRST: in-flight alarm actors still hold the
|
||||
@@ -500,6 +521,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
|
||||
// ── MES alarm-status API §6.4: AckTime through the mirror ──────────────
|
||||
|
||||
/// <summary>Verifies that the emitted state carries the adapter's ack time verbatim.</summary>
|
||||
[Fact]
|
||||
public void Emit_CarriesTheAdaptersAckTimeVerbatim()
|
||||
{
|
||||
@@ -527,6 +549,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.True(acked.Condition.Acknowledged);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rehydration restores the ack time so it survives failover.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Rehydration_RestoresAckTime_SoItSurvivesFailover()
|
||||
{
|
||||
@@ -561,6 +585,8 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(ackedAt, emitted.AckTime);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that rehydrating a row from before ack times existed leaves the ack time null.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Rehydration_OfAPreAckTimeRow_LeavesAckTimeNull()
|
||||
{
|
||||
@@ -591,6 +617,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Null(emitted.AckTime); // …with no ack time invented
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a lost subscribe response causes the subscribe to be resent.</summary>
|
||||
[Fact]
|
||||
public void LostSubscribeResponse_ResendsSubscribe()
|
||||
{
|
||||
@@ -604,6 +631,9 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
// Swallow the response entirely — the actor must still re-subscribe.
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
/// <summary>Verifies that a late rehydration cannot resurrect a resolved or snapshot-removed alarm.</summary>
|
||||
/// <param name="snapshot">Whether the scenario removes the alarm through a snapshot rather than resolving it.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -627,6 +657,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.DoesNotContain(messages.OfType<AlarmStateChanged>(), e => e.State == AlarmState.Active);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stale success cannot cancel the current subscribe retry.</summary>
|
||||
[Fact]
|
||||
public void SR3_StaleSuccessCannotCancelCurrentSubscribeRetry()
|
||||
{
|
||||
@@ -641,6 +672,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a connection-wide restart discards an interrupted MX snapshot.</summary>
|
||||
[Fact]
|
||||
public void DC5_ConnectionWideRestartDiscardsInterruptedMxSnapshot()
|
||||
{
|
||||
@@ -654,6 +686,7 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a live clear during a snapshot cannot be resurrected by the snapshot end.</summary>
|
||||
[Fact]
|
||||
public void DC5_LiveClearDuringSnapshotCannotBeResurrectedBySnapshotEnd()
|
||||
{
|
||||
|
||||
+29
-8
@@ -47,6 +47,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test class with a fresh local database and the actor-system fixtures.</summary>
|
||||
public ReconcileStartsInstanceTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("reconcile-start-test");
|
||||
@@ -59,6 +60,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
|
||||
@@ -79,13 +81,13 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
/// would still pass, while proving the BOOT LOAD works rather than the startup-reconcile path
|
||||
/// it claims to cover. A sleep only makes that race unlikely; on a loaded machine the load can
|
||||
/// still be in flight when it expires, and the test silently changes what it proves instead of
|
||||
/// failing. The row-78 fix session hit exactly this trap and closed it in
|
||||
/// <c>InstanceSweepSelfHealTests</c>; this is the same gate, applied to row 67's suite.
|
||||
/// failing. An earlier fix session hit exactly this trap and closed it in
|
||||
/// <c>InstanceSweepSelfHealTests</c>; this is the same gate, applied to this suite.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The sweep is disabled (<c>InstanceSweepIntervalSeconds = 0</c>) so these tests keep proving
|
||||
/// the row-67 <see cref="EnsureInstanceStartedCommand"/> path specifically — with the sweep
|
||||
/// the <see cref="EnsureInstanceStartedCommand"/> path specifically — with the sweep
|
||||
/// armed, its periodic self-heal would independently start the same instances and the suite
|
||||
/// would no longer distinguish the two mechanisms.
|
||||
/// </para>
|
||||
@@ -165,7 +167,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
// ── The reproduction ──
|
||||
|
||||
/// <summary>
|
||||
/// THE row-67 reproduction, end to end over one shared storage file: the node boots with no
|
||||
/// THE reproduction, end to end over one shared storage file: the node boots with no
|
||||
/// config for an enabled instance (so the Deployment Manager's startup load starts nothing),
|
||||
/// then the startup reconcile pass fetches and stores that config. The instance must be
|
||||
/// RUNNING afterwards — not merely present in SQLite waiting for a second restart.
|
||||
@@ -176,6 +178,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
/// Instance Actor.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StartupReconcile_StoringConfigForEnabledInstance_StartsTheInstanceActor()
|
||||
{
|
||||
@@ -221,6 +224,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
/// <c>deployment</c> site event naming the instance, so an operator can see that a node
|
||||
/// booted without a config and self-repaired.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReconcileStartedInstance_EmitsASiteEvent()
|
||||
{
|
||||
@@ -319,6 +323,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
/// The command is idempotent: both site nodes' reconcile actors legitimately raise it for
|
||||
/// the same instance, and a repeat must not disturb (or replace) the running actor.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureInstanceStarted_IsIdempotent_WhenAlreadyRunning()
|
||||
{
|
||||
@@ -353,6 +358,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
/// stopped even if an ensure-started request arrives for it (e.g. a disable landed between
|
||||
/// the reconcile fetch and the start request).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureInstanceStarted_DisabledInstance_StaysStopped()
|
||||
{
|
||||
@@ -373,6 +379,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
/// no-op — the normal case on an unreplicated site whose peer node healed its own file.
|
||||
/// The Deployment Manager must not crash or invent an instance.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureInstanceStarted_UnknownInstance_IsAQuietNoOp()
|
||||
{
|
||||
@@ -404,11 +411,12 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
///
|
||||
/// <para>
|
||||
/// This test holds the read open, commits a disable behind it, then releases it — the
|
||||
/// interleaving the fix's store-write-generation stamp exists to reject (arch-review
|
||||
/// 2026-08-19 P1-1). <b>Fails before the fix</b>: the stale enabled snapshot passes every
|
||||
/// interleaving the fix's store-write-generation stamp exists to reject.
|
||||
/// <b>Fails before the fix</b>: the stale enabled snapshot passes every
|
||||
/// guard and the Instance Actor is created for a disabled instance.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureInstanceStarted_ResultLandingAfterADisable_IsDiscarded()
|
||||
{
|
||||
@@ -451,6 +459,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
///
|
||||
/// <para><b>Fails before the fix</b> at the final assert.</para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureInstanceStarted_ResultLandingAfterADelete_IsDiscarded()
|
||||
{
|
||||
@@ -485,8 +494,9 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
/// <summary>
|
||||
/// The complement of the two tests above: with no mutation in the read's window the heal
|
||||
/// still starts the instance, so the generation check rejects superseded snapshots without
|
||||
/// disabling the row-67 repair itself.
|
||||
/// disabling the repair itself.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnsureInstanceStarted_ResultLandingWithNoInterveningWrite_StillStarts()
|
||||
{
|
||||
@@ -523,7 +533,7 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
/// Reads real storage every time and only parks AFTER the read, so the returned snapshot is
|
||||
/// exactly what production would have seen at that instant. Inert until <see cref="Arm"/>
|
||||
/// is called, which keeps the Deployment Manager's boot load — a separate seam
|
||||
/// (<c>_configLoader</c>, register P1-4) untouched by this gate — unblocked.
|
||||
/// (<c>_configLoader</c>) untouched by this gate — unblocked.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private sealed class GatedConfigLoader(SiteStorageService storage)
|
||||
@@ -532,13 +542,20 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
private readonly TaskCompletionSource _parked =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Arms the gate so the next load parks until released.</summary>
|
||||
public void Arm() => _release = new TaskCompletionSource(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Releases the parked load so it can complete.</summary>
|
||||
public void Release() => _release!.TrySetResult();
|
||||
|
||||
/// <summary>Waits until a load has parked at the gate.</summary>
|
||||
/// <returns>A task that completes once a load is parked at the gate.</returns>
|
||||
public Task WaitUntilParkedAsync() => _parked.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
/// <summary>Reads the deployed config from storage and then parks at the gate if armed.</summary>
|
||||
/// <param name="instanceName">The unique name of the instance to load.</param>
|
||||
/// <returns>The stored deployed instance, or null when none exists.</returns>
|
||||
public async Task<DeployedInstance?> LoadAsync(string instanceName)
|
||||
{
|
||||
var config = await storage.GetDeployedConfigAsync(instanceName);
|
||||
@@ -559,10 +576,14 @@ public class ReconcileStartsInstanceTests : TestKit, IDisposable
|
||||
private sealed class FakeConfigFetcher : IDeploymentConfigFetcher
|
||||
{
|
||||
private readonly Func<string, Task<string>> _behavior;
|
||||
/// <summary>Gets the recorded fetch calls (base URL, deployment id, token).</summary>
|
||||
public ConcurrentQueue<(string BaseUrl, string DeploymentId, string Token)> Calls { get; } = new();
|
||||
|
||||
/// <summary>Initializes the fake with the per-deployment-id behavior to run on each fetch.</summary>
|
||||
/// <param name="behavior">Maps a deployment id to the fetch result.</param>
|
||||
public FakeConfigFetcher(Func<string, Task<string>> behavior) => _behavior = behavior;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> FetchAsync(
|
||||
string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
|
||||
{
|
||||
|
||||
+17
-1
@@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for arch-review 2026-08-19 P1-2 — <b>"static overrides are reset on
|
||||
/// Regression tests for arch-review 2026-08-19 — <b>"static overrides are reset on
|
||||
/// redeployment" was racy in both directions</b>.
|
||||
///
|
||||
/// <para>
|
||||
@@ -45,6 +45,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Creates a temporary LocalDb-backed site storage, compilation service and shared script library for each test.</summary>
|
||||
public RedeployStaticOverrideResetTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("redeploy-override-test");
|
||||
@@ -57,6 +58,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
|
||||
@@ -135,6 +137,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
/// <see cref="RedeployResetGate_HoldsTheOverrideLoadUntilTheResetHasCommitted"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Redeploy_DoesNotApplyPreRedeployOverridesToTheNewActor()
|
||||
{
|
||||
@@ -179,6 +182,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
/// after it is released proves the read is ordered behind the reset rather than merely
|
||||
/// losing a race to it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RedeployResetGate_HoldsTheOverrideLoadUntilTheResetHasCommitted()
|
||||
{
|
||||
@@ -223,6 +227,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
// Leg 2: final persistence is acknowledged independently of termination.
|
||||
// The manager waits for both before resetting state; a standalone actor still
|
||||
// persists its final dirty batch asynchronously after stopping.
|
||||
/// <summary>Verifies that stopping an Instance Actor completes its final override flush asynchronously.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task InstanceActorStop_CompletesFinalOverrideFlushAsynchronously()
|
||||
{
|
||||
@@ -264,6 +270,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
/// the clear and durably resurrect the predecessor's values — invisible until the next
|
||||
/// restart loaded them.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Redeploy_LateFlushFromTheStoppedActor_CannotResurrectClearedOverrides()
|
||||
{
|
||||
@@ -297,6 +304,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
/// up on its config defaults, and an override written after it must persist normally. Pins
|
||||
/// that the reset gate did not turn the ordinary deploy path into a no-op.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task FirstDeploy_StillLoadsAndPersistsStaticOverridesNormally()
|
||||
{
|
||||
@@ -318,6 +326,7 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
"corr-get", instanceName, "Temperature", DateTimeOffset.UtcNow));
|
||||
Assert.Equal("123.4", ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5)).Value?.ToString());
|
||||
}
|
||||
/// <summary>Verifies that stopping an actor does not block the dispatcher while the SQLite writer is busy.</summary>
|
||||
[Fact]
|
||||
public void SR5_StopDoesNotBlockDispatcherWhileSqliteWriterIsBusy()
|
||||
{
|
||||
@@ -337,6 +346,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
ExpectTerminated(actor, TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a redeploy waits for the flush acknowledgement after the predecessor terminates.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SR5_RedeployWaitsForFlushAcknowledgementAfterTermination()
|
||||
{
|
||||
@@ -360,6 +371,9 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
Assert.Empty(await _storage.GetStaticOverridesAsync(name));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancelling a queued redeploy stops its predecessor.</summary>
|
||||
/// <param name="delete">Whether the queued operations are deletes rather than redeploys.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -392,6 +406,8 @@ public class RedeployStaticOverrideResetTests : TestKit, IDisposable
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a delete waits for the final flush and rejects an overlapping start.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SR5_DeleteWaitsForFinalFlushAndRejectsAnOverlappingStart()
|
||||
{
|
||||
|
||||
@@ -15,10 +15,8 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP-15: Script Actor and Script Execution Actor tests.
|
||||
/// WP-20: Recursion limit tests.
|
||||
/// WP-22: Tell vs Ask convention tests.
|
||||
/// WP-32: Script error handling tests.
|
||||
/// Script Actor and Script Execution Actor tests, including recursion limit,
|
||||
/// Tell vs Ask convention and script error handling coverage.
|
||||
/// </summary>
|
||||
public class ScriptActorTests : TestKit, IDisposable
|
||||
{
|
||||
@@ -26,7 +24,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
|
||||
// Gitea #16/#18: every ScriptActor built here runs its trigger-expression
|
||||
// Every ScriptActor built here runs its trigger-expression
|
||||
// evaluation and spawned script bodies on THIS class's own scheduler, never the
|
||||
// process-wide singleton. That is what makes the faulted-task test below
|
||||
// un-leakable across the assembly: any worker a test strands (or the 30 s bounded
|
||||
@@ -34,6 +32,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
// teardown, so it can never starve unrelated test classes' script executions.
|
||||
private readonly ScriptExecutionScheduler _scheduler;
|
||||
|
||||
/// <summary>Initializes the test with an isolated script scheduler, shared script library and compilation service.</summary>
|
||||
public ScriptActorTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
@@ -48,6 +47,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
_scheduler = new ScriptExecutionScheduler(_options.ScriptExecutionThreadCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -65,6 +65,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
return script;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that calling a script returns its result.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_CallScript_ReturnsResult()
|
||||
{
|
||||
@@ -91,7 +92,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
null, // serviceProvider
|
||||
_scheduler)));
|
||||
|
||||
// Ask pattern (WP-22) for CallScript
|
||||
// Ask pattern for CallScript
|
||||
scriptActor.Tell(new ScriptCallRequest("GetAnswer", null, 0, "corr-1"));
|
||||
|
||||
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
@@ -99,6 +100,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
Assert.Equal(42, result.ReturnValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that calling a script with parameters passes them to the script.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_CallScript_WithParameters_Works()
|
||||
{
|
||||
@@ -133,6 +135,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
Assert.Equal(7, result.ReturnValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a script actor with no compiled script replies with an error.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_NullCompiledScript_ReturnsError()
|
||||
{
|
||||
@@ -165,6 +168,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
Assert.Contains("not compiled", result.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a value-change trigger spawns a script execution.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_ValueChangeTrigger_SpawnsExecution()
|
||||
{
|
||||
@@ -202,6 +206,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a trigger arriving within the minimum time between runs is skipped.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_MinTimeBetweenRuns_SkipsIfTooSoon()
|
||||
{
|
||||
@@ -243,6 +248,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a script failure does not disable the script actor.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_WP32_ScriptFailure_DoesNotDisable()
|
||||
{
|
||||
@@ -342,6 +348,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
private AttributeValueChanged Change(string attribute, object? value) =>
|
||||
new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>Verifies that the non-numeric fallback comparison of a conditional trigger is culture-invariant.</summary>
|
||||
[Fact]
|
||||
public void ConditionalTrigger_NonNumericFallback_IsCultureInvariant()
|
||||
{
|
||||
@@ -393,6 +400,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a faulted expression evaluation clears the in-flight flag and drains the pending evaluation.</summary>
|
||||
[Fact]
|
||||
public void ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluation()
|
||||
{
|
||||
@@ -406,10 +414,10 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
actor.Tell(Change("A", "1")); // eval starts off-thread and BLOCKS → _evalInFlight = true
|
||||
AwaitAssert(() => Assert.Equal(1, EvalGate.Entries), TimeSpan.FromSeconds(10));
|
||||
|
||||
// WP3.1: the blocked evaluation no longer occupies a script-execution worker at
|
||||
// The blocked evaluation no longer occupies a script-execution worker at
|
||||
// all — it runs on the shared thread pool behind TriggerEvalGate, which is the
|
||||
// whole point of finding #4. The blocking pool must be completely idle here; the
|
||||
// pre-WP3.1 assertion was the opposite (BusyThreadCount == 1).
|
||||
// whole point of moving it off the blocking pool. The blocking pool must be completely idle here; the
|
||||
// earlier assertion was the opposite (BusyThreadCount == 1).
|
||||
Assert.Equal(0, _scheduler.BusyThreadCount);
|
||||
Assert.Equal(0, _scheduler.QueueDepth);
|
||||
|
||||
@@ -433,14 +441,15 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an expression trigger is evaluated off the blocking script pool and still fires.</summary>
|
||||
[Fact]
|
||||
public void ExpressionTrigger_EvaluatesOffTheBlockingScriptPool_AndStillFires()
|
||||
{
|
||||
// WP3.1 retarget of the former P1 assertion, whose sense is deliberately INVERTED.
|
||||
// Retarget of the former P1 assertion, whose sense is deliberately INVERTED.
|
||||
//
|
||||
// P1 moved trigger-expression evaluation off the actor's dispatcher thread and onto
|
||||
// the dedicated script-execution scheduler, and this test asserted exactly that
|
||||
// ("evaluated on a script-execution-* thread"). WP3.1 (finding #4) proved that
|
||||
// ("evaluated on a script-execution-* thread"). Later work proved that
|
||||
// destination wrong: sharing the blocking pool meant N blocked script bodies stalled
|
||||
// every Expression trigger on the node indefinitely. Evaluations are non-blocking by
|
||||
// construction, so they now run as plain async work on the shared .NET thread pool
|
||||
@@ -463,6 +472,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(10)); // fired ⇒ evaluated off the blocking pool
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a while-true conditional trigger fires on the edge and then re-fires while the condition holds.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
|
||||
{
|
||||
@@ -482,6 +492,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 2
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a while-true conditional trigger stops re-firing when the condition goes false.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse()
|
||||
{
|
||||
@@ -501,6 +512,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700)); // re-firing has stopped
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a while-true conditional trigger re-arms after the condition goes false and then true again.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain()
|
||||
{
|
||||
@@ -520,6 +532,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a while-true conditional trigger without a minimum time between runs fires only once.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly()
|
||||
{
|
||||
@@ -535,6 +548,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900)); // no repeats
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an on-true conditional trigger fires on each change while true, without a timer.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer()
|
||||
{
|
||||
@@ -554,6 +568,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a while-true expression trigger re-fires while the expression holds.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds()
|
||||
{
|
||||
@@ -574,6 +589,7 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an on-true expression trigger fires once per false-to-true edge.</summary>
|
||||
[Fact]
|
||||
public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()
|
||||
{
|
||||
@@ -618,7 +634,7 @@ public static class EvalGate
|
||||
/// Resets the gate to a fresh, permit-free state and zeroes the entry counter.
|
||||
/// Called at the start of the test so counts never carry across runs — the old
|
||||
/// static leaked <c>_entries</c> from whatever compiled an expression before it
|
||||
/// (Gitea #16).
|
||||
/// .
|
||||
/// </summary>
|
||||
public static void Reset()
|
||||
{
|
||||
@@ -630,9 +646,10 @@ public static class EvalGate
|
||||
/// Counts the entry, then blocks the calling script-scheduler thread until a permit
|
||||
/// is released. The wait is BOUNDED (30 s): even if a test releases too few permits,
|
||||
/// a stranded evaluation frees its worker instead of parking it for the rest of the
|
||||
/// process — the leak mechanism behind Gitea #16. Returns false so a trigger
|
||||
/// process — the leak mechanism behind stranded workers. Returns false so a trigger
|
||||
/// expression built on it evaluates to "not fired".
|
||||
/// </summary>
|
||||
/// <returns>Always <see langword="false"/>.</returns>
|
||||
public static bool Block()
|
||||
{
|
||||
Interlocked.Increment(ref _entries);
|
||||
|
||||
@@ -27,12 +27,18 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
{
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
/// <summary>The audit events captured so far.</summary>
|
||||
public List<AuditEvent> Events { get; } = new();
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (Events) Events.Add(evt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
/// <summary>Polls the captured events until one with the given action appears or the timeout elapses.</summary>
|
||||
/// <param name="action">The audit action to wait for.</param>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <returns>The first matching event, or <see langword="null"/> if none arrived in time.</returns>
|
||||
public AuditEvent? WaitForKind(string action, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
@@ -55,6 +61,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
private readonly CapturingAuditWriter _writer = new();
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
/// <summary>Creates the shared script library, options, scheduler and service provider used by every test.</summary>
|
||||
public ScriptActorTraceTests()
|
||||
{
|
||||
_sharedLibrary = new SharedScriptLibrary(
|
||||
@@ -67,6 +74,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
_services = sc.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -91,6 +99,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
NullLogger<ScriptActor>.Instance, null, null, null, _services, _scheduler)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced call replies with the execution ID and a ScriptRun trace event.</summary>
|
||||
[Fact]
|
||||
public void Call_WithTrace_ReplyCarriesExecutionId_AndAScriptRunEvent()
|
||||
{
|
||||
@@ -117,6 +126,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
Assert.NotNull(_writer.WaitForKind("ScriptTrace.ScriptRun", TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced call whose script fails returns a failed ScriptRun trace event.</summary>
|
||||
[Fact]
|
||||
public void Call_WithTrace_FailedRun_ReturnsFailedScriptRunEvent()
|
||||
{
|
||||
@@ -132,6 +142,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
Assert.Contains("kaboom", row.ErrorMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced call replies with no trace events but still carries the execution ID.</summary>
|
||||
[Fact]
|
||||
public void Call_WithoutTrace_ReplyHasNoTraceEvents_ButStillCarriesExecutionId()
|
||||
{
|
||||
@@ -147,6 +158,9 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
Assert.Empty(_writer.Events);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced value change makes the triggered run a child of the source execution ID.</summary>
|
||||
/// <param name="inferred">Whether the change is an inferred device update rather than a direct write.</param>
|
||||
/// <param name="triggerSource">The trigger source label stamped on the change.</param>
|
||||
[Theory]
|
||||
[InlineData(false, "value-change")]
|
||||
[InlineData(true, "inferred-device-update")]
|
||||
@@ -170,6 +184,7 @@ public class ScriptActorTraceTests : TestKit, IDisposable
|
||||
Assert.Equal(triggerSource, extra.TriggerSource);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an untraced value change leaves the triggered run as a root and emits no trace events.</summary>
|
||||
[Fact]
|
||||
public void ValueChangeTrigger_UntracedChange_RunStaysRoot_AndEmitsNothing()
|
||||
{
|
||||
|
||||
+6
-3
@@ -12,10 +12,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 test group 3 — a script's execution deadline is armed when the run is ENQUEUED, not
|
||||
/// A script's execution deadline is armed when the run is ENQUEUED, not
|
||||
/// when it is dequeued.
|
||||
///
|
||||
/// <para>Before WP3.1 the deadline <see cref="CancellationTokenSource"/> was constructed inside
|
||||
/// <para>Previously the deadline <see cref="CancellationTokenSource"/> was constructed inside
|
||||
/// the queued body, so a run that spent ten minutes waiting behind blocked scripts still got a
|
||||
/// fresh full 30 s budget when it finally started — and then ran work whose triggering
|
||||
/// condition was long stale. Now queue wait consumes the run's own budget, and a body that
|
||||
@@ -26,6 +26,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
|
||||
/// <summary>Initializes the test with an isolated script execution scheduler.</summary>
|
||||
public ScriptDeadlineAtEnqueueTests()
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
@@ -36,6 +37,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
|
||||
DeadlineHooks.SecondScriptRan = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
DeadlineHooks.Gate.Release(8);
|
||||
@@ -64,6 +66,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
|
||||
NullLogger<ScriptActor>.Instance, null, null, null, serviceProvider, scheduler, null)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a run that dequeues past its deadline is shed without executing its body.</summary>
|
||||
[Fact]
|
||||
public void ARunThatDequeuesPastItsDeadline_IsShedWithoutExecutingItsBody()
|
||||
{
|
||||
@@ -86,7 +89,7 @@ public class ScriptDeadlineAtEnqueueTests : TestKit, IDisposable
|
||||
{
|
||||
ScriptExecutionTimeoutSeconds = 1,
|
||||
StuckScriptGraceMs = 1000,
|
||||
// WP3.2 defaults per-run Started/Completed events OFF, which would make the
|
||||
// The defaults leave per-run Started/Completed events OFF, which would make the
|
||||
// "no started event" assertion below vacuously true whether the body ran or not.
|
||||
// Opt back in so that assertion actually discriminates: a body that reached the
|
||||
// run loop WOULD emit "started" here, and its absence is therefore evidence.
|
||||
|
||||
+6
-5
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
/// <c>ScriptRuntimeContext.CallScript</c> — unreachable.</para>
|
||||
///
|
||||
/// <para>The cap now applies to <c>callDepth == 0</c> launches only: trigger-driven runs and
|
||||
/// depth-0 Ask calls, i.e. genuinely NEW work. Both halves are pinned below.</para>
|
||||
/// depth 0 Ask calls, i.e. genuinely NEW work. Both halves are pinned below.</para>
|
||||
/// </summary>
|
||||
public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
|
||||
{
|
||||
@@ -43,6 +43,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
|
||||
RecursionHooks.Reset();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
RecursionHooks.Gate.Release(64);
|
||||
@@ -142,8 +143,8 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exemption is scoped to nesting: with the cap already full of depth-0 runs, another
|
||||
/// depth-0 request is still shed, while a nested (<c>callDepth > 0</c>) request is
|
||||
/// The exemption is scoped to nesting: with the cap already full of depth 0 runs, another
|
||||
/// depth 0 request is still shed, while a nested (<c>callDepth > 0</c>) request is
|
||||
/// launched. This is the discriminating pin — a blanket "skip the cap" would fail the first
|
||||
/// half, and the pre-fix behaviour fails the second.
|
||||
/// </summary>
|
||||
@@ -164,7 +165,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
|
||||
null, null, health, new SingleServiceProvider(siteLog), _scheduler, null)),
|
||||
"cap-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
// Fill the cap with four depth-0 runs, all blocked in their bodies.
|
||||
// Fill the cap with four depth 0 runs, all blocked in their bodies.
|
||||
for (var i = 0; i < 4; i++)
|
||||
actor.Tell(new ScriptCallRequest("Hot", null, 0, $"corr-{i}"), ActorRefs.NoSender);
|
||||
|
||||
@@ -195,7 +196,7 @@ public class ScriptRecursionVsRunCapTests : TestKit, IDisposable
|
||||
// It is running, not answered: no shed reply reached the nested caller.
|
||||
nested.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Exactly the one depth-0 refusal was counted.
|
||||
// Exactly the one depth 0 refusal was counted.
|
||||
Assert.Equal(1, health.CollectReport("site-1").ScriptRunShedCount);
|
||||
}
|
||||
|
||||
|
||||
+27
-9
@@ -18,10 +18,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 supervision-parity set (design memo §4, seven pins) — the reworked successor to
|
||||
/// Supervision-parity set (design memo §4, seven pins) — the reworked successor to
|
||||
/// <c>ExecutionActorTests</c>.
|
||||
///
|
||||
/// <para>WP3.1 eliminated the short-lived <c>ScriptExecutionActor</c> and
|
||||
/// <para>The refactor eliminated the short-lived <c>ScriptExecutionActor</c> and
|
||||
/// <c>AlarmExecutionActor</c>: neither had a <c>Receive</c> handler, a <c>PostStop</c>, or any
|
||||
/// state, and neither's <c>IActorRef</c> was ever a message target — the entire lifecycle lived
|
||||
/// in a detached task. Runs are now launched directly by the coordinator via
|
||||
@@ -35,9 +35,10 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
|
||||
/// <summary>Own pool per test class (#18 seam), so a wedged body cannot strand the process-wide one.</summary>
|
||||
/// <summary>Own pool per test class, so a wedged body cannot strand the process-wide one.</summary>
|
||||
private readonly ScriptExecutionScheduler _scheduler = new(4);
|
||||
|
||||
/// <summary>Initializes the test with a compilation service and shared script library.</summary>
|
||||
public ScriptRunLauncherParityTests()
|
||||
{
|
||||
_compilationService = new ScriptCompilationService(
|
||||
@@ -46,6 +47,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -72,7 +74,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
MaxScriptCallDepth = 10,
|
||||
ScriptExecutionTimeoutSeconds = timeoutSeconds,
|
||||
StuckScriptGraceMs = graceMs,
|
||||
// WP3.2: per-run Started/Completed Info events default OFF; tests that pin the
|
||||
// Per-run Started/Completed Info events default OFF; tests that pin the
|
||||
// legacy per-run event shape opt back in explicitly via this flag.
|
||||
PerRunScriptEvents = perRunScriptEvents
|
||||
};
|
||||
@@ -104,6 +106,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
|
||||
// ── Pin 1: throwing body — coordinator survives, everything is reported ───────
|
||||
|
||||
/// <summary>Verifies that a throwing script body leaves the script actor alive and reports everything.</summary>
|
||||
[Fact]
|
||||
public void ThrowingScriptBody_LeavesScriptActorAliveAndReportsEverything()
|
||||
{
|
||||
@@ -150,7 +153,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
/// The only failure the removed per-run child could surface was a constructor throw —
|
||||
/// e.g. queueing onto a disposed <see cref="ScriptExecutionScheduler"/>. The old
|
||||
/// <c>OneForOneStrategy</c> logged it and stopped the child, leaving the Ask caller to
|
||||
/// hang with no reply at all. WP3.1 folds that into a launch-path catch that replies, an
|
||||
/// hang with no reply at all. The launcher folds that into a launch-path catch that replies, an
|
||||
/// intentional improvement pinned here so it is explicit rather than accidental.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -182,6 +185,9 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
|
||||
// ── Pin 3: exactly one DI scope per run, disposed on every path ───────────────
|
||||
|
||||
/// <summary>Verifies that each run creates one DI scope and disposes it exactly once.</summary>
|
||||
/// <param name="code">The script source to run.</param>
|
||||
/// <param name="timeoutSeconds">The script timeout in seconds.</param>
|
||||
[Theory]
|
||||
[InlineData("return 1;", 30)] // success
|
||||
[InlineData("throw new InvalidOperationException(\"boom\");", 30)] // failure
|
||||
@@ -240,6 +246,9 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
|
||||
// ── Pin 5: timeout resolution parity (perScript ?? global, <= 0 => global) ────
|
||||
|
||||
/// <summary>Verifies that timeout resolution matches the behavior of the removed execution actor.</summary>
|
||||
/// <param name="globalSeconds">The global script timeout in seconds.</param>
|
||||
/// <param name="perScriptSeconds">The per-script timeout in seconds, or null to use the global timeout.</param>
|
||||
[Theory]
|
||||
[InlineData(300, 1)] // per-script override wins over a much longer global
|
||||
[InlineData(1, null)] // null per-script falls back to the global
|
||||
@@ -377,6 +386,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
|
||||
// ── Retargeted from ExecutionActorTests: success path + operational events ────
|
||||
|
||||
/// <summary>Verifies that a successful run replies with the return value.</summary>
|
||||
[Fact]
|
||||
public void SuccessfulRun_RepliesWithTheReturnValue()
|
||||
{
|
||||
@@ -391,11 +401,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
Assert.Equal(42, result.ReturnValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a successful run emits Started then Completed info events.</summary>
|
||||
[Fact]
|
||||
public void SuccessfulRun_EmitsStartedThenCompletedInfoEvents()
|
||||
{
|
||||
var siteLog = new FakeSiteEventLogger();
|
||||
// WP3.2: per-run Info events are opt-in now (default off, interval summary instead) —
|
||||
// Per-run Info events are opt-in now (default off, interval summary instead) —
|
||||
// this test pins the legacy per-run shape, so it opts back in explicitly.
|
||||
var actor = BuildScriptActor(
|
||||
CompileScript("return 7 * 6;"), Options(perRunScriptEvents: true), new SingleServiceProvider(siteLog));
|
||||
@@ -419,11 +430,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a failing run emits a Started info event then an Error event.</summary>
|
||||
[Fact]
|
||||
public void FailingRun_EmitsStartedInfoThenErrorEvent()
|
||||
{
|
||||
var siteLog = new FakeSiteEventLogger();
|
||||
// WP3.2: the started Info event is opt-in (default off); the Error event on failure
|
||||
// The started Info event is opt-in (default off); the Error event on failure
|
||||
// stays unconditional regardless. This test pins the opted-in shape explicitly.
|
||||
var actor = BuildScriptActor(
|
||||
CompileScript("throw new InvalidOperationException(\"boom\");"),
|
||||
@@ -443,11 +455,12 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a fire-and-forget run needs no reply target.</summary>
|
||||
[Fact]
|
||||
public void FireAndForgetRun_NeedsNoReplyTarget()
|
||||
{
|
||||
var siteLog = new FakeSiteEventLogger();
|
||||
// WP3.2: opted in so the completed Info event (the observable proof the
|
||||
// Opted in so the completed Info event (the observable proof the
|
||||
// fire-and-forget run actually ran to completion) is emitted; the policy itself is
|
||||
// covered separately by ScriptRunEventPolicyTests.
|
||||
var actor = BuildScriptActor(
|
||||
@@ -463,7 +476,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
// ── WP3.2: site_events volume policy — actor-level integration ────────────────
|
||||
// ── site_events volume policy — actor-level integration ────────────────
|
||||
//
|
||||
// The pure ScriptRunEventPolicy matching rules (exact/wildcard/global/non-matching) are
|
||||
// unit-tested directly in ScriptRunEventPolicyTests; these prove the policy is actually
|
||||
@@ -471,6 +484,8 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
// the aggregate still sees the run, per-script opt-in works end to end, and the
|
||||
// IOptionsMonitor read makes the switch hot-togglable without an actor restart.
|
||||
|
||||
/// <summary>Verifies that with default options a successful run emits no per-run events but the summary recorder still sees it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DefaultOptions_SuccessfulRun_EmitsNoPerRunEvents_ButTheSummaryRecorderSeesIt()
|
||||
{
|
||||
@@ -499,6 +514,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
flushLog.OfType("script").Single().Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opting in a script by exact match restores per-run events for that script only.</summary>
|
||||
[Fact]
|
||||
public void PerScriptOptIn_ExactMatch_RestoresPerRunEventsForThatScriptOnly()
|
||||
{
|
||||
@@ -515,6 +531,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
AwaitAssert(() => Assert.Equal(2, siteLog.OfType("script").Count), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opting in with an instance wildcard restores per-run events.</summary>
|
||||
[Fact]
|
||||
public void PerScriptOptIn_InstanceWildcard_RestoresPerRunEvents()
|
||||
{
|
||||
@@ -531,6 +548,7 @@ public class ScriptRunLauncherParityTests : TestKit, IDisposable
|
||||
AwaitAssert(() => Assert.Equal(2, siteLog.OfType("script").Count), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that flipping the options monitor changes the next run with no actor restart.</summary>
|
||||
[Fact]
|
||||
public void HotToggle_FlippingTheOptionsMonitor_ChangesTheNextRunWithNoActorRestart()
|
||||
{
|
||||
|
||||
@@ -21,9 +21,9 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 test group 6 — the per-script in-flight cap and its shed policy.
|
||||
/// Test group 6 — the per-script in-flight cap and its shed policy.
|
||||
///
|
||||
/// <para>Before WP3.1 every trigger spawned another run unconditionally: a trigger firing
|
||||
/// <para>Previously every trigger spawned another run unconditionally: a trigger firing
|
||||
/// faster than its script completes produced unbounded fan-out onto a bounded thread pool.
|
||||
/// The cap (<see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>, default 4) sheds the
|
||||
/// NEWEST run instead. Keeping the four already queued/running — which are closest to their
|
||||
@@ -40,6 +40,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
private readonly ScriptExecutionScheduler _scheduler = new(8);
|
||||
|
||||
/// <summary>Creates the shared script library and the gate that blocks scripts so runs stay in flight.</summary>
|
||||
public ScriptRunShedTests()
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
@@ -49,6 +50,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
|
||||
ShedHooks.Gate = new SemaphoreSlim(0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
ShedHooks.Gate.Release(64);
|
||||
@@ -79,6 +81,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
|
||||
StuckScriptGraceMs = 120_000
|
||||
};
|
||||
|
||||
/// <summary>Verifies that a fifth concurrent run is shed, counted and reported as a site event once, and the caller is answered with an error.</summary>
|
||||
[Fact]
|
||||
public void FifthConcurrentRun_IsShed_Counted_EventedOnce_AndAnsweredWithAnError()
|
||||
{
|
||||
@@ -144,6 +147,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
|
||||
AwaitAssert(() => Assert.Equal(4, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm on-trigger runs are shed at the same cap and produce an alarm-scoped site event.</summary>
|
||||
[Fact]
|
||||
public void AlarmOnTriggerRuns_ShedAtTheSameCap_WithAnAlarmScopedSiteEvent()
|
||||
{
|
||||
@@ -233,17 +237,24 @@ public class ScriptRunShedTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly List<AuditEvent> _events = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
lock (_events) _events.Add(evt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Takes a copy of the rows captured so far.</summary>
|
||||
/// <returns>An array of the captured audit events.</returns>
|
||||
public AuditEvent[] Snapshot()
|
||||
{
|
||||
lock (_events) return _events.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Polls until a captured row matches the predicate or the timeout expires.</summary>
|
||||
/// <param name="predicate">The condition a row must satisfy.</param>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <returns>The first matching audit event, or null if none matched in time.</returns>
|
||||
public AuditEvent? WaitFor(Func<AuditEvent, bool> predicate, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
@@ -279,6 +290,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
|
||||
private static bool IsFailedScriptRun(AuditEvent evt) =>
|
||||
evt.Action == "ScriptTrace.ScriptRun" && evt.AsRow().Status.ToString() == "Failed";
|
||||
|
||||
/// <summary>Verifies that a traced run shed at the cap emits a failed script-run row parented to the writer.</summary>
|
||||
[Fact]
|
||||
public void TracedRun_ShedAtTheCap_EmitsAFailedScriptRunRow_ParentedToTheWriter()
|
||||
{
|
||||
@@ -325,6 +337,7 @@ public class ScriptRunShedTests : TestKit, IDisposable
|
||||
Assert.Equal(4, actor.UnderlyingActor.RunsInFlight);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a traced run suppressed by the minimum time between runs emits a failed script-run row.</summary>
|
||||
[Fact]
|
||||
public void TracedRun_SuppressedByMinTimeBetweenRuns_EmitsAFailedScriptRunRow()
|
||||
{
|
||||
|
||||
+20
-9
@@ -30,14 +30,14 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
/// <c>ApplyDeployment</c>. The two self-heal starts did not:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>StartStoredInstance</c> — the path shared by the row-67 startup-reconcile
|
||||
/// ensure-started command and the row-78 sweep.</item>
|
||||
/// <item><c>StartStoredInstance</c> — the path shared by the startup-reconcile
|
||||
/// ensure-started command and the sweep.</item>
|
||||
/// <item><c>HandleEnableResult</c> — enabling an instance that was disabled at boot.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Row 85's live sequence (2026-08-16).</b> A fresh node-a was seeded by LocalDb CDC
|
||||
/// replication; the row-78 sweep correctly started the enabled instances, and then every tag
|
||||
/// <b>The live sequence (2026-08-16).</b> A fresh node-a was seeded by LocalDb CDC
|
||||
/// replication; the sweep correctly started the enabled instances, and then every tag
|
||||
/// subscribe failed for 6+ minutes with <c>Unknown connection: OPC PLC Simulator</c> /
|
||||
/// <c>No connection actor for OPC PLC Simulator</c>, because nothing on that node had created the
|
||||
/// connection actor. A second pair restart healed it — the boot load creates connections, so it
|
||||
@@ -73,6 +73,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
/// </summary>
|
||||
private readonly ConcurrentQueue<CreateConnectionCommand> _createdConnections = new();
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>SelfHealCreatesDclConnectionTests</c> class.</summary>
|
||||
public SelfHealCreatesDclConnectionTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("dcl-selfheal-test");
|
||||
@@ -84,6 +85,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
|
||||
@@ -170,20 +172,21 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}, TimeSpan.FromSeconds(20));
|
||||
|
||||
// ── The row-85 reproduction: a self-heal start must bring its connections with it ──
|
||||
// ── The reproduction: a self-heal start must bring its connections with it ──
|
||||
|
||||
/// <summary>
|
||||
/// THE row-85 reproduction. The node boots with an empty store, then a config for an ENABLED
|
||||
/// THE reproduction. The node boots with an empty store, then a config for an ENABLED
|
||||
/// instance appears in storage on its own — exactly what LocalDb's CDC sync applier does when
|
||||
/// it seeds a fresh node from its peer. The row-78 sweep starts the Instance Actor; the DCL
|
||||
/// it seeds a fresh node from its peer. The sweep starts the Instance Actor; the DCL
|
||||
/// must be told to create the connection that instance's tags subscribe through.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Fails before the fix</b> at the connection assert: the instance starts (row 78 works)
|
||||
/// <b>Fails before the fix</b> at the connection assert: the instance starts (the sweep works)
|
||||
/// but NO <see cref="CreateConnectionCommand"/> is ever sent, which is the live symptom —
|
||||
/// the instance is up and every tag subscribe is answered <c>Unknown connection</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SweepStartedInstance_AlsoCreatesItsDclConnection()
|
||||
{
|
||||
@@ -204,12 +207,13 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The row-67 startup-reconcile heal shares <c>StartStoredInstance</c> with the row-78 sweep,
|
||||
/// The startup-reconcile heal shares <c>StartStoredInstance</c> with the sweep,
|
||||
/// so it had the identical gap: the reconcile fetched and stored a config, the Deployment
|
||||
/// Manager started the instance, and its connections were never created.
|
||||
///
|
||||
/// <para><b>Fails before the fix.</b></para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReconcileEnsureStartedInstance_AlsoCreatesItsDclConnection()
|
||||
{
|
||||
@@ -235,6 +239,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
///
|
||||
/// <para><b>Fails before the fix.</b></para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task EnabledInstance_DisabledAtBoot_AlsoCreatesItsDclConnection()
|
||||
{
|
||||
@@ -268,6 +273,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
/// <c>_createdConnections</c> hash guard inside <c>EnsureDclConnection</c> were ever lost.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SweepStartedInstance_CreatesTheConnection_ExactlyOnce_AcrossManySweeps()
|
||||
{
|
||||
@@ -290,6 +296,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
/// all. The hash guard is what makes the connection-ensure safe to sit in the creation
|
||||
/// funnel, and this pins it against a config that never changes.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Sweep_SendsNoConnectionCommands_WhenThereIsNothingToHeal()
|
||||
{
|
||||
@@ -319,6 +326,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
/// connections — it passes both before and after the fix, which is the point of a
|
||||
/// refactor guard.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task OrdinaryDeploy_StillCreatesItsDclConnection()
|
||||
{
|
||||
@@ -344,6 +352,7 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
/// stored BEFORE the manager starts, so the boot load — not the sweep — is what creates the
|
||||
/// instance, and it must still bring its connections with it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task BootLoadedInstance_StillCreatesItsDclConnection()
|
||||
{
|
||||
@@ -365,6 +374,8 @@ public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
|
||||
/// </summary>
|
||||
private sealed class DclConnectionSpy : ReceiveActor
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <c>DclConnectionSpy</c> class.</summary>
|
||||
/// <param name="creates">The creates.</param>
|
||||
public DclConnectionSpy(ConcurrentQueue<CreateConnectionCommand> creates)
|
||||
{
|
||||
Receive<CreateConnectionCommand>(creates.Enqueue);
|
||||
|
||||
@@ -25,6 +25,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
/// <summary>Initializes the test class with a fresh local database and the actor-system fixtures.</summary>
|
||||
public SiteReconciliationActorTests()
|
||||
{
|
||||
// SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the
|
||||
@@ -35,6 +36,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Shut the actor system down FIRST: a reconcile continuation may still be
|
||||
@@ -62,6 +64,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
TimeSpan.FromMilliseconds(50), // initial delay — fast for tests
|
||||
askTimeout ?? TimeSpan.FromSeconds(5)))); // ask timeout
|
||||
|
||||
/// <summary>Verifies that an instance missing at the site is fetched from central and stored.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MissingInstance_IsFetchedAndStored()
|
||||
{
|
||||
@@ -101,6 +105,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
Assert.Equal("tok-b", call.Token);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a stale instance is refreshed from central.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StaleInstance_IsRefreshed()
|
||||
{
|
||||
@@ -134,6 +140,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
Assert.Equal("tok-a", call.Token);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an orphaned instance is logged but not deleted.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Orphan_IsLoggedNotDeleted()
|
||||
{
|
||||
@@ -163,6 +171,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
Assert.Empty(fetcher.Calls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no fetch is made when there is no gap between central and the site.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task NoGap_DoesNotFetch()
|
||||
{
|
||||
@@ -181,6 +191,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
Assert.Empty(fetcher.Calls);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a fetch failure for one item does not abort the remaining items.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PerItemFetchFailure_DoesNotAbortTheRest()
|
||||
{
|
||||
@@ -216,6 +228,8 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a superseded item is skipped quietly while the other items are still applied.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SupersededItem_IsSkippedQuietly_OthersStillApply()
|
||||
{
|
||||
@@ -259,6 +273,7 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the actor survives a failed ask to central.</summary>
|
||||
[Fact]
|
||||
public void CentralAskFails_ActorSurvives()
|
||||
{
|
||||
@@ -289,10 +304,14 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
private sealed class FakeConfigFetcher : IDeploymentConfigFetcher
|
||||
{
|
||||
private readonly Func<string, Task<string>> _behavior;
|
||||
/// <summary>Gets the recorded fetch calls (base URL, deployment id, token).</summary>
|
||||
public ConcurrentQueue<(string BaseUrl, string DeploymentId, string Token)> Calls { get; } = new();
|
||||
|
||||
/// <summary>Initializes the fake with the per-deployment-id behavior to run on each fetch.</summary>
|
||||
/// <param name="behavior">Maps a deployment id to the fetch result.</param>
|
||||
public FakeConfigFetcher(Func<string, Task<string>> behavior) => _behavior = behavior;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> FetchAsync(
|
||||
string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
|
||||
{
|
||||
@@ -305,11 +324,23 @@ public class SiteReconciliationActorTests : TestKit, IDisposable
|
||||
/// <summary>Thread-safe capturing logger so tests can assert on emitted warnings.</summary>
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
/// <summary>Gets the captured log entries (level and formatted message).</summary>
|
||||
public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
|
||||
/// <summary>Reports every log level as enabled.</summary>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <returns>Always true.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Records the log entry for later assertions.</summary>
|
||||
/// <typeparam name="TState">The log state type.</typeparam>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <param name="eventId">The event id.</param>
|
||||
/// <param name="state">The log state.</param>
|
||||
/// <param name="exception">The associated exception, if any.</param>
|
||||
/// <param name="formatter">Formats the state and exception into a message.</param>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
|
||||
@@ -13,10 +13,10 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 test group 4 — the stuck-script watchdog now REPLACES the thread a wedged script is
|
||||
/// The stuck-script watchdog now REPLACES the thread a wedged script is
|
||||
/// holding, not just names it.
|
||||
///
|
||||
/// <para>Before WP3.1 the watchdog was observability only: a script blocked in synchronous,
|
||||
/// <para>Previously the watchdog was observability only: a script blocked in synchronous,
|
||||
/// uninterruptible I/O never observes the cooperative cancellation the timeout requests, so
|
||||
/// the pool simply lost that dedicated thread — permanently, and silently apart from one log
|
||||
/// line. Eight such scripts left the site with no script execution at all. The watchdog now
|
||||
@@ -29,6 +29,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
|
||||
/// <summary>Initializes a new instance of <c>StuckScriptWatchdogTests</c>.</summary>
|
||||
public StuckScriptWatchdogTests()
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
@@ -38,6 +39,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
|
||||
WatchdogHooks.Gate = new SemaphoreSlim(0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// Free any still-wedged worker before tearing down, so no test leaves a blocked thread.
|
||||
@@ -81,6 +83,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
|
||||
MaxScriptCallDepth = 10
|
||||
};
|
||||
|
||||
/// <summary>Verifies that wedged script detaches its worker starts a replacement and drains when freed.</summary>
|
||||
[Fact]
|
||||
public void WedgedScript_DetachesItsWorker_StartsAReplacement_AndDrainsWhenFreed()
|
||||
{
|
||||
@@ -107,7 +110,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
// The replacement worker is live: a fresh script runs even though the pool's only
|
||||
// original thread is still blocked. Before WP3.1 this reply never came.
|
||||
// original thread is still blocked. Previously this reply never came.
|
||||
var healthy = BuildScriptActor("Healthy", CompileRaw("return 7;"), options, scheduler, null);
|
||||
var caller = CreateTestProbe();
|
||||
healthy.Tell(new ScriptCallRequest("Healthy", null, 0, "corr-healthy"), caller.Ref);
|
||||
@@ -124,6 +127,7 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
|
||||
Assert.Equal(1, scheduler.MaximumConcurrencyLevel);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that at the detach cap no further replacement is started and an error event is emitted.</summary>
|
||||
[Fact]
|
||||
public void AtTheDetachCap_NoFurtherReplacementIsStarted_AndAnErrorEventIsEmitted()
|
||||
{
|
||||
@@ -155,6 +159,8 @@ public class StuckScriptWatchdogTests : TestKit, IDisposable
|
||||
WatchdogHooks.Gate.Release(2);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that detached thread count is surfaced on the site health report.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachedThreadCount_IsSurfacedOnTheSiteHealthReport()
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 test groups 1 and 2 — the regression pin for arch-review finding #4 (High).
|
||||
/// Test groups 1 and 2 — the regression pin for the trigger-evaluation starvation finding.
|
||||
///
|
||||
/// <para><b>The finding.</b> Trigger-expression evaluation used to be queued onto the same
|
||||
/// fixed 8-thread <see cref="ScriptExecutionScheduler"/> that runs blocking script bodies. Eight
|
||||
@@ -33,6 +33,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SharedScriptLibrary _sharedLibrary;
|
||||
|
||||
/// <summary>Initializes a new instance of the <c>TriggerEvalStarvationTests</c> class.</summary>
|
||||
public TriggerEvalStarvationTests()
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
@@ -42,6 +43,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
|
||||
StarvationHooks.Gate = new SemaphoreSlim(0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
StarvationHooks.Gate.Release(32);
|
||||
@@ -70,11 +72,11 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GROUP 1 — the finding-#4 regression pin. Eight script bodies blocked on a semaphore
|
||||
/// GROUP 1 — the trigger-evaluation starvation regression pin. Eight script bodies blocked on a semaphore
|
||||
/// occupy every thread of an 8-thread pool; an Expression-triggered alarm must still raise
|
||||
/// in well under 2 s.
|
||||
///
|
||||
/// <para>On the pre-WP3.1 wiring this test does not merely fail slowly — it never
|
||||
/// <para>On the previous wiring this test does not merely fail slowly — it never
|
||||
/// completes: the evaluation sits in the same FIFO behind eight bodies that only unblock
|
||||
/// after the assertion window, and its 2 s timeout has not even started ticking.</para>
|
||||
/// </summary>
|
||||
@@ -138,6 +140,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
|
||||
/// false) at its timeout rather than stalling indefinitely — and the trigger must drain
|
||||
/// rather than park, so the next change still evaluates.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SaturatedEvalGate_ResolvesTheQueuedEvaluationAtItsEnqueueAnchoredDeadline()
|
||||
{
|
||||
@@ -169,7 +172,7 @@ public class TriggerEvalStarvationTests : TestKit, IDisposable
|
||||
|
||||
// 3. The next evaluation can never acquire the gate. Its deadline was armed at
|
||||
// enqueue, so it cancels at ~1 s, is treated as false, and clears the alarm.
|
||||
// Pre-WP3.1 the clock started at dequeue, so this would hang forever.
|
||||
// Previously the clock started at dequeue, so this would hang forever.
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 2, "Good", DateTimeOffset.UtcNow));
|
||||
var cleared = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
|
||||
|
||||
Reference in New Issue
Block a user