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); + } }