Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/Phase7ServicesTests.cs
Joseph Doherty 0687bb2e2d Phase 7 Stream F — Admin UI for scripts + test harness + historian diagnostics
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
2026-04-20 19:59:18 -04:00

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);
}
}