diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/WaitForAttribute.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/WaitForAttribute.cs
index 6617952b..57480e02 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/WaitForAttribute.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Instance/WaitForAttribute.cs
@@ -27,6 +27,16 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
///
/// How long to wait before self-evicting with a timeout reply.
/// When the request was issued (UTC).
+///
+/// Quality-gated ("Good"-only) mode (spec §4.2): when , a
+/// match additionally requires the attribute quality to be exactly
+/// "Good" ( ) — 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 (quality-agnostic:
+/// the match tests the value only). Trailing/defaulted so existing positional
+/// constructions compile unchanged.
+///
public record WaitForAttributeRequest(
string CorrelationId,
string InstanceName,
@@ -34,7 +44,8 @@ public record WaitForAttributeRequest(
string? TargetValueEncoded,
Func? Predicate,
TimeSpan Timeout,
- DateTimeOffset OccurredAtUtc);
+ DateTimeOffset OccurredAtUtc,
+ bool RequireGoodQuality = false);
///
/// Reply to a . Exactly one of
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/WaitResult.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/WaitResult.cs
new file mode 100644
index 00000000..040da5eb
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/WaitResult.cs
@@ -0,0 +1,21 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
+
+///
+/// Rich result of an Attributes.WaitForAsync 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 Attributes.WaitAsync helpers
+/// surface only ; WaitForAsync returns this struct so
+/// a script can also read the matched , its ,
+/// and distinguish a genuine timeout ( ) from a non-match.
+///
+///
+/// when the attribute reached the target / satisfied the
+/// predicate within the timeout (and, in quality-gated mode, at "Good" quality).
+///
+/// The matched value; on timeout / error.
+///
+/// The attribute quality at match time; on the non-match
+/// paths (timeout / error / cap-exceeded).
+///
+/// when the timeout fired before a match.
+public readonly record struct WaitResult(bool Matched, object? Value, string? Quality, bool TimedOut);
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
index 91043e83..c640db9b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
@@ -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);
}
///
@@ -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
/// The match test (decoded-target equality OR site-local predicate OR any-change).
/// The original sender to reply to on match / timeout.
/// The scheduled timeout handle, canceled on match.
+ ///
+ /// Quality-gated ("Good"-only) mode (spec §4.2): when true , the resolve
+ /// loop additionally requires changed.Quality == "Good" before the test
+ /// can match.
+ ///
private sealed record PendingWait(
string AttributeName,
Func Test,
IActorRef Replyer,
- ICancelable Timeout);
+ ICancelable Timeout,
+ bool RequireGoodQuality);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs
index f2388396..35d29d7c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs
@@ -84,8 +84,10 @@ public class AttributeAccessor
///
///
/// Quality-agnostic by default (spec §4.2): 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
+ /// :true 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).
///
///
///
@@ -100,9 +102,13 @@ public class AttributeAccessor
/// "match on any change" (matches immediately if the attribute already has a value).
///
/// How long to wait before returning false.
+ ///
+ /// true for quality-gated ("Good"-only) matching (spec §4.2); defaults to
+ /// false (quality-agnostic — Bad/Uncertain-quality transients still match).
+ ///
/// true on match within the timeout; false on timeout.
- public Task WaitAsync(string key, object? targetValue, TimeSpan timeout)
- => _ctx.WaitAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout);
+ public Task WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
+ => _ctx.WaitAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout, requireGoodQuality);
///
/// WaitForAttribute (spec §3-§5): predicate form — waits event-driven until
@@ -114,16 +120,60 @@ public class AttributeAccessor
///
/// Quality-agnostic by default (spec §4.2): 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
+ /// :true for quality-gated ("Good"-only) matching: a value satisfying the
+ /// predicate at Bad/Uncertain quality is ignored until it does so at "Good" quality.
///
///
/// The attribute key (scope-resolved before the wait is registered).
/// The site-local predicate tested against the current value.
/// How long to wait before returning false.
+ ///
+ /// true for quality-gated ("Good"-only) matching (spec §4.2); defaults to
+ /// false (quality-agnostic).
+ ///
/// true on match within the timeout; false on timeout.
- public Task WaitAsync(string key, Func predicate, TimeSpan timeout)
- => _ctx.WaitAttribute(Resolve(key), null, predicate, timeout);
+ public Task WaitAsync(string key, Func predicate, TimeSpan timeout, bool requireGoodQuality = false)
+ => _ctx.WaitAttribute(Resolve(key), null, predicate, timeout, requireGoodQuality);
+
+ ///
+ /// WaitForAttribute (spec §3): richer value-equality form — like
+ /// but returns the full
+ /// (matched flag + matched value + quality + timed-out
+ /// flag) instead of a bare bool. Scope/composition path resolution
+ /// ( ) is applied to just like the
+ /// other accessors. Never throws on timeout — a timeout yields
+ /// WaitResult { Matched = false, TimedOut = true } .
+ ///
+ /// The attribute key (scope-resolved before the wait is registered).
+ ///
+ /// The value to wait for (codec-encoded for comparison); null means
+ /// "match on any change".
+ ///
+ /// How long to wait before returning a timed-out result.
+ ///
+ /// true for quality-gated ("Good"-only) matching (spec §4.2); defaults to false .
+ ///
+ /// The full for the wait.
+ public Task WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
+ => _ctx.WaitAttributeFull(Resolve(key), AttributeValueCodec.Encode(targetValue), null, timeout, requireGoodQuality);
+
+ ///
+ /// WaitForAttribute (spec §3): richer predicate form — like
+ /// but returns
+ /// the full . Site-local only (the predicate is an
+ /// in-process delegate). Scope/composition path resolution applies. Never throws
+ /// on timeout (WaitResult { Matched = false, TimedOut = true } ).
+ ///
+ /// The attribute key (scope-resolved before the wait is registered).
+ /// The site-local predicate tested against the current value.
+ /// How long to wait before returning a timed-out result.
+ ///
+ /// true for quality-gated ("Good"-only) matching (spec §4.2); defaults to false .
+ ///
+ /// The full for the wait.
+ public Task WaitForAsync(string key, Func predicate, TimeSpan timeout, bool requireGoodQuality = false)
+ => _ctx.WaitAttributeFull(Resolve(key), null, predicate, timeout, requireGoodQuality);
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
index 025794f0..7ace9074 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs
@@ -422,31 +422,75 @@ public class ScriptRuntimeContext
///
/// Site-local predicate; null when the encoded target is used.
/// How long to wait before returning false.
+ ///
+ /// Quality-gated ("Good"-only) mode (spec §4.2): when , 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 (quality-agnostic).
+ ///
/// true on match within the timeout; false on timeout.
public async Task WaitAttribute(
- string name, string? targetValueEncoded, Func? predicate, TimeSpan timeout)
+ string name, string? targetValueEncoded, Func? predicate, TimeSpan timeout,
+ bool requireGoodQuality = false)
+ => (await WaitInternal(name, targetValueEncoded, predicate, timeout, requireGoodQuality)).Matched;
+
+ ///
+ /// WaitForAttribute (spec §3): the richer overload backing Attributes.WaitForAsync
+ /// — identical semantics to but surfaces the full
+ /// (matched flag + matched value + quality + timed-out
+ /// flag) instead of a bare bool. Never throws on timeout (see ).
+ ///
+ /// The scope-resolved attribute name to wait on.
+ /// The codec-encoded target value; null (with null predicate) means "any change".
+ /// Site-local predicate; null when the encoded target is used.
+ /// How long to wait before returning a timed-out result.
+ /// Quality-gated ("Good"-only) mode (spec §4.2); defaults to .
+ /// The full — on timeout: Matched:false, TimedOut:true .
+ public async Task WaitAttributeFull(
+ string name, string? targetValueEncoded, Func? 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);
+ }
+
+ ///
+ /// Shared core for / :
+ /// builds the (incl. the §4.2
+ /// flag), Asks the InstanceActor bounded by
+ /// the script's execution-timeout token, and returns the full response. An
+ /// (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 /
+ /// from the script-deadline token is NOT caught
+ /// — it propagates to abort the script (§4.3).
+ ///
+ private async Task WaitInternal(
+ string name, string? targetValueEncoded, Func? 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(
+ return await _instanceActor.Ask(
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);
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs
index 8745d53a..ca80f112 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs
@@ -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) ──────────────────
+
+ ///
+ /// Builds a data-connected instance actor with a single attribute backed by a
+ /// DCL probe, draining the initial SubscribeTagsRequest . Used by the
+ /// quality-gate tests, which drive value+quality through the DCL ingest path.
+ ///
+ 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.Instance,
+ dcl.Ref)));
+
+ dcl.ExpectMsg(TimeSpan.FromSeconds(5));
+ return actor;
+ }
+
+ ///
+ /// Spec §4.2 (change-match): with RequireGoodQuality:true , a value that
+ /// reaches the target but arrives at Bad quality is NOT a match — the
+ /// waiter stays pending and times out.
+ ///
+ [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(TimeSpan.FromSeconds(3));
+ Assert.False(response.Matched);
+ Assert.True(response.TimedOut);
+ Assert.Equal("wfa-qg-bad", response.CorrelationId);
+ }
+
+ ///
+ /// Spec §4.2 (change-match, quality-agnostic baseline): the SAME Bad-quality
+ /// value-reaches-target scenario DOES match when RequireGoodQuality:false .
+ ///
+ [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(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);
+ }
+
+ ///
+ /// Spec §4.2 (change-match): with RequireGoodQuality:true , a value that
+ /// reaches the target at Good 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.
+ ///
+ [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(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);
+ }
+
+ ///
+ /// Spec §4.2 (fast-path): the attribute ALREADY holds the target value at
+ /// Bad 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.
+ ///
+ [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(TimeSpan.FromSeconds(3));
+ Assert.False(response.Matched);
+ Assert.True(response.TimedOut);
+ Assert.Equal("wfa-qg-fp-bad", response.CorrelationId);
+ }
+
+ ///
+ /// Spec §4.2 (fast-path, quality-agnostic baseline): the SAME already-at-target-
+ /// but-Bad attribute fast-path MATCHES when RequireGoodQuality:false .
+ ///
+ [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(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);
+ }
+
+ ///
+ /// Spec §4.2 (fast-path): the attribute ALREADY holds the target value at
+ /// Good quality when the quality-gated waiter registers → the fast-path
+ /// matches immediately.
+ ///
+ [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(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);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs
index 91848839..573628f2 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs
@@ -219,4 +219,80 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
var req = probe.ExpectMsg(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(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 predicate = _ => true;
+ var task = acc.WaitForAsync("Level", predicate, TimeSpan.FromSeconds(30));
+
+ var req = probe.ExpectMsg(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(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);
+ }
}