feat(scripting): evaluators log through root script logger → script-log page (F8)
This commit is contained in:
@@ -61,4 +61,34 @@ public sealed class ScriptLoggerFactory
|
|||||||
throw new ArgumentException("Script name is required.", nameof(scriptName));
|
throw new ArgumentException("Script name is required.", nameof(scriptName));
|
||||||
return _rootLogger.ForContext(ScriptNameProperty, scriptName);
|
return _rootLogger.ForContext(ScriptNameProperty, scriptName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a per-evaluation script logger bound with the supplied identity properties.
|
||||||
|
/// <see cref="ScriptIdProperty"/> is always bound; <see cref="VirtualTagIdProperty"/>,
|
||||||
|
/// <see cref="AlarmIdProperty"/>, and <see cref="EquipmentIdProperty"/> are bound only
|
||||||
|
/// when the corresponding argument is non-null. Every event the returned logger emits
|
||||||
|
/// carries these properties so the <see cref="ScriptLogTopicSink"/> can attribute each
|
||||||
|
/// line on the Script-log page.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scriptId">The Script row identifier; always bound. Required.</param>
|
||||||
|
/// <param name="virtualTagId">VirtualTag context, when the script runs against a virtual tag.</param>
|
||||||
|
/// <param name="alarmId">ScriptedAlarm context, when the script runs an alarm predicate.</param>
|
||||||
|
/// <param name="equipmentId">Equipment scope, for per-equipment script evaluations.</param>
|
||||||
|
/// <returns>An ILogger instance bound with the supplied identity properties.</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when <paramref name="scriptId"/> is null or whitespace.</exception>
|
||||||
|
public ILogger Create(
|
||||||
|
string scriptId,
|
||||||
|
string? virtualTagId = null,
|
||||||
|
string? alarmId = null,
|
||||||
|
string? equipmentId = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptId))
|
||||||
|
throw new ArgumentException("Script id is required.", nameof(scriptId));
|
||||||
|
|
||||||
|
var logger = _rootLogger.ForContext(ScriptIdProperty, scriptId);
|
||||||
|
if (virtualTagId is not null) logger = logger.ForContext(VirtualTagIdProperty, virtualTagId);
|
||||||
|
if (alarmId is not null) logger = logger.ForContext(AlarmIdProperty, alarmId);
|
||||||
|
if (equipmentId is not null) logger = logger.ForContext(EquipmentIdProperty, equipmentId);
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
using SerilogLogger = Serilog.ILogger;
|
using SerilogLogger = Serilog.ILogger;
|
||||||
using SerilogLog = Serilog.Log;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
@@ -22,20 +21,24 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
|
public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
|
||||||
{
|
{
|
||||||
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynScriptedAlarmEvaluator>();
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, ScriptEvaluator<AlarmPredicateContext, bool>> _cache
|
private readonly ConcurrentDictionary<string, ScriptEvaluator<AlarmPredicateContext, bool>> _cache
|
||||||
= new(StringComparer.Ordinal);
|
= new(StringComparer.Ordinal);
|
||||||
private readonly ILogger<RoslynScriptedAlarmEvaluator> _logger;
|
private readonly ILogger<RoslynScriptedAlarmEvaluator> _logger;
|
||||||
|
private readonly SerilogLogger _scriptRoot;
|
||||||
private readonly TimeSpan _runTimeout;
|
private readonly TimeSpan _runTimeout;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the Roslyn scripted alarm evaluator.</summary>
|
/// <summary>Initializes a new instance of the Roslyn scripted alarm evaluator.</summary>
|
||||||
/// <param name="logger">Logger for diagnostic messages.</param>
|
/// <param name="logger">Logger for diagnostic messages (host diagnostics).</param>
|
||||||
|
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
||||||
/// <param name="runTimeout">Optional timeout for script evaluation; defaults to 2 seconds.</param>
|
/// <param name="runTimeout">Optional timeout for script evaluation; defaults to 2 seconds.</param>
|
||||||
public RoslynScriptedAlarmEvaluator(ILogger<RoslynScriptedAlarmEvaluator> logger, TimeSpan? runTimeout = null)
|
public RoslynScriptedAlarmEvaluator(
|
||||||
|
ILogger<RoslynScriptedAlarmEvaluator> logger,
|
||||||
|
ScriptRootLogger scriptRoot,
|
||||||
|
TimeSpan? runTimeout = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
||||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +74,12 @@ public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDis
|
|||||||
}
|
}
|
||||||
|
|
||||||
var readCache = BuildReadCache(dependencies);
|
var readCache = BuildReadCache(dependencies);
|
||||||
var context = new AlarmPredicateContext(readCache, ScriptLogger);
|
// Per-evaluation script logger: bind both ScriptId and AlarmId from the alarm id so the
|
||||||
|
// Script-log page can attribute each line to the owning scripted alarm.
|
||||||
|
var scriptLog = _scriptRoot
|
||||||
|
.ForContext(ScriptLoggerFactory.ScriptIdProperty, alarmId)
|
||||||
|
.ForContext(ScriptLoggerFactory.AlarmIdProperty, alarmId);
|
||||||
|
var context = new AlarmPredicateContext(readCache, scriptLog);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
using SerilogLogger = Serilog.ILogger;
|
using SerilogLogger = Serilog.ILogger;
|
||||||
using SerilogLog = Serilog.Log;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
@@ -23,20 +22,24 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
|
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
|
||||||
{
|
{
|
||||||
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynVirtualTagEvaluator>();
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, ScriptEvaluator<VirtualTagContext, object?>> _cache
|
private readonly ConcurrentDictionary<string, ScriptEvaluator<VirtualTagContext, object?>> _cache
|
||||||
= new(StringComparer.Ordinal);
|
= new(StringComparer.Ordinal);
|
||||||
private readonly ILogger<RoslynVirtualTagEvaluator> _logger;
|
private readonly ILogger<RoslynVirtualTagEvaluator> _logger;
|
||||||
|
private readonly SerilogLogger _scriptRoot;
|
||||||
private readonly TimeSpan _runTimeout;
|
private readonly TimeSpan _runTimeout;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>Initializes a new RoslynVirtualTagEvaluator with the given logger and optional timeout.</summary>
|
/// <summary>Initializes a new RoslynVirtualTagEvaluator with the given loggers and optional timeout.</summary>
|
||||||
/// <param name="logger">Logger for recording compilation and execution errors.</param>
|
/// <param name="logger">Logger for recording compilation and execution errors (host diagnostics).</param>
|
||||||
|
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
||||||
/// <param name="runTimeout">Maximum execution time for each script; defaults to 2 seconds if not specified.</param>
|
/// <param name="runTimeout">Maximum execution time for each script; defaults to 2 seconds if not specified.</param>
|
||||||
public RoslynVirtualTagEvaluator(ILogger<RoslynVirtualTagEvaluator> logger, TimeSpan? runTimeout = null)
|
public RoslynVirtualTagEvaluator(
|
||||||
|
ILogger<RoslynVirtualTagEvaluator> logger,
|
||||||
|
ScriptRootLogger scriptRoot,
|
||||||
|
TimeSpan? runTimeout = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
||||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +87,18 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
var readCache = BuildReadCache(dependencies);
|
var readCache = BuildReadCache(dependencies);
|
||||||
|
// Per-evaluation script logger: bind both ScriptId and VirtualTagId from the virtual-tag id
|
||||||
|
// (in the live path the script id equals the virtual-tag id) so the Script-log page can
|
||||||
|
// attribute each line. EquipmentId stays unbound for now.
|
||||||
|
var scriptLog = _scriptRoot
|
||||||
|
.ForContext(ScriptLoggerFactory.ScriptIdProperty, virtualTagId)
|
||||||
|
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, virtualTagId);
|
||||||
var context = new VirtualTagContext(
|
var context = new VirtualTagContext(
|
||||||
readCache,
|
readCache,
|
||||||
setVirtualTag: (path, _) =>
|
setVirtualTag: (path, _) =>
|
||||||
_logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)",
|
_logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)",
|
||||||
virtualTagId, path),
|
virtualTagId, path),
|
||||||
logger: ScriptLogger);
|
logger: scriptLog);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -109,13 +109,17 @@ if (hasDriver)
|
|||||||
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
|
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
|
||||||
// scripts at runtime.
|
// scripts at runtime.
|
||||||
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
|
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
|
||||||
new RoslynVirtualTagEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>()));
|
new RoslynVirtualTagEvaluator(
|
||||||
|
sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>(),
|
||||||
|
sp.GetRequiredService<ScriptRootLogger>()));
|
||||||
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
||||||
|
|
||||||
// F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
|
// F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
|
||||||
// any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
|
// any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
|
||||||
builder.Services.AddSingleton<RoslynScriptedAlarmEvaluator>(sp =>
|
builder.Services.AddSingleton<RoslynScriptedAlarmEvaluator>(sp =>
|
||||||
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
|
new RoslynScriptedAlarmEvaluator(
|
||||||
|
sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>(),
|
||||||
|
sp.GetRequiredService<ScriptRootLogger>()));
|
||||||
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||||
|
|
||||||
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
|
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
|
||||||
|
|||||||
@@ -101,4 +101,64 @@ public sealed class ScriptLoggerFactoryTests
|
|||||||
// this exact string. If it changes, the filter breaks silently.
|
// this exact string. If it changes, the filter breaks silently.
|
||||||
ScriptLoggerFactory.ScriptNameProperty.ShouldBe("ScriptName");
|
ScriptLoggerFactory.ScriptNameProperty.ShouldBe("ScriptName");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A logger from the identity overload, when written through a
|
||||||
|
/// <see cref="ScriptLogTopicSink"/>, produces an entry carrying every bound id.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Create_identity_overload_binds_ids_onto_published_entry()
|
||||||
|
{
|
||||||
|
var publisher = new FakePublisher();
|
||||||
|
var root = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
|
||||||
|
.CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(root);
|
||||||
|
|
||||||
|
var logger = factory.Create(
|
||||||
|
scriptId: "S9", virtualTagId: "V9", alarmId: "A9", equipmentId: "EQ9");
|
||||||
|
logger.Information("typed identity");
|
||||||
|
|
||||||
|
publisher.Published.Count.ShouldBe(1);
|
||||||
|
var entry = publisher.Published[0];
|
||||||
|
entry.ScriptId.ShouldBe("S9");
|
||||||
|
entry.VirtualTagId.ShouldBe("V9");
|
||||||
|
entry.AlarmId.ShouldBe("A9");
|
||||||
|
entry.EquipmentId.ShouldBe("EQ9");
|
||||||
|
entry.Message.ShouldBe("typed identity");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The identity overload leaves optional ids unbound (null on the entry) when not
|
||||||
|
/// supplied, binding only <c>ScriptId</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Create_identity_overload_leaves_optional_ids_null_when_absent()
|
||||||
|
{
|
||||||
|
var publisher = new FakePublisher();
|
||||||
|
var root = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
|
||||||
|
.CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(root);
|
||||||
|
|
||||||
|
factory.Create(scriptId: "S10").Information("only script id");
|
||||||
|
|
||||||
|
var entry = publisher.Published[0];
|
||||||
|
entry.ScriptId.ShouldBe("S10");
|
||||||
|
entry.VirtualTagId.ShouldBeNull();
|
||||||
|
entry.AlarmId.ShouldBeNull();
|
||||||
|
entry.EquipmentId.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Capturing publisher for the identity-overload sink tests.</summary>
|
||||||
|
private sealed class FakePublisher : IScriptLogPublisher
|
||||||
|
{
|
||||||
|
/// <summary>Gets the entries published so far.</summary>
|
||||||
|
public List<Commons.Messages.Logging.ScriptLogEntry> Published { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Publish(Commons.Messages.Logging.ScriptLogEntry entry) => Published.Add(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-7
@@ -1,6 +1,10 @@
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
@@ -13,11 +17,25 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RoslynScriptedAlarmEvaluatorTests
|
public sealed class RoslynScriptedAlarmEvaluatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Captures published <see cref="ScriptLogEntry"/> records for assertion.</summary>
|
||||||
|
private sealed class FakePublisher : IScriptLogPublisher
|
||||||
|
{
|
||||||
|
/// <summary>Gets the entries published so far.</summary>
|
||||||
|
public List<ScriptLogEntry> Published { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on logging.</summary>
|
||||||
|
private static ScriptRootLogger NoOpScriptRoot() =>
|
||||||
|
new(new LoggerConfiguration().CreateLogger());
|
||||||
|
|
||||||
/// <summary>Verifies evaluation of predicate returning true reports Active.</summary>
|
/// <summary>Verifies evaluation of predicate returning true reports Active.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_predicate_returning_true_reports_Active()
|
public void Evaluate_predicate_returning_true_reports_Active()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
alarmId: "alarm-hi",
|
alarmId: "alarm-hi",
|
||||||
@@ -32,7 +50,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_predicate_returning_false_reports_Inactive()
|
public void Evaluate_predicate_returning_false_reports_Inactive()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
alarmId: "alarm-hi",
|
alarmId: "alarm-hi",
|
||||||
@@ -47,7 +65,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_caches_compiled_predicate_across_calls()
|
public void Evaluate_caches_compiled_predicate_across_calls()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||||
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
|
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
|
||||||
|
|
||||||
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
|
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
|
||||||
@@ -61,7 +79,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_compile_error_returns_Failure()
|
public void Evaluate_compile_error_returns_Failure()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
|
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
|
||||||
|
|
||||||
@@ -73,7 +91,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
|
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
|
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
@@ -89,7 +107,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_empty_predicate_returns_Failure()
|
public void Evaluate_empty_predicate_returns_Failure()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
@@ -98,7 +116,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_after_dispose_returns_Failure()
|
public void Evaluate_after_dispose_returns_Failure()
|
||||||
{
|
{
|
||||||
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||||
sut.Dispose();
|
sut.Dispose();
|
||||||
|
|
||||||
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
|
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
|
||||||
@@ -106,4 +124,36 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
|
|||||||
result.Success.ShouldBeFalse();
|
result.Success.ShouldBeFalse();
|
||||||
result.Reason!.ShouldContain("disposed");
|
result.Reason!.ShouldContain("disposed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A predicate's <c>ctx.Logger.Warning(...)</c> call flows through the injected root script
|
||||||
|
/// logger and out the <see cref="ScriptLogTopicSink"/>, producing one
|
||||||
|
/// <see cref="ScriptLogEntry"/> carrying the message, the bound <c>AlarmId</c>, and the
|
||||||
|
/// Warning level.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Script_logger_call_publishes_entry_with_bound_alarm_identity()
|
||||||
|
{
|
||||||
|
var publisher = new FakePublisher();
|
||||||
|
var root = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
|
||||||
|
.CreateLogger();
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(
|
||||||
|
NullLogger<RoslynScriptedAlarmEvaluator>.Instance, new ScriptRootLogger(root));
|
||||||
|
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
alarmId: "alarm-log",
|
||||||
|
predicate: "ctx.Logger.Warning(\"alarm log\"); return true;",
|
||||||
|
dependencies: new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Reason);
|
||||||
|
result.Active.ShouldBeTrue();
|
||||||
|
publisher.Published.Count.ShouldBe(1);
|
||||||
|
var entry = publisher.Published[0];
|
||||||
|
entry.Message.ShouldBe("alarm log");
|
||||||
|
entry.AlarmId.ShouldBe("alarm-log");
|
||||||
|
entry.ScriptId.ShouldBe("alarm-log");
|
||||||
|
entry.Level.ShouldBe("Warning");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-14
@@ -1,8 +1,12 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
@@ -15,11 +19,25 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RoslynVirtualTagEvaluatorTests
|
public sealed class RoslynVirtualTagEvaluatorTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Captures published <see cref="ScriptLogEntry"/> records for assertion.</summary>
|
||||||
|
private sealed class FakePublisher : IScriptLogPublisher
|
||||||
|
{
|
||||||
|
/// <summary>Gets the entries published so far.</summary>
|
||||||
|
public List<ScriptLogEntry> Published { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on logging.</summary>
|
||||||
|
private static ScriptRootLogger NoOpScriptRoot() =>
|
||||||
|
new(new LoggerConfiguration().CreateLogger());
|
||||||
|
|
||||||
/// <summary>Verifies that simple addition expression is evaluated correctly.</summary>
|
/// <summary>Verifies that simple addition expression is evaluated correctly.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_simple_addition_returns_summed_value()
|
public void Evaluate_simple_addition_returns_summed_value()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
virtualTagId: "vt-sum",
|
virtualTagId: "vt-sum",
|
||||||
@@ -34,7 +52,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_caches_compiled_expression_across_calls()
|
public void Evaluate_caches_compiled_expression_across_calls()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
|
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
|
||||||
|
|
||||||
var first = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
|
var first = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
|
||||||
@@ -50,7 +68,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_compile_error_returns_Failure_with_reason()
|
public void Evaluate_compile_error_returns_Failure_with_reason()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary<string, object?>());
|
var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary<string, object?>());
|
||||||
|
|
||||||
@@ -63,7 +81,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_runtime_exception_returns_Failure_with_reason()
|
public void Evaluate_runtime_exception_returns_Failure_with_reason()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
virtualTagId: "vt-div0",
|
virtualTagId: "vt-div0",
|
||||||
@@ -79,7 +97,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_empty_expression_returns_Failure()
|
public void Evaluate_empty_expression_returns_Failure()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
sut.Evaluate("vt-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
sut.Evaluate("vt-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||||
sut.Evaluate("vt-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
sut.Evaluate("vt-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||||
@@ -89,7 +107,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Evaluate_after_dispose_returns_Failure()
|
public void Evaluate_after_dispose_returns_Failure()
|
||||||
{
|
{
|
||||||
var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
sut.Dispose();
|
sut.Dispose();
|
||||||
|
|
||||||
var result = sut.Evaluate("vt", "return 1;", new Dictionary<string, object?>());
|
var result = sut.Evaluate("vt", "return 1;", new Dictionary<string, object?>());
|
||||||
@@ -105,7 +123,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Passthrough_returns_dependency_value_without_compiling()
|
public void Passthrough_returns_dependency_value_without_compiling()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
virtualTagId: "vt-mirror",
|
virtualTagId: "vt-mirror",
|
||||||
@@ -121,7 +139,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Passthrough_returns_same_object_reference_as_dependency()
|
public void Passthrough_returns_same_object_reference_as_dependency()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
var payload = new object();
|
var payload = new object();
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
@@ -137,7 +155,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Passthrough_whitespace_variants_match()
|
public void Passthrough_whitespace_variants_match()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
virtualTagId: "vt-ws",
|
virtualTagId: "vt-ws",
|
||||||
@@ -152,7 +170,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Non_passthrough_falls_through_to_Roslyn()
|
public void Non_passthrough_falls_through_to_Roslyn()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
virtualTagId: "vt-plus1",
|
virtualTagId: "vt-plus1",
|
||||||
@@ -169,7 +187,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Passthrough_missing_dependency_matches_Roslyn_behaviour()
|
public void Passthrough_missing_dependency_matches_Roslyn_behaviour()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
const string mirror = "return ctx.GetTag(\"missing\").Value;";
|
const string mirror = "return ctx.GetTag(\"missing\").Value;";
|
||||||
|
|
||||||
// Roslyn baseline: same source, but force a near-miss that compiles, to capture the
|
// Roslyn baseline: same source, but force a near-miss that compiles, to capture the
|
||||||
@@ -187,7 +205,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Roslyn_missing_dependency_also_returns_Ok_null()
|
public void Roslyn_missing_dependency_also_returns_Ok_null()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
// `(object?)...Value` forces the compiled path (not the mirror shape) but reads the same
|
// `(object?)...Value` forces the compiled path (not the mirror shape) but reads the same
|
||||||
// missing tag; result must match the passthrough missing-dep result above.
|
// missing tag; result must match the passthrough missing-dep result above.
|
||||||
var result = sut.Evaluate(
|
var result = sut.Evaluate(
|
||||||
@@ -209,7 +227,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Passthrough_exact_mirror_missing_dep_matches_Roslyn_baseline()
|
public void Passthrough_exact_mirror_missing_dep_matches_Roslyn_baseline()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
// Fast-path: exact mirror shape → passthrough returns Ok(null) for missing dep.
|
// Fast-path: exact mirror shape → passthrough returns Ok(null) for missing dep.
|
||||||
var fastPath = sut.Evaluate(
|
var fastPath = sut.Evaluate(
|
||||||
@@ -238,7 +256,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Passthrough_does_not_populate_the_compiled_script_cache()
|
public void Passthrough_does_not_populate_the_compiled_script_cache()
|
||||||
{
|
{
|
||||||
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
|
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
|
||||||
|
|
||||||
// Mirror shape — must take the fast-path, leaving the Roslyn cache untouched.
|
// Mirror shape — must take the fast-path, leaving the Roslyn cache untouched.
|
||||||
sut.Evaluate("vt-mirror", "return ctx.GetTag(\"a\").Value;",
|
sut.Evaluate("vt-mirror", "return ctx.GetTag(\"a\").Value;",
|
||||||
@@ -251,6 +269,36 @@ public sealed class RoslynVirtualTagEvaluatorTests
|
|||||||
CompiledCacheCount(sut).ShouldBe(1);
|
CompiledCacheCount(sut).ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A script's <c>ctx.Logger.*</c> call flows through the injected root script logger and out
|
||||||
|
/// the <see cref="ScriptLogTopicSink"/>, producing exactly one <see cref="ScriptLogEntry"/>
|
||||||
|
/// carrying the message and the bound identity (<c>ScriptId</c> + <c>VirtualTagId</c> both
|
||||||
|
/// equal the virtual-tag id in the live path).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Script_logger_call_publishes_entry_with_bound_identity()
|
||||||
|
{
|
||||||
|
var publisher = new FakePublisher();
|
||||||
|
var root = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
|
||||||
|
.CreateLogger();
|
||||||
|
using var sut = new RoslynVirtualTagEvaluator(
|
||||||
|
NullLogger<RoslynVirtualTagEvaluator>.Instance, new ScriptRootLogger(root));
|
||||||
|
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
virtualTagId: "vt-log",
|
||||||
|
expression: "ctx.Logger.Information(\"hi from script\"); return 1;",
|
||||||
|
dependencies: new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Reason);
|
||||||
|
publisher.Published.Count.ShouldBe(1);
|
||||||
|
var entry = publisher.Published[0];
|
||||||
|
entry.Message.ShouldBe("hi from script");
|
||||||
|
entry.ScriptId.ShouldBe("vt-log");
|
||||||
|
entry.VirtualTagId.ShouldBe("vt-log");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Reads the count of the private compiled-script cache via reflection.</summary>
|
/// <summary>Reads the count of the private compiled-script cache via reflection.</summary>
|
||||||
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)
|
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user