Adds the draft-editor tab + page surface for authoring Phase 7 virtual tags and scripted alarms, plus the /alarms/historian operator diagnostics page. Monaco loads from CDN via a progressive-enhancement JS shim — the textarea works immediately so the page is functional even if the CDN is unreachable. ## New services (Admin) - ScriptService — CRUD for Script entity. SHA-256 SourceHash recomputed on save so Core.Scripting's CompiledScriptCache hits on re-publish of unchanged source + misses when the source actually changes. - VirtualTagService — CRUD for VirtualTag, with Enabled toggle. - ScriptedAlarmService — CRUD for ScriptedAlarm + lookup of persistent ScriptedAlarmState (logical-id-keyed per plan decision #14). - ScriptTestHarnessService — pre-publish dry-run. Enforces plan decision #22: only inputs the DependencyExtractor identifies can be supplied. Missing / extra synthetic inputs surface as dedicated outcomes. Captures SetVirtualTag writes + Serilog events from the script so the operator can see both the output + the log output before publishing. - HistorianDiagnosticsService — surfaces the local-process IAlarmHistorianSink state on /alarms/historian. Null sink reports Disabled + swallows retry. Live SqliteStoreAndForwardSink reports real queue depth + last-error + drain state and routes the Retry-dead-lettered button through. ## New UI - ScriptsTab.razor (inside DraftEditor tabs) — list + create/edit/delete scripts with Monaco editor + dependency preview + test-harness run panel showing output + writes + log emissions. - ScriptEditor.razor — reusable Monaco-backed textarea. Loads editor from CDN via wwwroot/js/monaco-loader.js. Textarea stays authoritative for Blazor binding; Monaco mirrors into it on every keystroke. - AlarmsHistorian.razor (/alarms/historian) — queue depth + dead-letter depth + drain state badge + last-error banner + Retry-dead-lettered button. - DraftEditor.razor — new "Scripts" tab. ## DI wiring All five services registered in Program.cs. Null historian sink bound at Admin composition time (real SqliteStoreAndForwardSink lives in the Server process). ## Tests — 13/13 Phase7ServicesTests covers: - ScriptService: Add generates logical id + hash, Update recomputes hash on source change, Update same-source keeps hash (cache-hit preservation), Delete is idempotent - VirtualTagService: round-trips trigger flags, Enabled toggle works - ScriptedAlarmService: HistorizeToAveva defaults true per plan decision #15 - ScriptTestHarness: successful run captures output + writes, rejects missing / extra inputs, rejects non-literal paths, compile errors surface as Threw - HistorianDiagnosticsService: null sink reports Disabled + retry returns 0
197 lines
6.7 KiB
C#
197 lines
6.7 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
|
|
|
/// <summary>
|
|
/// Admin-side services shipped in Phase 7 Stream F — draft CRUD for scripts + virtual
|
|
/// tags + scripted alarms, the pre-publish test harness, and the historian
|
|
/// diagnostics façade.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class Phase7ServicesTests
|
|
{
|
|
private static OtOpcUaConfigDbContext NewDb([System.Runtime.CompilerServices.CallerMemberName] string test = "")
|
|
{
|
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
|
.UseInMemoryDatabase($"phase7-{test}-{Guid.NewGuid():N}")
|
|
.Options;
|
|
return new OtOpcUaConfigDbContext(options);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptService_AddAsync_generates_logical_id_and_hash()
|
|
{
|
|
using var db = NewDb();
|
|
var svc = new ScriptService(db);
|
|
|
|
var s = await svc.AddAsync(5, "line-rate", "return ctx.GetTag(\"a\").Value;", default);
|
|
|
|
s.ScriptId.ShouldStartWith("scr-");
|
|
s.GenerationId.ShouldBe(5);
|
|
s.SourceHash.Length.ShouldBe(64);
|
|
(await svc.ListAsync(5, default)).Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptService_UpdateAsync_recomputes_hash_on_source_change()
|
|
{
|
|
using var db = NewDb();
|
|
var svc = new ScriptService(db);
|
|
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
|
var hashBefore = s.SourceHash;
|
|
|
|
var updated = await svc.UpdateAsync(5, s.ScriptId, "s", "return 2;", default);
|
|
updated.SourceHash.ShouldNotBe(hashBefore);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptService_UpdateAsync_same_source_same_hash()
|
|
{
|
|
using var db = NewDb();
|
|
var svc = new ScriptService(db);
|
|
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
|
var updated = await svc.UpdateAsync(5, s.ScriptId, "renamed", "return 1;", default);
|
|
|
|
updated.SourceHash.ShouldBe(s.SourceHash, "source unchanged → hash unchanged → compile cache hit preserved");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptService_DeleteAsync_is_idempotent()
|
|
{
|
|
using var db = NewDb();
|
|
var svc = new ScriptService(db);
|
|
|
|
await Should.NotThrowAsync(() => svc.DeleteAsync(5, "nonexistent", default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VirtualTagService_round_trips_trigger_flags()
|
|
{
|
|
using var db = NewDb();
|
|
var svc = new VirtualTagService(db);
|
|
|
|
var v = await svc.AddAsync(7, "eq-1", "LineRate", "Float32", "scr-1",
|
|
changeTriggered: true, timerIntervalMs: 1000, historize: true, default);
|
|
|
|
v.ChangeTriggered.ShouldBeTrue();
|
|
v.TimerIntervalMs.ShouldBe(1000);
|
|
v.Historize.ShouldBeTrue();
|
|
v.Enabled.ShouldBeTrue();
|
|
(await svc.ListAsync(7, default)).Single().VirtualTagId.ShouldBe(v.VirtualTagId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VirtualTagService_update_enabled_toggles_flag()
|
|
{
|
|
using var db = NewDb();
|
|
var svc = new VirtualTagService(db);
|
|
var v = await svc.AddAsync(7, "eq-1", "N", "Int32", "scr-1", true, null, false, default);
|
|
|
|
var disabled = await svc.UpdateEnabledAsync(7, v.VirtualTagId, false, default);
|
|
disabled.Enabled.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptedAlarmService_defaults_HistorizeToAveva_true_per_plan_decision_15()
|
|
{
|
|
using var db = NewDb();
|
|
var svc = new ScriptedAlarmService(db);
|
|
|
|
var a = await svc.AddAsync(9, "eq-1", "HighTemp", "LimitAlarm", severity: 800,
|
|
messageTemplate: "{Temp} too high", predicateScriptId: "scr-9",
|
|
historizeToAveva: true, retain: true, default);
|
|
|
|
a.HistorizeToAveva.ShouldBeTrue();
|
|
a.Severity.ShouldBe(800);
|
|
a.ScriptedAlarmId.ShouldStartWith("sal-");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptTestHarness_runs_successful_script_and_captures_writes()
|
|
{
|
|
var harness = new ScriptTestHarnessService();
|
|
var source = """
|
|
ctx.SetVirtualTag("Out", 42);
|
|
return ctx.GetTag("In").Value;
|
|
""";
|
|
var inputs = new Dictionary<string, DataValueSnapshot>
|
|
{
|
|
["In"] = new(123, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
|
};
|
|
|
|
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
|
|
|
result.Outcome.ShouldBe(ScriptTestOutcome.Success);
|
|
result.Output.ShouldBe(123);
|
|
result.Writes["Out"].ShouldBe(42);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptTestHarness_rejects_missing_synthetic_input()
|
|
{
|
|
var harness = new ScriptTestHarnessService();
|
|
var source = """return ctx.GetTag("A").Value;""";
|
|
|
|
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
|
|
|
result.Outcome.ShouldBe(ScriptTestOutcome.MissingInputs);
|
|
result.Errors[0].ShouldContain("A");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptTestHarness_rejects_extra_synthetic_input_not_referenced_by_script()
|
|
{
|
|
var harness = new ScriptTestHarnessService();
|
|
var source = """return 1;"""; // no GetTag calls
|
|
var inputs = new Dictionary<string, DataValueSnapshot>
|
|
{
|
|
["Unexpected"] = new(0, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
|
};
|
|
|
|
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
|
|
|
result.Outcome.ShouldBe(ScriptTestOutcome.UnknownInputs);
|
|
result.Errors[0].ShouldContain("Unexpected");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptTestHarness_rejects_non_literal_path()
|
|
{
|
|
var harness = new ScriptTestHarnessService();
|
|
var source = """
|
|
var p = "A";
|
|
return ctx.GetTag(p).Value;
|
|
""";
|
|
|
|
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
|
|
|
result.Outcome.ShouldBe(ScriptTestOutcome.DependencyRejected);
|
|
result.Errors.ShouldNotBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScriptTestHarness_surfaces_compile_error_as_Threw()
|
|
{
|
|
var harness = new ScriptTestHarnessService();
|
|
var source = "this is not valid C#;";
|
|
|
|
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
|
|
|
result.Outcome.ShouldBe(ScriptTestOutcome.Threw);
|
|
}
|
|
|
|
[Fact]
|
|
public void HistorianDiagnosticsService_reports_Disabled_for_null_sink()
|
|
{
|
|
var diag = new HistorianDiagnosticsService(NullAlarmHistorianSink.Instance);
|
|
diag.GetStatus().DrainState.ShouldBe(HistorianDrainState.Disabled);
|
|
diag.TryRetryDeadLettered().ShouldBe(0);
|
|
}
|
|
}
|