feat(testrun): route WaitForAttribute in the Test-Run sandbox (deferred #14)
Closes deferred-work register item #14 with full fidelity. The Central UI Test-Run sandbox previously threw ScriptSandboxException for Attributes.WaitAsync/WaitForAsync; now the value-equality forms route to the bound deployed instance over the same cross-site path inbound Route.To().WaitForAttribute() uses. - Commons: additive trailing RouteToWaitForAttributeRequest.RequireGoodQuality (default false; message-evolution safe) so quality-gated waits route too. - SiteRuntime: DeploymentManagerActor's routed-wait handler now threads request.RequireGoodQuality into the InstanceActor WaitForAttributeRequest (previously hard-coded to default). - CentralUI sandbox: ISandboxInstanceGateway.WaitForAttributeAsync + SandboxInstanceGateway impl (via CommunicationService.RouteToWaitForAttributeAsync); SandboxAttributeAccessor value-equality WaitAsync/WaitForAsync route through it (codec-encoded target, scope-resolved name). Predicate overloads stay unsupported (an in-process lambda can't be routed) and throw a clearer, dedicated message. - Tests: sandbox accessor routing + predicate/unbound rejection (CentralUI); site-handler RequireGoodQuality threading (SiteRuntime). Register + the historical waitfor-deferred plan doc updated. Full slnx build 0/0; CentralUI 59, SiteRuntime routed-wait 4, InboundAPI wait 32 green. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -219,8 +219,8 @@ WD-1 must add `RequireGoodQuality` ONLY as a **trailing defaulted** ctor param o
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (explicit)
|
||||
## Out of scope (explicit — at original authoring)
|
||||
|
||||
- Routed `WaitForAttribute` is NOT wired into the CentralUI Test-Run sandbox (`ISandboxInstanceGateway`/`SandboxInstanceGateway`); production inbound scripts get it. Follow-up if Test-Run parity is wanted.
|
||||
- No predicate or quality flag across the wire (§6 is value-equality only, per spec).
|
||||
- ~~Routed `WaitForAttribute` is NOT wired into the CentralUI Test-Run sandbox~~ **DELIVERED 2026-07-10 (deferred-work #14, full fidelity):** value-equality `Attributes.WaitAsync`/`WaitForAsync` in the sandbox now route to the bound instance via `ISandboxInstanceGateway.WaitForAttributeAsync` → `CommunicationService.RouteToWaitForAttributeAsync`. Predicate overloads stay unsupported (an in-process lambda cannot be routed) and throw a labelled `ScriptSandboxException`.
|
||||
- ~~No quality flag across the wire~~ **Superseded:** `RouteToWaitForAttributeRequest` gained an additive trailing `bool RequireGoodQuality = false`, honored by the site handler, so quality-gated waits route too. Predicate matching is still value-equality-only across the wire (no cross-process lambda).
|
||||
- No docker redeploy (no cluster-runtime config change; additive script surface only).
|
||||
|
||||
@@ -23,7 +23,6 @@ them and are removed from this table when that plan's task lands.
|
||||
| 10 | Aggregated live alarm stream for Alarm Summary | m7 design :252 | Snapshot fan-out acceptable at current instance counts | Alarm Summary latency complaints or >~50 instances/site |
|
||||
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
|
||||
| 12 | Native-alarm-source-override CSV import — **Central UI `InstanceConfigure` upload affordance only** (CLI + Management API + parser shipped 2026-07-10, see Resolved) | m7 follow-ups | CLI/API path closes the operator parity gap; the Blazor upload button is polish | First request to bulk-import native sources from the UI rather than the CLI |
|
||||
| 14 | WaitForAttribute in Test-Run sandbox | waitfor-deferred :222 | Test-Run parity gap; production unaffected (sandbox throws a clearly-labelled ScriptSandboxException; deploy to a site to exercise) | Author feedback on Test-Run fidelity |
|
||||
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
|
||||
| 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) |
|
||||
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
|
||||
@@ -37,6 +36,7 @@ Rows removed from the Deferred table above once confirmed shipped. Kept here for
|
||||
| 7 | SecuredWrite audit rows leave SourceNode NULL | Resolved (PLAN-07): `ManagementActor.EmitSecuredWriteAuditAsync` routes through `ICentralAuditWriter`, which stamps `SourceNode` (`central-a`/`central-b`) from `INodeIdentityProvider`. |
|
||||
| 12 (CLI/API) | Native-alarm-source-override CSV import | Shipped 2026-07-10: shared `CsvLineSplitter`, `NativeAlarmSourceOverrideCsvParser`, bulk all-or-nothing `SetInstanceNativeAlarmSourceOverridesCommand` + ManagementActor handler (Deployer-gated), CLI `instance native-alarm-source import --file`, parser/CLI/handler tests. **UI upload affordance still pending — see row 12 above.** |
|
||||
| 13 | WaitForAttribute quality-gated ("Good"-only) mode | Already implemented (Commons `WaitForAttribute.RequireGoodQuality`, enforced in `InstanceActor`, threaded through `ScriptRuntimeContext`, tested in `InstanceActorWaitForAttributeTests`). Stale "planned enhancement" doc line corrected 2026-07-10. |
|
||||
| 14 | WaitForAttribute in Test-Run sandbox | Shipped 2026-07-10 (full fidelity): sandbox `Attributes.WaitAsync`/`WaitForAsync` (value-equality) route to the bound instance via `ISandboxInstanceGateway.WaitForAttributeAsync` → the existing `CommunicationService.RouteToWaitForAttributeAsync` cross-site route. Additive `RouteToWaitForAttributeRequest.RequireGoodQuality` (honored by the site handler) makes quality-gated waits route too. **Predicate-form waits stay unsupported** (an in-process lambda can't be routed) and throw a labelled `ScriptSandboxException`. Tests: sandbox accessor routing (CentralUI), site-handler quality-flag threading (SiteRuntime). |
|
||||
| 15 | BrowseNext final-page signal not surfaced | Already surfaced (M7 browse work): `RealOpcUaClient` sets `Truncated=false`/`ContinuationToken=null` on the last page; `BrowseNodeResult` carries both; `TreeRow.razor` renders "Load more" only when a continuation token remains — no wasted BrowseNext. |
|
||||
| 16 | StubOpcUaClient throws on browse | Already resolved: `StubOpcUaClient` supports browse + address-space search, covered by `StubOpcUaClientBrowseTests`/`StubOpcUaClientSearchTests`. |
|
||||
| 20 | Deployment EXPIRED-row purge | Already resolved (PLAN-04): `PendingDeploymentPurgeActor` central singleton (spawned in `AkkaHostedService`) ticks `IDeploymentManagerRepository.PurgeExpiredPendingDeploymentsAsync` every `CommunicationOptions.PendingDeploymentPurgeInterval` (default 1h), options-validated, tested. |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
@@ -74,4 +75,22 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
|
||||
$"CallScript(\"{canonicalScriptName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
|
||||
return response.ReturnValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WaitResult> WaitForAttributeAsync(
|
||||
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
|
||||
{
|
||||
// Reuse the inbound-API cross-site wait route (RouteToWaitForAttributeAsync) — the
|
||||
// same real event-driven waiter Route.To().WaitForAttribute() uses. ParentExecutionId
|
||||
// is null for the sandbox path (no inbound execution to link).
|
||||
var request = new RouteToWaitForAttributeRequest(
|
||||
Guid.NewGuid().ToString(), _instanceUniqueName, canonicalName,
|
||||
targetValueEncoded, timeout, DateTimeOffset.UtcNow,
|
||||
ParentExecutionId: null, RequireGoodQuality: requireGoodQuality);
|
||||
var response = await _comms.RouteToWaitForAttributeAsync(_siteId, request, _runToken);
|
||||
if (!response.Success)
|
||||
throw new ScriptSandboxException(
|
||||
$"WaitForAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
|
||||
return new WaitResult(response.Matched, response.Value, response.Quality, response.TimedOut);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,21 @@ public interface ISandboxInstanceGateway
|
||||
/// <returns>The script result, or null if none.</returns>
|
||||
Task<object?> CallScriptAsync(
|
||||
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until the named attribute reaches a target value (value-equality,
|
||||
/// codec-encoded), optionally quality-gated, or the timeout elapses — routed to the
|
||||
/// bound deployed instance cross-site. Value-equality only: a script-side predicate
|
||||
/// cannot be routed, so the sandbox predicate overloads stay unsupported.
|
||||
/// </summary>
|
||||
/// <param name="canonicalName">The (already scope-resolved) canonical attribute name.</param>
|
||||
/// <param name="targetValueEncoded">The <c>AttributeValueCodec</c>-encoded target value, or null for "any change".</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="requireGoodQuality">When true, only a Good-quality value satisfies the wait.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The wait outcome (matched flag, matched value, quality, timed-out flag).</returns>
|
||||
Task<WaitResult> WaitForAttributeAsync(
|
||||
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -212,6 +227,26 @@ public class SandboxInstanceContext
|
||||
"bind one in Test Run to call sibling scripts.");
|
||||
return _gateway.CallScriptAsync(scriptName, ScriptArgs.Normalize(parameters), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a value-equality attribute wait to the bound deployed instance cross-site.
|
||||
/// Needs a bound instance (the site owns the event-driven waiter); throws when unbound.
|
||||
/// </summary>
|
||||
/// <param name="canonicalName">The (already scope-resolved) canonical attribute name.</param>
|
||||
/// <param name="targetValueEncoded">The codec-encoded target value, or null for "any change".</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="requireGoodQuality">When true, only a Good-quality value satisfies the wait.</param>
|
||||
/// <returns>The wait outcome.</returns>
|
||||
public Task<WaitResult> WaitForAttribute(
|
||||
string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality)
|
||||
{
|
||||
if (_gateway == null)
|
||||
throw new ScriptSandboxException(
|
||||
$"WaitForAttribute(\"{canonicalName}\") needs a deployed instance — " +
|
||||
"bind one in Test Run to exercise the event-driven attribute waiter.");
|
||||
return _gateway.WaitForAttributeAsync(
|
||||
canonicalName, targetValueEncoded, timeout, requireGoodQuality, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -342,20 +377,23 @@ public class SandboxAttributeAccessor
|
||||
=> throw NotInSandbox(nameof(WriteBatchAndWaitAsync));
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (value-equality form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// Value-equality attribute wait — routed to the bound deployed instance cross-site
|
||||
/// (the same real event-driven waiter a site uses). Needs a bound instance; throws
|
||||
/// <see cref="ScriptSandboxException"/> when the Test Run has no instance bound.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to observe.</param>
|
||||
/// <param name="targetValue">The value to wait for.</param>
|
||||
/// <param name="targetValue">The value to wait for (codec-normalized), or null for "any change".</param>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
public Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitAsync));
|
||||
/// <returns>True when the attribute reached the target within the timeout; false on timeout.</returns>
|
||||
public async Task<bool> WaitAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> (await _ctx.WaitForAttribute(
|
||||
Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality)).Matched;
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitAsync</c> (predicate form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// Predicate-form attribute wait. Unsupported in Test Run because a script-side
|
||||
/// predicate (an in-process lambda) cannot be evaluated on the remote site — only the
|
||||
/// value-equality overloads route. Throws a clearly-labelled <see cref="ScriptSandboxException"/>.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to observe.</param>
|
||||
/// <param name="predicate">The predicate the attribute value must satisfy.</param>
|
||||
@@ -363,23 +401,25 @@ public class SandboxAttributeAccessor
|
||||
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
public Task<bool> WaitAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitAsync));
|
||||
=> throw PredicateWaitNotRoutable(nameof(WaitAsync));
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (value-equality form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// Value-equality attribute wait returning the full <see cref="WaitResult"/> — routed
|
||||
/// to the bound deployed instance cross-site. Needs a bound instance; throws
|
||||
/// <see cref="ScriptSandboxException"/> when the Test Run has no instance bound.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to observe.</param>
|
||||
/// <param name="targetValue">The value to wait for.</param>
|
||||
/// <param name="targetValue">The value to wait for (codec-normalized), or null for "any change".</param>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
/// <returns>The wait outcome (matched flag, matched value, quality, timed-out flag).</returns>
|
||||
public Task<WaitResult> WaitForAsync(string key, object? targetValue, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitForAsync));
|
||||
=> _ctx.WaitForAttribute(Resolve(key), AttributeValueCodec.Encode(targetValue), timeout, requireGoodQuality);
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox stand-in for <c>AttributeAccessor.WaitForAsync</c> (predicate form);
|
||||
/// see <see cref="WriteBatchAndWaitAsync"/>.
|
||||
/// Predicate-form attribute wait returning the full <see cref="WaitResult"/>.
|
||||
/// Unsupported in Test Run for the same reason as the predicate <see cref="WaitAsync(string, Func{object?, bool}, TimeSpan, bool)"/>
|
||||
/// overload — an in-process predicate cannot be routed to the remote site.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to observe.</param>
|
||||
/// <param name="predicate">The predicate the attribute value must satisfy.</param>
|
||||
@@ -387,12 +427,18 @@ public class SandboxAttributeAccessor
|
||||
/// <param name="requireGoodQuality">Whether the attribute must also have good quality to satisfy the wait.</param>
|
||||
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||
public Task<WaitResult> WaitForAsync(string key, Func<object?, bool> predicate, TimeSpan timeout, bool requireGoodQuality = false)
|
||||
=> throw NotInSandbox(nameof(WaitForAsync));
|
||||
=> throw PredicateWaitNotRoutable(nameof(WaitForAsync));
|
||||
|
||||
private static ScriptSandboxException NotInSandbox(string member) =>
|
||||
new($"{member}(...) drives live device tags and the site's event-driven " +
|
||||
"attribute waiter, which aren't available in the central Test Run sandbox — " +
|
||||
"deploy to a site to exercise batch-write/wait handshakes.");
|
||||
|
||||
private static ScriptSandboxException PredicateWaitNotRoutable(string member) =>
|
||||
new($"{member}(...) with a predicate can't run in Test Run — a script-side " +
|
||||
"predicate is an in-process lambda that cannot be evaluated on the remote " +
|
||||
"site. Use the value-equality overload (which routes to the bound instance), " +
|
||||
"or deploy to a site to exercise predicate waits.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -87,9 +87,10 @@ public record RouteToSetAttributesResponse(
|
||||
/// <summary>
|
||||
/// Request to block until a remote instance attribute reaches a target value
|
||||
/// (spec §6 — <c>Route.To("inst").WaitForAttribute(name, targetValue, timeout)</c>).
|
||||
/// Value-equality ONLY across the wire: <see cref="TargetValueEncoded"/> carries the
|
||||
/// canonical <c>AttributeValueCodec</c>-encoded target; there is no predicate and no
|
||||
/// quality flag in the comparison. The site evaluates equality and either matches or
|
||||
/// Value-equality across the wire: <see cref="TargetValueEncoded"/> carries the
|
||||
/// canonical <c>AttributeValueCodec</c>-encoded target; there is no predicate (a
|
||||
/// cross-process lambda cannot be routed). <see cref="RequireGoodQuality"/> optionally
|
||||
/// gates the match on Good quality. The site evaluates the match and either matches or
|
||||
/// times out.
|
||||
/// </summary>
|
||||
/// <param name="ParentExecutionId">
|
||||
@@ -99,6 +100,12 @@ public record RouteToSetAttributesResponse(
|
||||
/// so the inbound→site execution-tree link survives the wait path. Additive trailing
|
||||
/// member — null for the Central UI sandbox path or for callers built before the field existed.
|
||||
/// </param>
|
||||
/// <param name="RequireGoodQuality">
|
||||
/// Quality-gated ("Good"-only) mode (spec §4.2): when true, a value reaching the target at
|
||||
/// Bad/Uncertain quality is NOT a match — the site holds the wait until the value satisfies
|
||||
/// the target at Good quality (or times out). Additive trailing member; defaults to
|
||||
/// <see langword="false"/> (quality-agnostic) so older callers and the wire default are unchanged.
|
||||
/// </param>
|
||||
public record RouteToWaitForAttributeRequest(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
@@ -106,7 +113,8 @@ public record RouteToWaitForAttributeRequest(
|
||||
string? TargetValueEncoded,
|
||||
TimeSpan Timeout,
|
||||
DateTimeOffset Timestamp,
|
||||
Guid? ParentExecutionId = null);
|
||||
Guid? ParentExecutionId = null,
|
||||
bool RequireGoodQuality = false);
|
||||
|
||||
/// <summary>
|
||||
/// Response from a remote attribute wait. <see cref="Success"/>/<see cref="ErrorMessage"/>
|
||||
|
||||
@@ -1756,10 +1756,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// Spec §6 (WD-2b): unpacks a routed <see cref="RouteToWaitForAttributeRequest"/>
|
||||
/// (inbound-API <c>Route.To().WaitForAttribute()</c>) into the deployed
|
||||
/// Instance Actor's site-local <see cref="WaitForAttributeRequest"/> and relays
|
||||
/// the result back. Value-equality only across the wire — the predicate is null
|
||||
/// and <c>RequireGoodQuality</c> is left at its default. The Ask is bounded by the
|
||||
/// wait timeout plus slack (NOT a fixed 30s), since the wait legitimately blocks
|
||||
/// for up to <see cref="RouteToWaitForAttributeRequest.Timeout"/>.
|
||||
/// the result back. Value-equality across the wire — the predicate is null (a
|
||||
/// cross-process lambda cannot be routed), but <c>RequireGoodQuality</c> is honored
|
||||
/// from <see cref="RouteToWaitForAttributeRequest.RequireGoodQuality"/>. The Ask is
|
||||
/// bounded by the wait timeout plus slack (NOT a fixed 30s), since the wait
|
||||
/// legitimately blocks for up to <see cref="RouteToWaitForAttributeRequest.Timeout"/>.
|
||||
/// </summary>
|
||||
private void RouteInboundApiWaitForAttribute(RouteToWaitForAttributeRequest request)
|
||||
{
|
||||
@@ -1773,10 +1774,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
var sender = Sender;
|
||||
// Routed waits are value-equality only (predicate null); RequireGoodQuality left at default.
|
||||
// Routed waits are value-equality (predicate null — a cross-process lambda
|
||||
// cannot be routed); RequireGoodQuality is honored from the request.
|
||||
var inner = new WaitForAttributeRequest(
|
||||
request.CorrelationId, request.InstanceUniqueName, request.AttributeName,
|
||||
request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow);
|
||||
request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow,
|
||||
request.RequireGoodQuality);
|
||||
|
||||
// Ask bounded by the WAIT timeout + slack — NOT a fixed 30s (the wait legitimately blocks up to request.Timeout).
|
||||
instanceActor.Ask<WaitForAttributeResponse>(inner, request.Timeout + TimeSpan.FromSeconds(5))
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Deferred-work #14 (full fidelity): the Test-Run sandbox routes value-equality
|
||||
/// <c>WaitAsync</c>/<c>WaitForAsync</c> to the bound instance via
|
||||
/// <see cref="ISandboxInstanceGateway"/>; predicate overloads and unbound runs still
|
||||
/// throw a clearly-labelled <see cref="ScriptSandboxException"/>.
|
||||
/// </summary>
|
||||
public class SandboxWaitForAttributeTests
|
||||
{
|
||||
private static SandboxAttributeAccessor BoundAccessor(ISandboxInstanceGateway gateway) =>
|
||||
new(new SandboxInstanceContext(gateway), prefix: string.Empty);
|
||||
|
||||
[Fact]
|
||||
public async Task WaitAsync_ValueForm_RoutesThroughGateway_AndReturnsMatched()
|
||||
{
|
||||
var gateway = Substitute.For<ISandboxInstanceGateway>();
|
||||
gateway.WaitForAttributeAsync("Flag", Arg.Any<string?>(), Arg.Any<TimeSpan>(), false, Arg.Any<CancellationToken>())
|
||||
.Returns(new WaitResult(true, true, "Good", false));
|
||||
var accessor = BoundAccessor(gateway);
|
||||
|
||||
var matched = await accessor.WaitAsync("Flag", true, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(matched);
|
||||
// The target is codec-encoded and the resolved (root-scope) name is passed through.
|
||||
await gateway.Received(1).WaitForAttributeAsync(
|
||||
"Flag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5), false, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAsync_ValueForm_PassesRequireGoodQuality_AndReturnsFullResult()
|
||||
{
|
||||
var gateway = Substitute.For<ISandboxInstanceGateway>();
|
||||
gateway.WaitForAttributeAsync("Done", Arg.Any<string?>(), Arg.Any<TimeSpan>(), true, Arg.Any<CancellationToken>())
|
||||
.Returns(new WaitResult(true, 7, "Good", false));
|
||||
var accessor = BoundAccessor(gateway);
|
||||
|
||||
var result = await accessor.WaitForAsync("Done", 7, TimeSpan.FromSeconds(3), requireGoodQuality: true);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal(7, result.Value);
|
||||
Assert.Equal("Good", result.Quality);
|
||||
await gateway.Received(1).WaitForAttributeAsync(
|
||||
"Done", AttributeValueCodec.Encode(7), TimeSpan.FromSeconds(3), true, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitAsync_PredicateForm_Throws()
|
||||
{
|
||||
var accessor = BoundAccessor(Substitute.For<ISandboxInstanceGateway>());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ScriptSandboxException>(
|
||||
() => accessor.WaitAsync("Done", v => v != null, TimeSpan.FromSeconds(1)));
|
||||
Assert.Contains("predicate", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAsync_PredicateForm_Throws()
|
||||
{
|
||||
var accessor = BoundAccessor(Substitute.For<ISandboxInstanceGateway>());
|
||||
|
||||
await Assert.ThrowsAsync<ScriptSandboxException>(
|
||||
() => accessor.WaitForAsync("Done", v => v != null, TimeSpan.FromSeconds(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitAsync_ValueForm_UnboundInstance_Throws()
|
||||
{
|
||||
// No gateway bound → the sandbox has no instance to route to.
|
||||
var accessor = new SandboxAttributeAccessor(new SandboxInstanceContext(), prefix: string.Empty);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ScriptSandboxException>(
|
||||
() => accessor.WaitAsync("Flag", true, TimeSpan.FromSeconds(1)));
|
||||
Assert.Contains("deployed instance", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -551,6 +551,35 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteInboundApiWaitForAttribute_RequireGoodQuality_IsThreadedToInstanceActor()
|
||||
{
|
||||
// #14 (full fidelity): the additive RouteToWaitForAttributeRequest.RequireGoodQuality
|
||||
// flag must reach the InstanceActor's wait. A static attribute is Good quality, so a
|
||||
// quality-gated wait whose target equals the current value still matches — proving the
|
||||
// flag threads through (and does not spuriously reject a Good value).
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500);
|
||||
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-wait-gq", "WaitPumpGq", "sha256:wait-gq",
|
||||
MakeConfigJson("WaitPumpGq"), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(1000);
|
||||
|
||||
var encodedTarget = AttributeValueCodec.Encode("42");
|
||||
actor.Tell(new RouteToWaitForAttributeRequest(
|
||||
"wait-corr-gq", "WaitPumpGq", "TestAttr", encodedTarget,
|
||||
TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow,
|
||||
ParentExecutionId: null, RequireGoodQuality: true));
|
||||
|
||||
var response = ExpectMsg<RouteToWaitForAttributeResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("wait-corr-gq", response.CorrelationId);
|
||||
Assert.True(response.Success, $"Routed quality-gated wait failed: {response.ErrorMessage}");
|
||||
Assert.True(response.Matched, "A Good-quality attribute at target must satisfy a RequireGoodQuality wait.");
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteInboundApiWaitForAttribute_UnknownInstance_RepliesNotFound()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user