merge: Calculation review findings resolved (Driver.Calculation-005 pump split, Driver.Calculation.Contracts-001 single-parse Inspect)
This commit is contained in:
@@ -41,6 +41,18 @@ public sealed class CalculationDriverTests
|
||||
return (driver, published, logs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait until the driver's evaluation pump has drained. A change-triggered evaluation is queued by
|
||||
/// <c>OnDependencyValue</c> and run on the driver's own worker (Driver.Calculation-005), so a test
|
||||
/// that used to assert on the very next line now settles first. Quiescence, not a sleep: the work
|
||||
/// is enqueued synchronously inside the callback, so "idle" can never be observed before it lands.
|
||||
/// </summary>
|
||||
private static async Task Settle(CalculationDriver driver)
|
||||
{
|
||||
var idle = await WaitUntil(() => driver.IsEvaluationIdle, TimeSpan.FromSeconds(10));
|
||||
idle.ShouldBeTrue("the calc driver's evaluation pump should have drained");
|
||||
}
|
||||
|
||||
// ── dependency-ref extraction ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -64,10 +76,12 @@ public sealed class CalculationDriverTests
|
||||
|
||||
// Only one of two deps arrived → the change-gate holds.
|
||||
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.ShouldBeEmpty();
|
||||
|
||||
// Second dep arrives → now it computes + publishes 30.
|
||||
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.Count.ShouldBe(1);
|
||||
published.TryDequeue(out var e).ShouldBeTrue();
|
||||
e!.FullReference.ShouldBe("calc/e/sum");
|
||||
@@ -90,12 +104,90 @@ public sealed class CalculationDriverTests
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
|
||||
published.ShouldBeEmpty();
|
||||
logs.ShouldBeEmpty("a still-missing declared dependency must hold the gate BEFORE evaluation, " +
|
||||
"not merely before the publish");
|
||||
}
|
||||
|
||||
// ── the dependency callback must not execute scripts (Driver.Calculation-005) ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>OnDependencyValue</c> is called <b>directly and synchronously</b> from
|
||||
/// <c>DependencyConsumerMuxAdapter.OnDependencyValueChanged</c> — i.e. on an Akka actor's own
|
||||
/// dispatcher thread. Evaluating there means a script run (bounded only by <c>RunTimeout</c>,
|
||||
/// default 2 s) occupies that thread, so the callback must queue the work and return instead.
|
||||
/// <para>The publish is the observable: it can only happen after the script ran, so a publish that
|
||||
/// lands on the caller's stack <em>between</em> entering and leaving the callback proves the script
|
||||
/// ran inline. A thread-local flag set for exactly that window is the exact test — comparing thread
|
||||
/// <i>ids</i> is not, because the pump is free to reuse the pooled thread the test released while
|
||||
/// waiting, which makes an id match mean nothing. The value assertion is the positive control:
|
||||
/// without it this would pass just as happily against a driver that evaluates nothing at all.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_dependency_arrival_does_not_run_the_script_on_the_callers_thread()
|
||||
{
|
||||
var (driver, published, _) = Build(
|
||||
CalcTag("calc/e/dbl", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
|
||||
using var insideCallback = new ThreadLocal<bool>();
|
||||
var publishedInline = false;
|
||||
driver.OnDataChange += (_, _) => { if (insideCallback.Value) publishedInline = true; };
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
insideCallback.Value = true;
|
||||
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow);
|
||||
insideCallback.Value = false;
|
||||
|
||||
(await WaitUntil(() => !published.IsEmpty, TimeSpan.FromSeconds(10)))
|
||||
.ShouldBeTrue("the queued evaluation must still compute and publish the value");
|
||||
published.TryDequeue(out var e).ShouldBeTrue();
|
||||
e!.Snapshot.Value.ShouldBe(10.0);
|
||||
|
||||
publishedInline.ShouldBeFalse(
|
||||
"the calc script must run on the driver's own evaluation pump, never on the mux callback thread");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The second half of Driver.Calculation-005: the evaluation also used to run <b>under the state
|
||||
/// lock</b>, so a slow script froze every other lock-taking operation (here <c>ReadAsync</c>) for
|
||||
/// its whole run as well as the callback itself. Both must now return while the script is still
|
||||
/// executing; the trailing publish is the positive control that the script did run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_slow_script_blocks_neither_the_dependency_callback_nor_a_concurrent_read()
|
||||
{
|
||||
// A CPU-bound busy-wait: the sandbox forbids Thread/Task, and DateTime is explicitly allowed.
|
||||
const string slowScript =
|
||||
"var deadline = System.DateTime.UtcNow.AddMilliseconds(600); " +
|
||||
"while (System.DateTime.UtcNow < deadline) { } " +
|
||||
"return (double)ctx.GetTag(\"src/a\").Value;";
|
||||
var (driver, published, _) = Build(CalcTag("calc/e/slow", slowScript));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var elapsed = System.Diagnostics.Stopwatch.StartNew();
|
||||
driver.OnDependencyValue("src/a", 7.0, StatusGood, DateTime.UtcNow);
|
||||
var callbackElapsed = elapsed.Elapsed;
|
||||
|
||||
// ReadAsync takes the same state lock the inline evaluation used to hold for the whole run.
|
||||
var snapshot = await driver.ReadAsync(["calc/e/slow"], TestContext.Current.CancellationToken);
|
||||
var readElapsed = elapsed.Elapsed;
|
||||
|
||||
callbackElapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(300),
|
||||
"the mux callback thread must not wait on the script run");
|
||||
readElapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(300),
|
||||
"the state lock must not be held across the script run");
|
||||
snapshot[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData,
|
||||
"the read got through while the evaluation was still in flight");
|
||||
|
||||
(await WaitUntil(() => !published.IsEmpty, TimeSpan.FromSeconds(10)))
|
||||
.ShouldBeTrue("the slow evaluation must still complete and publish");
|
||||
published.TryDequeue(out var e).ShouldBeTrue();
|
||||
e!.Snapshot.Value.ShouldBe(7.0);
|
||||
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// ── dedup + recompute ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -105,9 +197,12 @@ public sealed class CalculationDriverTests
|
||||
CalcTag("calc/e/dbl", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
|
||||
await driver.InitializeAsync("{}", default);
|
||||
|
||||
// Each arrival is queued with the dependency snapshot that triggered it, so the three evaluations
|
||||
// see 5 / 5 / 7 in that order however far the pump lags behind them.
|
||||
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (publish)
|
||||
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (dedup, no publish)
|
||||
driver.OnDependencyValue("src/a", 7.0, StatusGood, DateTime.UtcNow); // → 14 (publish)
|
||||
await Settle(driver);
|
||||
|
||||
published.Select(p => p.Snapshot.Value).ShouldBe(new object?[] { 10.0, 14.0 });
|
||||
}
|
||||
@@ -123,6 +218,7 @@ public sealed class CalculationDriverTests
|
||||
|
||||
// a = 0 → DivideByZero → Failure → Bad (carrying last-known = null) + one script-log.
|
||||
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.Count.ShouldBe(1);
|
||||
published.TryDequeue(out var bad).ShouldBeTrue();
|
||||
bad!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
|
||||
@@ -133,11 +229,13 @@ public sealed class CalculationDriverTests
|
||||
|
||||
// a = 0 again → still failing, but NOT a fresh transition → no second Bad publish (script-log still emits).
|
||||
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.ShouldBeEmpty();
|
||||
logs.Count.ShouldBe(2);
|
||||
|
||||
// a = 5 → 20 → Good recovery is force-published.
|
||||
driver.OnDependencyValue("src/a", 5, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.Count.ShouldBe(1);
|
||||
published.TryDequeue(out var good).ShouldBeTrue();
|
||||
good!.Snapshot.Value.ShouldBe(20);
|
||||
@@ -175,6 +273,7 @@ public sealed class CalculationDriverTests
|
||||
before[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);
|
||||
|
||||
driver.OnDependencyValue("src/a", 3.0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
var after = await driver.ReadAsync(new[] { "calc/e/x" }, default);
|
||||
after[0].StatusCode.ShouldBe(StatusGood);
|
||||
after[0].Value.ShouldBe(6.0);
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time gate contract for <see cref="CalculationTagDefinitionFactory.Inspect"/>. Its outcomes
|
||||
/// were pinned only by the AdminUI parity corpus (a DB-backed suite); these fix the wording and the
|
||||
/// rejection/advisory split locally, so the single-parse refactor
|
||||
/// (Driver.Calculation.Contracts-001 — <c>Inspect</c> now reads its own already-parsed root instead of
|
||||
/// re-parsing the blob through <c>TryReadTagConfig</c>) is provably behaviour-preserving.
|
||||
/// <para>The invariant underneath all of it: <c>Inspect</c> is never STRICTER than
|
||||
/// <see cref="CalculationTagDefinitionFactory.TryReadTagConfig"/> — the deploy gate can be configured
|
||||
/// to turn warnings into blocking errors, so it may never flag a blob the driver serves happily.</para>
|
||||
/// </summary>
|
||||
public sealed class CalculationTagDefinitionFactoryInspectTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_clean_blob_draws_nothing()
|
||||
{
|
||||
CalculationTagDefinitionFactory
|
||||
.Inspect("""{"scriptId":"s1","scriptSource":"return 1.0;","changeTriggered":true,"timerIntervalMs":5000}""")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("[1,2]")] // not object-shaped at all — some other authoring surface's blob, not ours
|
||||
[InlineData("plain text")]
|
||||
public void A_blank_or_non_object_shaped_blob_is_left_alone(string blob)
|
||||
{
|
||||
CalculationTagDefinitionFactory.Inspect(blob).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unparseable_json_is_reported_as_a_skip()
|
||||
{
|
||||
CalculationTagDefinitionFactory.Inspect("{\"scriptId\":").ShouldBe(
|
||||
[
|
||||
"Calculation TagConfig is not valid JSON — the driver will skip this tag and it will never compute.",
|
||||
]);
|
||||
}
|
||||
|
||||
// The "root is not a JSON object" warning is unreachable from Inspect by construction: the leading-'{'
|
||||
// pre-filter above returns early for every non-object root, and anything that starts with '{' and
|
||||
// parses IS an object. It is kept as a belt-and-braces guard for a future change to that pre-filter,
|
||||
// so there is nothing to pin here beyond the pre-filter cases already covered.
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"scriptId":" "}""")]
|
||||
[InlineData("""{"scriptId":42}""")]
|
||||
public void A_blob_with_no_usable_scriptId_is_reported_as_a_skip(string blob)
|
||||
{
|
||||
CalculationTagDefinitionFactory.Inspect(blob).ShouldContain(
|
||||
"Calculation TagConfig has no usable 'scriptId' (a non-blank string) — the driver does not "
|
||||
+ "treat this as a calc tag, skips it, and it never computes.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_non_boolean_changeTriggered_draws_the_present_but_discarded_advisory()
|
||||
{
|
||||
CalculationTagDefinitionFactory.Inspect("""{"scriptId":"s1","changeTriggered":"false"}""").ShouldBe(
|
||||
[
|
||||
"value '\"false\"' for 'changeTriggered' is not a JSON boolean; anything but an explicit false "
|
||||
+ "is read as TRUE, so this tag is change-triggered whatever the value looks like.",
|
||||
]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-5")]
|
||||
[InlineData("1.5")]
|
||||
[InlineData("\"5000\"")]
|
||||
public void A_timerIntervalMs_that_is_not_a_positive_whole_number_draws_the_present_but_discarded_advisory(string raw)
|
||||
{
|
||||
var warnings = CalculationTagDefinitionFactory.Inspect($$"""{"scriptId":"s1","timerIntervalMs":{{raw}}}""");
|
||||
|
||||
warnings.ShouldBe(
|
||||
[
|
||||
$"value '{raw}' for 'timerIntervalMs' is not a positive whole number of milliseconds; it is "
|
||||
+ "discarded and the tag gets NO periodic re-evaluation.",
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>The rejection and the advisories are independent: a blob can draw both, and the rejection
|
||||
/// always comes first.</summary>
|
||||
[Fact]
|
||||
public void A_rejected_blob_still_reports_its_discarded_fields()
|
||||
{
|
||||
var warnings = CalculationTagDefinitionFactory.Inspect("""{"changeTriggered":1,"timerIntervalMs":0}""");
|
||||
|
||||
warnings.Count.ShouldBe(3);
|
||||
warnings[0].ShouldStartWith("Calculation TagConfig has no usable 'scriptId'");
|
||||
warnings[1].ShouldStartWith("value '1' for 'changeTriggered'");
|
||||
warnings[2].ShouldStartWith("value '0' for 'timerIntervalMs'");
|
||||
}
|
||||
|
||||
/// <summary>Falsification control for the "never stricter than the runtime read" invariant: every blob
|
||||
/// the runtime ACCEPTS but this gate warns about must still be accepted by the runtime read, so an
|
||||
/// advisory can never be mistaken for a rejection.</summary>
|
||||
[Theory]
|
||||
[InlineData("""{"scriptId":"s1","changeTriggered":"yes"}""")]
|
||||
[InlineData("""{"scriptId":"s1","timerIntervalMs":0}""")]
|
||||
public void An_advisory_never_means_the_runtime_read_rejects_the_blob(string blob)
|
||||
{
|
||||
CalculationTagDefinitionFactory.Inspect(blob).ShouldNotBeEmpty();
|
||||
CalculationTagDefinitionFactory.TryReadTagConfig(blob, out _).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user