using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; /// /// — the bind-time clamp of the Scripting section's numeric /// knobs. An absurd value must be pulled into a sane range at bind time rather than surfacing later as an /// inside a script run (timeout * StuckOrphanQuarantineFactor). /// [Trait("Category", "Unit")] public sealed class ScriptingOptionsTests { [Fact] public void Defaults_pass_through_unchanged_with_no_adjustments() { var sanitized = new ScriptingOptions().Sanitized(out var adjustments); adjustments.ShouldBeEmpty(); sanitized.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun); sanitized.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts); sanitized.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor); } [Theory] [InlineData(double.MaxValue, ScriptingOptions.MaxStuckOrphanQuarantineFactor)] [InlineData(double.PositiveInfinity, ScriptingOptions.MaxStuckOrphanQuarantineFactor)] [InlineData(1e300, ScriptingOptions.MaxStuckOrphanQuarantineFactor)] [InlineData(double.NaN, ScriptingOptions.DefaultStuckOrphanQuarantineFactor)] [InlineData(-5, 0)] [InlineData(double.NegativeInfinity, 0)] [InlineData(0, 0)] [InlineData(2.5, 2.5)] public void StuckOrphanQuarantineFactor_is_clamped(double configured, double expected) { var sanitized = new ScriptingOptions { StuckOrphanQuarantineFactor = configured }.Sanitized(out var adjustments); sanitized.StuckOrphanQuarantineFactor.ShouldBe(expected); // Negative already means "disabled" and is documented as such; normalising it to 0 is not worth a warning. if (!double.IsNaN(configured) && configured >= 0 && configured <= ScriptingOptions.MaxStuckOrphanQuarantineFactor) adjustments.ShouldBeEmpty(); } [Theory] [InlineData(0, 1)] [InlineData(-3, 1)] [InlineData(int.MaxValue, ScriptingOptions.MaxQuarantineAfterConsecutiveTimeouts)] [InlineData(5, 5)] public void QuarantineAfterConsecutiveTimeouts_is_clamped(int configured, int expected) => new ScriptingOptions { QuarantineAfterConsecutiveTimeouts = configured }.Sanitized(out _) .QuarantineAfterConsecutiveTimeouts.ShouldBe(expected); [Theory] [InlineData(-1L, 0L)] [InlineData(0L, 0L)] [InlineData(1024L, ScriptingOptions.MinMaxAllocBytesPerRun)] [InlineData(long.MaxValue, long.MaxValue)] public void MaxAllocBytesPerRun_is_clamped(long configured, long expected) => new ScriptingOptions { MaxAllocBytesPerRun = configured }.Sanitized(out _) .MaxAllocBytesPerRun.ShouldBe(expected); [Fact] public void An_out_of_range_value_is_reported_as_an_adjustment_naming_the_key() { _ = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 }.Sanitized(out var adjustments); adjustments.ShouldHaveSingleItem().ShouldContain("Scripting:StuckOrphanQuarantineFactor"); } [Fact] public void Sanitized_returns_a_copy_and_leaves_the_source_untouched() { var source = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 }; var sanitized = source.Sanitized(out _); sanitized.ShouldNotBeSameAs(source); source.StuckOrphanQuarantineFactor.ShouldBe(1e300); } /// /// The guard takes options from more places than the bind (the Calculation driver's per-driver DTO, tests), /// so it must not overflow either: an absurd factor reaching the guard directly is clamped, and a refusal /// behind a young orphan is still a plain refusal rather than an . /// [Fact] public async Task An_absurd_factor_reaching_the_guard_directly_does_not_overflow_on_a_refusal() { var guard = new ScriptRunGuard(new ScriptingOptions { StuckOrphanQuarantineFactor = double.MaxValue }); const string spin = """ var end = System.DateTime.UtcNow.AddSeconds(3); while (System.DateTime.UtcNow < end) { } return 1; """; using var eval = ScriptEvaluator.Compile(spin); var ct = TestContext.Current.CancellationToken; var timeout = TimeSpan.FromMilliseconds(60); await Should.ThrowAsync( () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct)); // The refusal path evaluates timeout * factor. Unclamped, TimeSpan * double.MaxValue throws OverflowException. await Should.ThrowAsync( () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct)); // A huge timeout times the clamped maximum factor must not overflow either. await Should.ThrowAsync( () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), TimeSpan.FromDays(365 * 1000), ct)); } }