feat(siteruntime): WaitForAsync/WaitResult + quality-gated WaitAsync (spec §3, §4.2)
This commit is contained in:
@@ -27,6 +27,16 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
/// </param>
|
||||
/// <param name="Timeout">How long to wait before self-evicting with a timeout reply.</param>
|
||||
/// <param name="OccurredAtUtc">When the request was issued (UTC).</param>
|
||||
/// <param name="RequireGoodQuality">
|
||||
/// Quality-gated ("Good"-only) mode (spec §4.2): when <see langword="true"/>, a
|
||||
/// match additionally requires the attribute quality to be exactly
|
||||
/// <c>"Good"</c> (<see cref="System.StringComparison.Ordinal"/>) — a value that
|
||||
/// reaches the target / satisfies the predicate at Bad/Uncertain quality is NOT a
|
||||
/// match and the waiter stays pending until the value satisfies the test at Good
|
||||
/// quality (or times out). Defaults to <see langword="false"/> (quality-agnostic:
|
||||
/// the match tests the value only). Trailing/defaulted so existing positional
|
||||
/// constructions compile unchanged.
|
||||
/// </param>
|
||||
public record WaitForAttributeRequest(
|
||||
string CorrelationId,
|
||||
string InstanceName,
|
||||
@@ -34,7 +44,8 @@ public record WaitForAttributeRequest(
|
||||
string? TargetValueEncoded,
|
||||
Func<object?, bool>? Predicate,
|
||||
TimeSpan Timeout,
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
DateTimeOffset OccurredAtUtc,
|
||||
bool RequireGoodQuality = false);
|
||||
|
||||
/// <summary>
|
||||
/// Reply to a <see cref="WaitForAttributeRequest"/>. Exactly one of
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Rich result of an <c>Attributes.WaitForAsync</c> wait (spec §3) — the full
|
||||
/// outcome of waiting for an attribute to reach a value / satisfy a predicate /
|
||||
/// change at all, bounded by a timeout. The <c>Attributes.WaitAsync</c> helpers
|
||||
/// surface only <see cref="Matched"/>; <c>WaitForAsync</c> returns this struct so
|
||||
/// a script can also read the matched <see cref="Value"/>, its <see cref="Quality"/>,
|
||||
/// and distinguish a genuine timeout (<see cref="TimedOut"/>) from a non-match.
|
||||
/// </summary>
|
||||
/// <param name="Matched">
|
||||
/// <see langword="true"/> when the attribute reached the target / satisfied the
|
||||
/// predicate within the timeout (and, in quality-gated mode, at "Good" quality).
|
||||
/// </param>
|
||||
/// <param name="Value">The matched value; <see langword="null"/> on timeout / error.</param>
|
||||
/// <param name="Quality">
|
||||
/// The attribute quality at match time; <see langword="null"/> on the non-match
|
||||
/// paths (timeout / error / cap-exceeded).
|
||||
/// </param>
|
||||
/// <param name="TimedOut"><see langword="true"/> when the timeout fired before a match.</param>
|
||||
public readonly record struct WaitResult(bool Matched, object? Value, string? Quality, bool TimedOut);
|
||||
@@ -577,10 +577,22 @@ public class InstanceActor : ReceiveActor
|
||||
// and return WITHOUT registering (no timeout scheduled).
|
||||
if (_attributes.TryGetValue(req.AttributeName, out var current))
|
||||
{
|
||||
// Effective quality used for BOTH the §4.2 quality gate and the match
|
||||
// reply — the same `?? "Good"` default the reply has always used.
|
||||
_attributeQualities.TryGetValue(req.AttributeName, out var fastQuality);
|
||||
var effectiveQuality = fastQuality ?? "Good";
|
||||
|
||||
bool fastMatch;
|
||||
try
|
||||
{
|
||||
fastMatch = test(current);
|
||||
// §4.2 quality gate ANDed with the value test, both INSIDE the guard:
|
||||
// in quality-gated mode a value already at target but at Bad/Uncertain
|
||||
// quality is NOT a fast match — it falls through to register + schedule
|
||||
// the timeout like any other pending waiter (do NOT fast-reply matched).
|
||||
fastMatch =
|
||||
(!req.RequireGoodQuality
|
||||
|| string.Equals(effectiveQuality, "Good", StringComparison.Ordinal))
|
||||
&& test(current);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -595,9 +607,8 @@ public class InstanceActor : ReceiveActor
|
||||
|
||||
if (fastMatch)
|
||||
{
|
||||
_attributeQualities.TryGetValue(req.AttributeName, out var quality);
|
||||
replyer.Tell(new WaitForAttributeResponse(
|
||||
req.CorrelationId, Matched: true, current, quality ?? "Good", TimedOut: false));
|
||||
req.CorrelationId, Matched: true, current, effectiveQuality, TimedOut: false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -617,7 +628,7 @@ public class InstanceActor : ReceiveActor
|
||||
req.Timeout, Self, new WaitForAttributeTimeout(req.CorrelationId), Self);
|
||||
|
||||
_attributeWaiters[req.CorrelationId] =
|
||||
new PendingWait(req.AttributeName, test, replyer, handle);
|
||||
new PendingWait(req.AttributeName, test, replyer, handle, req.RequireGoodQuality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1101,7 +1112,14 @@ public class InstanceActor : ReceiveActor
|
||||
bool matched;
|
||||
try
|
||||
{
|
||||
matched = pending.Test(changed.Value);
|
||||
// §4.2 quality gate ANDed with the value test, both INSIDE the guard:
|
||||
// in quality-gated mode a value reaching the target at Bad/Uncertain
|
||||
// quality is NOT a match — the waiter stays pending until it satisfies
|
||||
// the test at Good quality (or times out).
|
||||
matched =
|
||||
(!pending.RequireGoodQuality
|
||||
|| string.Equals(changed.Quality, "Good", StringComparison.Ordinal))
|
||||
&& pending.Test(changed.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1410,9 +1428,15 @@ public class InstanceActor : ReceiveActor
|
||||
/// <param name="Test">The match test (decoded-target equality OR site-local predicate OR any-change).</param>
|
||||
/// <param name="Replyer">The original sender to reply to on match / timeout.</param>
|
||||
/// <param name="Timeout">The scheduled timeout handle, canceled on match.</param>
|
||||
/// <param name="RequireGoodQuality">
|
||||
/// Quality-gated ("Good"-only) mode (spec §4.2): when <c>true</c>, the resolve
|
||||
/// loop additionally requires <c>changed.Quality == "Good"</c> before the test
|
||||
/// can match.
|
||||
/// </param>
|
||||
private sealed record PendingWait(
|
||||
string AttributeName,
|
||||
Func<object?, bool> Test,
|
||||
IActorRef Replyer,
|
||||
ICancelable Timeout);
|
||||
ICancelable Timeout,
|
||||
bool RequireGoodQuality);
|
||||
}
|
||||
|
||||
@@ -84,8 +84,10 @@ public class AttributeAccessor
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Quality-agnostic by default (spec §4.2):</b> matching tests the VALUE, not
|
||||
/// the quality — a value arriving at Bad quality still satisfies the wait. A
|
||||
/// quality-gated ("Good"-only) mode is a planned enhancement, deferred per spec §4.2.
|
||||
/// the quality — a value arriving at Bad quality still satisfies the wait. Pass
|
||||
/// <paramref name="requireGoodQuality"/><c>:true</c> for quality-gated ("Good"-only)
|
||||
/// matching: a value reaching the target at Bad/Uncertain quality is ignored and
|
||||
/// the wait holds until the target is reached at "Good" quality (or times out).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
@@ -100,9 +102,13 @@ public class AttributeAccessor
|
||||
/// "match on any change" (matches immediately if the attribute already has a value).
|
||||
/// </param>
|
||||
/// <param name="timeout">How long to wait before returning false.</param>
|
||||
/// <param name="requireGoodQuality">
|
||||
/// <c>true</c> for quality-gated ("Good"-only) matching (spec §4.2); defaults to
|
||||
/// <c>false</c> (quality-agnostic — Bad/Uncertain-quality transients still match).
|
||||
/// </param>
|
||||
/// <returns><c>true</c> on match within the timeout; <c>false</c> on timeout.</returns>
|
||||
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout)
|
||||
=> _ctx.WaitAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout);
|
||||
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> _ctx.WaitAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout, requireGoodQuality);
|
||||
|
||||
/// <summary>
|
||||
/// WaitForAttribute (spec §3-§5): predicate form — waits event-driven until
|
||||
@@ -114,16 +120,60 @@ public class AttributeAccessor
|
||||
/// <para>
|
||||
/// <b>Quality-agnostic by default (spec §4.2):</b> the predicate is tested against
|
||||
/// the VALUE, regardless of quality — a value arriving at Bad quality still
|
||||
/// satisfies the wait if the predicate passes. A quality-gated ("Good"-only) mode
|
||||
/// is a planned enhancement, deferred per spec §4.2.
|
||||
/// satisfies the wait if the predicate passes. Pass <paramref name="requireGoodQuality"/>
|
||||
/// <c>:true</c> for quality-gated ("Good"-only) matching: a value satisfying the
|
||||
/// predicate at Bad/Uncertain quality is ignored until it does so at "Good" quality.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key (scope-resolved before the wait is registered).</param>
|
||||
/// <param name="predicate">The site-local predicate tested against the current value.</param>
|
||||
/// <param name="timeout">How long to wait before returning false.</param>
|
||||
/// <param name="requireGoodQuality">
|
||||
/// <c>true</c> for quality-gated ("Good"-only) matching (spec §4.2); defaults to
|
||||
/// <c>false</c> (quality-agnostic).
|
||||
/// </param>
|
||||
/// <returns><c>true</c> on match within the timeout; <c>false</c> on timeout.</returns>
|
||||
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout)
|
||||
=> _ctx.WaitAttribute(Resolve(key), null, predicate, timeout);
|
||||
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> _ctx.WaitAttribute(Resolve(key), null, predicate, timeout, requireGoodQuality);
|
||||
|
||||
/// <summary>
|
||||
/// WaitForAttribute (spec §3): richer value-equality form — like
|
||||
/// <see cref="WaitAsync(string, object?, TimeSpan, bool)"/> but returns the full
|
||||
/// <see cref="WaitResult"/> (matched flag + matched value + quality + timed-out
|
||||
/// flag) instead of a bare bool. Scope/composition path resolution
|
||||
/// (<see cref="Resolve"/>) is applied to <paramref name="key"/> just like the
|
||||
/// other accessors. Never throws on timeout — a timeout yields
|
||||
/// <c>WaitResult { Matched = false, TimedOut = true }</c>.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key (scope-resolved before the wait is registered).</param>
|
||||
/// <param name="targetValue">
|
||||
/// The value to wait for (codec-encoded for comparison); <c>null</c> means
|
||||
/// "match on any change".
|
||||
/// </param>
|
||||
/// <param name="timeout">How long to wait before returning a timed-out result.</param>
|
||||
/// <param name="requireGoodQuality">
|
||||
/// <c>true</c> for quality-gated ("Good"-only) matching (spec §4.2); defaults to <c>false</c>.
|
||||
/// </param>
|
||||
/// <returns>The full <see cref="WaitResult"/> for the wait.</returns>
|
||||
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> _ctx.WaitAttributeFull(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout, requireGoodQuality);
|
||||
|
||||
/// <summary>
|
||||
/// WaitForAttribute (spec §3): richer predicate form — like
|
||||
/// <see cref="WaitAsync(string, Func{object?, bool}, TimeSpan, bool)"/> but returns
|
||||
/// the full <see cref="WaitResult"/>. Site-local only (the predicate is an
|
||||
/// in-process delegate). Scope/composition path resolution applies. Never throws
|
||||
/// on timeout (<c>WaitResult { Matched = false, TimedOut = true }</c>).
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key (scope-resolved before the wait is registered).</param>
|
||||
/// <param name="predicate">The site-local predicate tested against the current value.</param>
|
||||
/// <param name="timeout">How long to wait before returning a timed-out result.</param>
|
||||
/// <param name="requireGoodQuality">
|
||||
/// <c>true</c> for quality-gated ("Good"-only) matching (spec §4.2); defaults to <c>false</c>.
|
||||
/// </param>
|
||||
/// <returns>The full <see cref="WaitResult"/> for the wait.</returns>
|
||||
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> _ctx.WaitAttributeFull(Resolve(key), null, predicate, timeout, requireGoodQuality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -422,31 +422,75 @@ public class ScriptRuntimeContext
|
||||
/// </param>
|
||||
/// <param name="predicate">Site-local predicate; null when the encoded target is used.</param>
|
||||
/// <param name="timeout">How long to wait before returning false.</param>
|
||||
/// <param name="requireGoodQuality">
|
||||
/// Quality-gated ("Good"-only) mode (spec §4.2): when <see langword="true"/>, a
|
||||
/// value reaching the target / satisfying the predicate at Bad/Uncertain quality
|
||||
/// is NOT a match — the wait holds until the value satisfies the test at Good
|
||||
/// quality (or times out). Defaults to <see langword="false"/> (quality-agnostic).
|
||||
/// </param>
|
||||
/// <returns><c>true</c> on match within the timeout; <c>false</c> on timeout.</returns>
|
||||
public async Task<bool> WaitAttribute(
|
||||
string name, string? targetValueEncoded, Func<object?, bool>? predicate, TimeSpan timeout)
|
||||
string name, string? targetValueEncoded, Func<object?, bool>? predicate, TimeSpan timeout,
|
||||
bool requireGoodQuality = false)
|
||||
=> (await WaitInternal(name, targetValueEncoded, predicate, timeout, requireGoodQuality)).Matched;
|
||||
|
||||
/// <summary>
|
||||
/// WaitForAttribute (spec §3): the richer overload backing <c>Attributes.WaitForAsync</c>
|
||||
/// — identical semantics to <see cref="WaitAttribute"/> but surfaces the full
|
||||
/// <see cref="WaitResult"/> (matched flag + matched value + quality + timed-out
|
||||
/// flag) instead of a bare bool. Never throws on timeout (see <see cref="WaitInternal"/>).
|
||||
/// </summary>
|
||||
/// <param name="name">The scope-resolved attribute name to wait on.</param>
|
||||
/// <param name="targetValueEncoded">The codec-encoded target value; null (with null predicate) means "any change".</param>
|
||||
/// <param name="predicate">Site-local predicate; null when the encoded target is used.</param>
|
||||
/// <param name="timeout">How long to wait before returning a timed-out result.</param>
|
||||
/// <param name="requireGoodQuality">Quality-gated ("Good"-only) mode (spec §4.2); defaults to <see langword="false"/>.</param>
|
||||
/// <returns>The full <see cref="WaitResult"/> — on timeout: <c>Matched:false, TimedOut:true</c>.</returns>
|
||||
public async Task<WaitResult> WaitAttributeFull(
|
||||
string name, string? targetValueEncoded, Func<object?, bool>? predicate, TimeSpan timeout,
|
||||
bool requireGoodQuality = false)
|
||||
{
|
||||
var r = await WaitInternal(name, targetValueEncoded, predicate, timeout, requireGoodQuality);
|
||||
return new WaitResult(r.Matched, r.Value, r.Quality, r.TimedOut);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared core for <see cref="WaitAttribute"/> / <see cref="WaitAttributeFull"/>:
|
||||
/// builds the <see cref="WaitForAttributeRequest"/> (incl. the §4.2
|
||||
/// <paramref name="requireGoodQuality"/> flag), Asks the InstanceActor bounded by
|
||||
/// the script's execution-timeout token, and returns the full response. An
|
||||
/// <see cref="AskTimeoutException"/> (the pathological case where the actor's own
|
||||
/// authoritative timeout reply never arrives — actor stopped/restarted) is caught
|
||||
/// and surfaced as a synthetic non-matched/timed-out response, preserving the
|
||||
/// "never throw on timeout" contract. An <see cref="OperationCanceledException"/> /
|
||||
/// <see cref="TaskCanceledException"/> from the script-deadline token is NOT caught
|
||||
/// — it propagates to abort the script (§4.3).
|
||||
/// </summary>
|
||||
private async Task<WaitForAttributeResponse> WaitInternal(
|
||||
string name, string? targetValueEncoded, Func<object?, bool>? predicate, TimeSpan timeout,
|
||||
bool requireGoodQuality)
|
||||
{
|
||||
var cid = Guid.NewGuid().ToString();
|
||||
var req = new WaitForAttributeRequest(
|
||||
cid, _instanceName, name, targetValueEncoded, predicate, timeout, DateTimeOffset.UtcNow);
|
||||
cid, _instanceName, name, targetValueEncoded, predicate, timeout, DateTimeOffset.UtcNow,
|
||||
requireGoodQuality);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _instanceActor.Ask<WaitForAttributeResponse>(
|
||||
return await _instanceActor.Ask<WaitForAttributeResponse>(
|
||||
req, timeout + _askTimeout, _scriptTimeoutToken);
|
||||
|
||||
return resp.Matched;
|
||||
}
|
||||
catch (AskTimeoutException)
|
||||
{
|
||||
// Pathological: the InstanceActor's own scheduled timeout reply never
|
||||
// arrived (e.g. the actor stopped/restarted under us). The helper's
|
||||
// contract is "false on timeout, never throw" — so swallow and return
|
||||
// false rather than leaking the Ask exception to the script.
|
||||
// contract is "false on timeout, never throw" — so synthesize a
|
||||
// non-matched/timed-out response rather than leaking the Ask exception.
|
||||
// OperationCanceledException / TaskCanceledException from the
|
||||
// script-deadline token are deliberately NOT caught here: they must
|
||||
// propagate to abort the script (§4.3).
|
||||
return false;
|
||||
return new WaitForAttributeResponse(
|
||||
cid, Matched: false, null, null, TimedOut: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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