feat(siteruntime): M2.12 (#25) — emit script Error site event on recursion-limit violation
Inject ISiteEventLogger into ScriptRuntimeContext (additive optional ctor param, defaulted null, all existing callers source-compatible). Add a single private EmitRecursionLimitEventAsync helper that fires-and-forgets a "script"/Error site event; called at both recursion guard sites (CallScript at ~:332 and ScriptCallHelper.CallShared at ~:499). ScriptExecutionActor threads the already-resolved siteEventLogger singleton into the context; AlarmExecutionActor leaves it null (no siteEventLogger wired there). Existing _logger.LogError + throw behaviour unchanged. Tests: RecursionLimitSiteEventTests — 5 tests covering both CallScript and CallShared (ISiteEventLogger.LogEventAsync called once with category "script", severity "Error"; null logger path does not throw).
This commit is contained in:
+176
@@ -0,0 +1,176 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// M2.12 (#25): recursion-limit violations at <c>CallScript</c> and
|
||||
/// <c>CallShared</c> must emit a <c>script</c> Error site event via
|
||||
/// <see cref="ISiteEventLogger"/> in addition to the existing
|
||||
/// <c>ILogger.LogError</c> + throw.
|
||||
/// </summary>
|
||||
public class RecursionLimitSiteEventTests
|
||||
{
|
||||
private const string InstanceName = "test-instance";
|
||||
private const string SourceScript = "ScriptActor:OnTick";
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="ScriptRuntimeContext"/> wired at the recursion
|
||||
/// limit (currentCallDepth == maxCallDepth) so the very next
|
||||
/// <c>CallScript</c> or <c>CallShared</c> call trips the guard.
|
||||
/// </summary>
|
||||
private static ScriptRuntimeContext CreateContextAtLimit(
|
||||
ISiteEventLogger siteEventLogger,
|
||||
int maxCallDepth = 3)
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
var sharedScriptLibrary = new SharedScriptLibrary(
|
||||
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
|
||||
return new ScriptRuntimeContext(
|
||||
ActorRefs.Nobody,
|
||||
ActorRefs.Nobody,
|
||||
sharedScriptLibrary,
|
||||
currentCallDepth: maxCallDepth, // already AT the limit
|
||||
maxCallDepth: maxCallDepth,
|
||||
askTimeout: TimeSpan.FromSeconds(5),
|
||||
instanceName: InstanceName,
|
||||
logger: NullLogger.Instance,
|
||||
siteEventLogger: siteEventLogger);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CallScript
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CallScript_RecursionLimitExceeded_EmitsScriptErrorSiteEvent()
|
||||
{
|
||||
var loggerMock = new Mock<ISiteEventLogger>();
|
||||
loggerMock
|
||||
.Setup(l => l.LogEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string?>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var context = CreateContextAtLimit(loggerMock.Object);
|
||||
|
||||
// The call must throw — recursion limit hit
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => context.CallScript("AnyScript"));
|
||||
|
||||
// Give the fire-and-forget task a moment to complete
|
||||
await Task.Yield();
|
||||
|
||||
loggerMock.Verify(l => l.LogEventAsync(
|
||||
"script",
|
||||
"Error",
|
||||
InstanceName,
|
||||
It.IsAny<string>(), // source (may be null when sourceScript not set)
|
||||
It.Is<string>(m => m.Contains("CallScript") && m.Contains("depth")),
|
||||
null),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallScript_RecursionLimitExceeded_NullLogger_DoesNotThrow()
|
||||
{
|
||||
// Null siteEventLogger — existing behaviour must be fully unchanged:
|
||||
// LogError + throw, no NullReferenceException from the emission path.
|
||||
var context = CreateContextAtLimit(siteEventLogger: null!);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => context.CallScript("AnyScript"));
|
||||
|
||||
Assert.Contains("depth", ex.Message);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CallShared (via Scripts.CallShared)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CallShared_RecursionLimitExceeded_EmitsScriptErrorSiteEvent()
|
||||
{
|
||||
var loggerMock = new Mock<ISiteEventLogger>();
|
||||
loggerMock
|
||||
.Setup(l => l.LogEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string?>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var context = CreateContextAtLimit(loggerMock.Object);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => context.Scripts.CallShared("AnySharedScript"));
|
||||
|
||||
await Task.Yield();
|
||||
|
||||
loggerMock.Verify(l => l.LogEventAsync(
|
||||
"script",
|
||||
"Error",
|
||||
InstanceName,
|
||||
It.IsAny<string>(),
|
||||
It.Is<string>(m => m.Contains("CallShared") && m.Contains("depth")),
|
||||
null),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallShared_RecursionLimitExceeded_NullLogger_DoesNotThrow()
|
||||
{
|
||||
var context = CreateContextAtLimit(siteEventLogger: null!);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => context.Scripts.CallShared("AnySharedScript"));
|
||||
|
||||
Assert.Contains("depth", ex.Message);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verify the event shape precisely (category, severity, message content)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CallScript_RecursionLimitExceeded_EventHasCorrectCategoryAndSeverity()
|
||||
{
|
||||
string? capturedCategory = null;
|
||||
string? capturedSeverity = null;
|
||||
string? capturedInstance = null;
|
||||
string? capturedMessage = null;
|
||||
|
||||
var loggerMock = new Mock<ISiteEventLogger>();
|
||||
loggerMock
|
||||
.Setup(l => l.LogEventAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string?>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string?, string, string, string?>(
|
||||
(cat, sev, inst, src, msg, det) =>
|
||||
{
|
||||
capturedCategory = cat;
|
||||
capturedSeverity = sev;
|
||||
capturedInstance = inst;
|
||||
capturedMessage = msg;
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var context = CreateContextAtLimit(loggerMock.Object, maxCallDepth: 2);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => context.CallScript("DeepScript"));
|
||||
await Task.Yield();
|
||||
|
||||
Assert.Equal("script", capturedCategory);
|
||||
Assert.Equal("Error", capturedSeverity);
|
||||
Assert.Equal(InstanceName, capturedInstance);
|
||||
Assert.NotNull(capturedMessage);
|
||||
Assert.Contains("CallScript", capturedMessage);
|
||||
Assert.Contains("2", capturedMessage); // maxCallDepth in the message
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user