feat(siteruntime): WaitForAsync/WaitResult + quality-gated WaitAsync (spec §3, §4.2)
This commit is contained in:
+214
@@ -564,4 +564,218 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "again", QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
// ── 9. Quality-gated ("Good"-only) matching (spec §4.2) ──────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a data-connected instance actor with a single attribute backed by a
|
||||
/// DCL probe, draining the initial <c>SubscribeTagsRequest</c>. Used by the
|
||||
/// quality-gate tests, which drive value+quality through the DCL ingest path.
|
||||
/// </summary>
|
||||
private IActorRef CreateDataConnectedActor(
|
||||
string instanceName, string attribute, string tag, string dataType, TestProbe dcl)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = attribute, Value = "init", DataType = dataType,
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
return actor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (change-match): with <c>RequireGoodQuality:true</c>, a value that
|
||||
/// reaches the target but arrives at <b>Bad</b> quality is NOT a match — the
|
||||
/// waiter stays pending and times out.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityGated_ChangeMatch_BadQuality_DoesNotMatch_TimesOut()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qg-bad", "Pump1", "State",
|
||||
target, null, TimeSpan.FromMilliseconds(500), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: true));
|
||||
|
||||
// Value reaches the target but at Bad quality → must NOT match.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
|
||||
// The only reply must be the timeout (no spurious Bad-quality match).
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(3));
|
||||
Assert.False(response.Matched);
|
||||
Assert.True(response.TimedOut);
|
||||
Assert.Equal("wfa-qg-bad", response.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (change-match, quality-agnostic baseline): the SAME Bad-quality
|
||||
/// value-reaches-target scenario DOES match when <c>RequireGoodQuality:false</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityAgnostic_ChangeMatch_BadQuality_Matches()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qa-bad", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: false));
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-qa-bad", response.CorrelationId);
|
||||
Assert.Equal("ready", response.Value);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (change-match): with <c>RequireGoodQuality:true</c>, a value that
|
||||
/// reaches the target at <b>Good</b> quality matches normally. Also proves the
|
||||
/// gate is per-quality not per-value: a Bad-quality arrival at the target is
|
||||
/// skipped, then a Good-quality arrival at the target resolves the waiter.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityGated_ChangeMatch_GoodQuality_Matches()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qg-good", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: true));
|
||||
|
||||
// First arrival at target but Bad quality is skipped (gate holds it pending).
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
|
||||
// Then a Good-quality arrival at the target resolves it.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-qg-good", response.CorrelationId);
|
||||
Assert.Equal("ready", response.Value);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (fast-path): the attribute ALREADY holds the target value at
|
||||
/// <b>Bad</b> quality when the quality-gated waiter registers. The fast-path must
|
||||
/// NOT reply matched — it registers + schedules the timeout like any pending
|
||||
/// waiter, and (here) times out because the value never reaches target at Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityGated_FastPath_AlreadyAtTargetButBad_DoesNotMatch_TimesOut()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
// Seed the attribute to the target value at Bad quality BEFORE registering.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200)); // no waiter yet → no reply
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qg-fp-bad", "Pump1", "State",
|
||||
target, null, TimeSpan.FromMilliseconds(500), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: true));
|
||||
|
||||
// Fast-path quality-fail → registers, then times out (no fast matched reply).
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(3));
|
||||
Assert.False(response.Matched);
|
||||
Assert.True(response.TimedOut);
|
||||
Assert.Equal("wfa-qg-fp-bad", response.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (fast-path, quality-agnostic baseline): the SAME already-at-target-
|
||||
/// but-Bad attribute fast-path MATCHES when <c>RequireGoodQuality:false</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityAgnostic_FastPath_AlreadyAtTargetButBad_Matches()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qa-fp-bad", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: false));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-qa-fp-bad", response.CorrelationId);
|
||||
Assert.Equal("ready", response.Value);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (fast-path): the attribute ALREADY holds the target value at
|
||||
/// <b>Good</b> quality when the quality-gated waiter registers → the fast-path
|
||||
/// matches immediately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityGated_FastPath_AlreadyAtTargetGood_MatchesImmediately()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qg-fp-good", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: true));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-qg-fp-good", response.CorrelationId);
|
||||
Assert.Equal("ready", response.Value);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,4 +219,80 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Flag", req.AttributeName);
|
||||
}
|
||||
|
||||
// ── WaitForAsync (spec §3): scope resolution + populated WaitResult ───────
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAsync_Value_AppliesScopeResolution_AndSurfacesPopulatedWaitResult()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
// Composed scope "TempSensor" — Resolve("Flag") => "TempSensor.Flag".
|
||||
var acc = new AttributeAccessor(ctx, "TempSensor");
|
||||
|
||||
var task = acc.WaitForAsync("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
// The actor receives the scope-resolved, codec-encoded request.
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("TempSensor.Flag", req.AttributeName);
|
||||
Assert.Equal(AttributeValueCodec.Encode(true), req.TargetValueEncoded);
|
||||
Assert.Null(req.Predicate);
|
||||
Assert.False(req.RequireGoodQuality);
|
||||
|
||||
// Reply with a matched response — the accessor must surface the full WaitResult.
|
||||
probe.Reply(new WaitForAttributeResponse(
|
||||
req.CorrelationId, Matched: true, Value: true, Quality: "Good", TimedOut: false));
|
||||
|
||||
var result = await task;
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal(true, result.Value);
|
||||
Assert.Equal("Good", result.Quality);
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAsync_Predicate_AppliesScopeResolution_AndSurfacesWaitResult()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var acc = new AttributeAccessor(ctx, "Motor.TempSensor");
|
||||
|
||||
Func<object?, bool> predicate = _ => true;
|
||||
var task = acc.WaitForAsync("Level", predicate, TimeSpan.FromSeconds(30));
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Motor.TempSensor.Level", req.AttributeName);
|
||||
Assert.Null(req.TargetValueEncoded);
|
||||
Assert.NotNull(req.Predicate);
|
||||
|
||||
probe.Reply(new WaitForAttributeResponse(
|
||||
req.CorrelationId, Matched: true, Value: 42, Quality: "Good", TimedOut: false));
|
||||
|
||||
var result = await task;
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal(42, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAsync_RequireGoodQuality_ThreadsFlagIntoRequest()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var acc = new AttributeAccessor(ctx, "");
|
||||
var task = acc.WaitForAsync("Flag", true, TimeSpan.FromSeconds(30), requireGoodQuality: true);
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(req.RequireGoodQuality);
|
||||
|
||||
probe.Reply(new WaitForAttributeResponse(
|
||||
req.CorrelationId, Matched: false, Value: null, Quality: null, TimedOut: true));
|
||||
|
||||
var result = await task;
|
||||
Assert.False(result.Matched);
|
||||
Assert.True(result.TimedOut);
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user