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; /// /// 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. /// [Trait("Category", "Unit")] public sealed class Phase7ServicesTests { private static OtOpcUaConfigDbContext NewDb([System.Runtime.CompilerServices.CallerMemberName] string test = "") { var options = new DbContextOptionsBuilder() .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 { ["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(), 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 { ["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(), 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(), 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); } }