refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,534 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
public class AlarmTriggerConfigCodecTests
{
// ── Parse: ValueMatch ──────────────────────────────────────────────────
[Fact]
public void Parse_ValueMatch_ReadsCanonicalKeys()
{
const string json = @"{""attributeName"":""Status"",""matchValue"":""Critical""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Equal("Critical", model.MatchValue);
Assert.False(model.NotEquals);
}
[Fact]
public void Parse_ValueMatch_AcceptsLegacyAttributeAndValueKeys()
{
// Older configs used "attribute" and "value" instead of the canonical names.
const string json = @"{""attribute"":""Status"",""value"":""Critical""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Equal("Critical", model.MatchValue);
}
[Fact]
public void Parse_ValueMatch_NotEqualsPrefix_SetsFlagAndStripsPrefix()
{
const string json = @"{""attributeName"":""Status"",""matchValue"":""!=Good""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.True(model.NotEquals);
Assert.Equal("Good", model.MatchValue);
}
[Fact]
public void Parse_ValueMatch_MissingMatchValue_LeavesNull()
{
const string json = @"{""attributeName"":""Status""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal("Status", model.AttributeName);
Assert.Null(model.MatchValue);
Assert.False(model.NotEquals);
}
// ── Parse: RangeViolation ──────────────────────────────────────────────
[Fact]
public void Parse_RangeViolation_ReadsCanonicalKeys()
{
const string json = @"{""attributeName"":""Temp"",""min"":0,""max"":100}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(0, model.Min);
Assert.Equal(100, model.Max);
}
[Fact]
public void Parse_RangeViolation_AcceptsLegacyLowHighKeys()
{
const string json = @"{""attributeName"":""Temp"",""low"":-10,""high"":50}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(-10, model.Min);
Assert.Equal(50, model.Max);
}
[Fact]
public void Parse_RangeViolation_CanonicalKeysWinOverLegacy()
{
// If both canonical and legacy aliases are present, the canonical key wins.
const string json = @"{""attributeName"":""T"",""min"":0,""low"":-999,""max"":100,""high"":999}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(0, model.Min);
Assert.Equal(100, model.Max);
}
[Fact]
public void Parse_RangeViolation_StringNumericValues_AreParsed()
{
// Some configs serialize min/max as JSON strings. Codec accepts both.
const string json = @"{""attributeName"":""T"",""min"":""1.5"",""max"":""9.75""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(1.5, model.Min);
Assert.Equal(9.75, model.Max);
}
// ── Parse: RateOfChange ────────────────────────────────────────────────
[Fact]
public void Parse_RateOfChange_ReadsAllFields()
{
const string json = @"{""attributeName"":""Pressure"",""thresholdPerSecond"":25,""windowSeconds"":2,""direction"":""rising""}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal("Pressure", model.AttributeName);
Assert.Equal(25, model.ThresholdPerSecond);
Assert.Equal(2, model.WindowSeconds);
Assert.Equal("rising", model.Direction);
}
[Theory]
[InlineData("rising", "rising")]
[InlineData("Rising", "rising")]
[InlineData("up", "rising")]
[InlineData("positive", "rising")]
[InlineData("falling", "falling")]
[InlineData("Down", "falling")]
[InlineData("negative", "falling")]
[InlineData("either", "either")]
[InlineData("bogus", "either")]
[InlineData("", "either")]
public void Parse_RateOfChange_NormalizesDirectionAliases(string input, string expected)
{
var json = $@"{{""attributeName"":""x"",""direction"":""{input}""}}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal(expected, model.Direction);
}
[Fact]
public void Parse_RateOfChange_MissingDirection_DefaultsToEither()
{
// Older configs predate the direction field — the codec must default it
// so existing data round-trips without surprises.
const string json = @"{""attributeName"":""x"",""thresholdPerSecond"":10,""windowSeconds"":1}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal("either", model.Direction);
}
// ── Parse: misc ────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_NullOrWhitespace_ReturnsDefaultModel(string? input)
{
var model = AlarmTriggerConfigCodec.Parse(input, AlarmTriggerType.ValueMatch);
Assert.Null(model.AttributeName);
Assert.Null(model.MatchValue);
Assert.False(model.NotEquals);
Assert.Equal("either", model.Direction);
}
[Fact]
public void Parse_MalformedJson_ReturnsDefaultModel_DoesNotThrow()
{
var model = AlarmTriggerConfigCodec.Parse("{not valid", AlarmTriggerType.RangeViolation);
Assert.Null(model.Min);
Assert.Null(model.Max);
}
// ── Serialize: ValueMatch ──────────────────────────────────────────────
[Fact]
public void Serialize_ValueMatch_WritesCanonicalKeysOnly()
{
var model = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Critical",
// Foreign fields from other trigger types must NOT leak into the JSON.
Min = 5,
ThresholdPerSecond = 99,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal("Status", root.GetProperty("attributeName").GetString());
Assert.Equal("Critical", root.GetProperty("matchValue").GetString());
Assert.False(root.TryGetProperty("min", out _));
Assert.False(root.TryGetProperty("thresholdPerSecond", out _));
Assert.False(root.TryGetProperty("direction", out _));
}
[Fact]
public void Serialize_ValueMatch_NotEquals_PrependsBangEqualsToMatchValue()
{
var model = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Good",
NotEquals = true
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
Assert.Equal("!=Good", doc.RootElement.GetProperty("matchValue").GetString());
}
[Fact]
public void Serialize_ValueMatch_NullAttributeName_WritesEmptyString()
{
// AlarmActor uses attributeName for subscription filtering, so the key
// must always be present even when the user hasn't picked one yet.
var model = new AlarmTriggerModel { MatchValue = "x" };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.ValueMatch);
using var doc = JsonDocument.Parse(json);
Assert.Equal("", doc.RootElement.GetProperty("attributeName").GetString());
}
// ── Serialize: RangeViolation ──────────────────────────────────────────
[Fact]
public void Serialize_RangeViolation_WritesCanonicalNumericKeys()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Min = 0,
Max = 100,
MatchValue = "ignored"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(0, root.GetProperty("min").GetDouble());
Assert.Equal(100, root.GetProperty("max").GetDouble());
Assert.False(root.TryGetProperty("matchValue", out _));
}
[Fact]
public void Serialize_RangeViolation_NullBound_OmitsKey()
{
var model = new AlarmTriggerModel { AttributeName = "Temp", Min = 0, Max = null };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RangeViolation);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("min", out _));
Assert.False(doc.RootElement.TryGetProperty("max", out _));
}
// ── Serialize: RateOfChange ────────────────────────────────────────────
[Fact]
public void Serialize_RateOfChange_WritesThresholdWindowAndDirection()
{
var model = new AlarmTriggerModel
{
AttributeName = "Pressure",
ThresholdPerSecond = 25,
WindowSeconds = 2,
Direction = "falling"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RateOfChange);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(25, root.GetProperty("thresholdPerSecond").GetDouble());
Assert.Equal(2, root.GetProperty("windowSeconds").GetDouble());
Assert.Equal("falling", root.GetProperty("direction").GetString());
}
[Fact]
public void Serialize_RateOfChange_AlwaysIncludesDirection()
{
// Even with a default-constructed model, the runtime needs to know how
// to evaluate — direction defaults to "either" and is always emitted.
var model = new AlarmTriggerModel { AttributeName = "x" };
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.RateOfChange);
using var doc = JsonDocument.Parse(json);
Assert.Equal("either", doc.RootElement.GetProperty("direction").GetString());
}
// ── Round-trip ─────────────────────────────────────────────────────────
[Fact]
public void RoundTrip_ValueMatch_NotEquals_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Status",
MatchValue = "Good",
NotEquals = true
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.ValueMatch);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.ValueMatch);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.MatchValue, round.MatchValue);
Assert.True(round.NotEquals);
}
[Fact]
public void RoundTrip_RangeViolation_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Temp",
Min = -10.5,
Max = 42.25
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.RangeViolation);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RangeViolation);
Assert.Equal(original.Min, round.Min);
Assert.Equal(original.Max, round.Max);
}
[Fact]
public void RoundTrip_RateOfChange_Preserved()
{
var original = new AlarmTriggerModel
{
AttributeName = "Pressure",
ThresholdPerSecond = 25,
WindowSeconds = 2,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.RateOfChange);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.RateOfChange);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.ThresholdPerSecond, round.ThresholdPerSecond);
Assert.Equal(original.WindowSeconds, round.WindowSeconds);
Assert.Equal(original.Direction, round.Direction);
}
// ── Parse: HiLo ────────────────────────────────────────────────────────
[Fact]
public void Parse_HiLo_ReadsAllSetpointsAndPriorities()
{
const string json = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":90,""hiHi"":100,""loLoPriority"":900,""loPriority"":500,""hiPriority"":500,""hiHiPriority"":900}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal("Temp", model.AttributeName);
Assert.Equal(0, model.LoLo);
Assert.Equal(10, model.Lo);
Assert.Equal(90, model.Hi);
Assert.Equal(100, model.HiHi);
Assert.Equal(900, model.LoLoPriority);
Assert.Equal(500, model.LoPriority);
Assert.Equal(500, model.HiPriority);
Assert.Equal(900, model.HiHiPriority);
}
[Fact]
public void Parse_HiLo_AcceptsPartialSetpoints_MissingOnesAreNull()
{
// Common case: only Hi/HiHi configured for over-temp protection.
const string json = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Null(model.LoLo);
Assert.Null(model.Lo);
Assert.Equal(80, model.Hi);
Assert.Equal(100, model.HiHi);
Assert.Null(model.HiPriority);
}
// ── Serialize: HiLo ────────────────────────────────────────────────────
[Fact]
public void Serialize_HiLo_OmitsNullSetpointsAndPriorities()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
HiHi = 100,
HiHiPriority = 900
// Lo, LoLo, and the other priorities left null
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(80, root.GetProperty("hi").GetDouble());
Assert.Equal(100, root.GetProperty("hiHi").GetDouble());
Assert.Equal(900, root.GetProperty("hiHiPriority").GetInt32());
Assert.False(root.TryGetProperty("lo", out _));
Assert.False(root.TryGetProperty("loLo", out _));
Assert.False(root.TryGetProperty("hiPriority", out _));
Assert.False(root.TryGetProperty("loPriority", out _));
}
[Fact]
public void Serialize_HiLo_DoesNotLeakForeignTriggerTypeFields()
{
// matchValue, min/max, threshold/window/direction must NOT show up in
// HiLo output even if the model happens to carry them.
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
MatchValue = "ignored",
Min = 1,
ThresholdPerSecond = 99,
Direction = "rising"
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.False(root.TryGetProperty("matchValue", out _));
Assert.False(root.TryGetProperty("min", out _));
Assert.False(root.TryGetProperty("thresholdPerSecond", out _));
Assert.False(root.TryGetProperty("direction", out _));
}
[Fact]
public void Parse_HiLo_ReadsDeadbands()
{
const string json = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100,""hiDeadband"":2,""hiHiDeadband"":5}";
var model = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal(2, model.HiDeadband);
Assert.Equal(5, model.HiHiDeadband);
Assert.Null(model.LoDeadband);
Assert.Null(model.LoLoDeadband);
}
[Fact]
public void Serialize_HiLo_OmitsNullDeadbands()
{
var model = new AlarmTriggerModel
{
AttributeName = "Temp",
Hi = 80,
HiDeadband = 2
// HiHiDeadband / LoDeadband / LoLoDeadband null
};
var json = AlarmTriggerConfigCodec.Serialize(model, AlarmTriggerType.HiLo);
using var doc = JsonDocument.Parse(json);
Assert.Equal(2, doc.RootElement.GetProperty("hiDeadband").GetDouble());
Assert.False(doc.RootElement.TryGetProperty("hiHiDeadband", out _));
Assert.False(doc.RootElement.TryGetProperty("loDeadband", out _));
}
[Fact]
public void RoundTrip_HiLo_PreservesAllFields()
{
var original = new AlarmTriggerModel
{
AttributeName = "Pressure",
LoLo = -5,
Lo = 0,
Hi = 90,
HiHi = 110,
LoLoPriority = 800,
LoPriority = 400,
HiPriority = 400,
HiHiPriority = 800,
LoLoDeadband = 1,
LoDeadband = 2,
HiDeadband = 3,
HiHiDeadband = 4
};
var json = AlarmTriggerConfigCodec.Serialize(original, AlarmTriggerType.HiLo);
var round = AlarmTriggerConfigCodec.Parse(json, AlarmTriggerType.HiLo);
Assert.Equal(original.AttributeName, round.AttributeName);
Assert.Equal(original.LoLo, round.LoLo);
Assert.Equal(original.Lo, round.Lo);
Assert.Equal(original.Hi, round.Hi);
Assert.Equal(original.HiHi, round.HiHi);
Assert.Equal(original.LoLoPriority, round.LoLoPriority);
Assert.Equal(original.LoPriority, round.LoPriority);
Assert.Equal(original.HiPriority, round.HiPriority);
Assert.Equal(original.HiHiPriority, round.HiHiPriority);
Assert.Equal(original.LoLoDeadband, round.LoLoDeadband);
Assert.Equal(original.LoDeadband, round.LoDeadband);
Assert.Equal(original.HiDeadband, round.HiDeadband);
Assert.Equal(original.HiHiDeadband, round.HiHiDeadband);
}
// ── NormalizeDirection (direct) ────────────────────────────────────────
[Theory]
[InlineData("rising", "rising")]
[InlineData("RISING", "rising")]
[InlineData("falling", "falling")]
[InlineData("up", "rising")]
[InlineData("down", "falling")]
[InlineData("positive", "rising")]
[InlineData("negative", "falling")]
[InlineData("either", "either")]
[InlineData("", "either")]
[InlineData(null, "either")]
[InlineData("nonsense", "either")]
public void NormalizeDirection_HandlesAllAliasesAndFallsBackToEither(string? input, string expected)
{
Assert.Equal(expected, AlarmTriggerConfigCodec.NormalizeDirection(input));
}
}
@@ -0,0 +1,68 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-016. <c>DataTable</c> looped
/// <c>for i = 1..totalPages</c> and emitted one numbered <c>&lt;li&gt;</c>
/// button per page; a few thousand records at page size 25 rendered hundreds
/// of buttons into the diff on every state change. The fix windows the pager
/// so only first / last / a small range around the current page render.
/// </summary>
public class DataTablePagerTests : BunitContext
{
private IRenderedComponent<DataTable<int>> RenderTable(int itemCount, int pageSize = 25)
{
return Render<DataTable<int>>(parameters => parameters
.Add(p => p.Items, Enumerable.Range(1, itemCount).ToList())
.Add(p => p.PageSize, pageSize)
.Add(p => p.ShowSearch, false)
.Add(p => p.HeaderContent, (RenderFragment)(b => b.AddMarkupContent(0, "<th>N</th>")))
.Add(p => p.RowContent, (RenderFragment<int>)(item => b => b.AddMarkupContent(0, $"<tr><td>{item}</td></tr>"))));
}
private static int NumberedPageButtons(IRenderedComponent<DataTable<int>> cut)
=> cut.FindAll("ul.pagination li.page-item button")
.Count(b => int.TryParse(b.TextContent.Trim(), out _));
[Fact]
public void Pager_WithThousandsOfPages_RendersWindowedNotEveryPage()
{
// 5000 items / 25 = 200 pages. The pre-fix pager rendered 200 numbered
// buttons; the windowed pager renders at most a dozen.
var cut = RenderTable(itemCount: 5000);
var numbered = NumberedPageButtons(cut);
Assert.True(numbered <= 12,
$"Expected a windowed pager (<= 12 numbered buttons) but rendered {numbered}.");
}
[Fact]
public void Pager_SmallDataset_StillRendersEveryPage()
{
// 5 pages — small enough to render all numbered buttons (no windowing harm).
var cut = RenderTable(itemCount: 125);
var numbered = NumberedPageButtons(cut);
Assert.Equal(5, numbered);
}
[Fact]
public void Pager_WindowedAroundCurrentPage_AlwaysIncludesFirstAndLast()
{
var cut = RenderTable(itemCount: 5000); // 200 pages
var numbered = cut.FindAll("ul.pagination li.page-item button")
.Select(b => b.TextContent.Trim())
.Where(t => int.TryParse(t, out _))
.ToList();
// First and last page are always reachable from the windowed pager.
Assert.Contains("1", numbered);
Assert.Contains("200", numbered);
}
}
@@ -0,0 +1,117 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Characterization tests for CentralUI-015 (re-triaged Won't Fix — see
/// findings.md). The finding claimed <c>ContinueWith(..., TaskScheduler.Default)</c>
/// made callers resume off the render thread; that premise is incorrect — an
/// <c>await</c> always resumes on the awaiter's own captured
/// <see cref="SynchronizationContext"/> regardless of where the awaited task
/// completes. <c>ConfirmAsync_AwaiterResumesOnItsCapturedSyncContext</c> pins
/// that correct behaviour (it passes against both the old <c>ContinueWith</c>
/// form and the current inline-projection form). The remaining tests pin the
/// dialog result-resolution contract.
/// </summary>
public class DialogServiceThreadingTests
{
/// <summary>
/// A single-threaded sync context that records every posted callback —
/// stands in for the Blazor renderer's dispatcher.
/// </summary>
private sealed class TrackingSyncContext : SynchronizationContext
{
private readonly Thread _thread;
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
public int PostedCount;
public TrackingSyncContext()
{
_thread = new Thread(() =>
{
SetSynchronizationContext(this);
foreach (var (cb, st) in _queue.GetConsumingEnumerable())
{
cb(st);
}
}) { IsBackground = true };
_thread.Start();
}
public override void Post(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref PostedCount);
_queue.Add((d, state));
}
public void Complete() => _queue.CompleteAdding();
}
[Fact]
public async Task ConfirmAsync_AwaiterResumesOnItsCapturedSyncContext()
{
var service = new DialogService();
var ctx = new TrackingSyncContext();
// Run the awaiting "component" code on the tracking context.
var done = new TaskCompletionSource<int>();
ctx.Post(async void (_) =>
{
try
{
var task = service.ConfirmAsync("t", "m");
// Resolve from another thread, mimicking the host dispatching.
_ = Task.Run(() => service.Resolve(true));
await task;
// The continuation after the await must be back on the tracking
// context's single thread.
done.SetResult(Environment.CurrentManagedThreadId);
}
catch (Exception ex)
{
done.SetException(ex);
}
}, null);
var resumeThreadId = await done.Task;
ctx.Complete();
// The continuation was posted to (and ran on) the captured context.
Assert.True(ctx.PostedCount >= 1,
"ConfirmAsync continuation must post back to the caller's SynchronizationContext.");
Assert.NotEqual(Environment.CurrentManagedThreadId, resumeThreadId);
}
[Fact]
public async Task ConfirmAsync_ResolvesWithExpectedValue()
{
var service = new DialogService();
var task = service.ConfirmAsync("t", "m");
service.Resolve(true);
Assert.True(await task);
}
[Fact]
public async Task PromptAsync_ResolvesWithExpectedValue()
{
var service = new DialogService();
var task = service.PromptAsync("t", "label");
service.Resolve("typed value");
Assert.Equal("typed value", await task);
}
[Fact]
public async Task PromptAsync_CancelledResolvesToNull()
{
var service = new DialogService();
var task = service.PromptAsync("t", "label");
service.Resolve(null);
Assert.Null(await task);
}
}
@@ -0,0 +1,75 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-011. <c>DiffDialog.OpenAsync</c> returns the
/// <c>TaskCompletionSource</c>'s task, completed only by <c>Close()</c>. If the
/// user navigated away while the dialog was open, <c>DisposeAsync</c> ran but
/// never completed the TCS — the awaiting caller was suspended forever and any
/// cleanup after the await was skipped. The fix completes the TCS in
/// <c>DisposeAsync</c>.
/// </summary>
public class DiffDialogTests : BunitContext
{
/// <summary>
/// DiffDialog applies/removes a body scroll-lock class and focuses the modal
/// via JS interop on open/close. Loose mode auto-completes those void calls
/// so a path that <c>await</c>s them (e.g. <c>DisposeAsync</c> →
/// <c>TryUnlockBodyAsync</c>) resumes instead of hanging on a never-completed
/// planned invocation, and no strict-mode unplanned-invocation exception
/// surfaces through the narrowed CentralUI-023 catch blocks.
/// </summary>
private void SetupBodyLockInterop()
{
JSInterop.Mode = JSRuntimeMode.Loose;
}
[Fact]
public async Task DisposeAsync_WhileOpen_CompletesPendingTask()
{
SetupBodyLockInterop();
var cut = Render<DiffDialog>();
// Open the dialog; the returned task represents the caller's await.
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
// await the dialog's own (deliberately long-lived) task.
Task<bool> pending = null!;
await cut.InvokeAsync(
() => { pending = cut.Instance.ShowAsync("Compare", "before", "after"); });
Assert.False(pending.IsCompleted, "Dialog task should be pending while open.");
// Simulate navigating away while the dialog is still open.
await cut.InvokeAsync(async () => await cut.Instance.DisposeAsync());
// The awaiter must complete deterministically rather than hang forever.
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(pending, completed);
Assert.True(pending.IsCompletedSuccessfully);
var result = await pending;
Assert.False(result, "Dismiss-on-dispose should resolve to false (not confirmed).");
}
[Fact]
public async Task Close_CompletesPendingTaskWithTrue()
{
SetupBodyLockInterop();
var cut = Render<DiffDialog>();
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
// await the dialog's own (deliberately long-lived) task.
Task<bool> pending = null!;
await cut.InvokeAsync(
() => { pending = cut.Instance.ShowAsync("Compare", "before", "after"); });
// Closing via the Close button completes the task with true.
await cut.InvokeAsync(() => cut.Find("button.btn-secondary").Click());
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.Same(pending, completed);
var result = await pending;
Assert.True(result);
}
}
@@ -0,0 +1,98 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Coverage for <see cref="DurationInput"/>, the number+unit codec behind the
/// script form's "Min time between runs" field.
/// </summary>
public class DurationInputTests
{
// ── Split: TimeSpan -> (value, unit) ───────────────────────────────────
[Fact]
public void Split_Null_ReturnsBlankWithSecondsUnit()
{
var (value, unit) = DurationInput.Split(null);
Assert.Null(value);
Assert.Equal("sec", unit);
}
[Fact]
public void Split_Zero_ReturnsBlank()
{
var (value, _) = DurationInput.Split(TimeSpan.Zero);
Assert.Null(value);
}
[Fact]
public void Split_WholeMinutes_UsesMinuteUnit()
{
var (value, unit) = DurationInput.Split(TimeSpan.FromMinutes(5));
Assert.Equal("5", value);
Assert.Equal("min", unit);
}
[Fact]
public void Split_WholeSeconds_UsesSecondUnit()
{
var (value, unit) = DurationInput.Split(TimeSpan.FromSeconds(30));
Assert.Equal("30", value);
Assert.Equal("sec", unit);
}
[Fact]
public void Split_SubSecond_UsesMillisecondUnit()
{
var (value, unit) = DurationInput.Split(TimeSpan.FromMilliseconds(250));
Assert.Equal("250", value);
Assert.Equal("ms", unit);
}
// ── Compose: (value, unit) -> TimeSpan? ────────────────────────────────
[Fact]
public void Compose_Blank_ReturnsNull() =>
Assert.Null(DurationInput.Compose(null, "sec"));
[Fact]
public void Compose_Zero_ReturnsNull() =>
Assert.Null(DurationInput.Compose("0", "sec"));
[Fact]
public void Compose_Negative_ReturnsNull() =>
Assert.Null(DurationInput.Compose("-5", "sec"));
[Fact]
public void Compose_SecondsValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromSeconds(30), DurationInput.Compose("30", "sec"));
[Fact]
public void Compose_MinutesValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromMinutes(5), DurationInput.Compose("5", "min"));
[Fact]
public void Compose_MillisecondsValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromMilliseconds(250), DurationInput.Compose("250", "ms"));
// ── Round-trip ─────────────────────────────────────────────────────────
[Theory]
[InlineData(250)]
[InlineData(30000)]
[InlineData(300000)]
public void RoundTrip_PreservesDuration(long milliseconds)
{
var original = TimeSpan.FromMilliseconds(milliseconds);
var (value, unit) = DurationInput.Split(original);
var reparsed = DurationInput.Compose(value, unit);
Assert.Equal(original, reparsed);
}
}
@@ -0,0 +1,142 @@
using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Communication;
using ParkedMessagesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.ParkedMessages;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-023. <c>DiffDialog.TryLockBodyAsync</c> /
/// <c>TryUnlockBodyAsync</c> and <c>ParkedMessages.CopyAsync</c> wrapped JS
/// interop in bare <c>catch { }</c> blocks: a genuine <see cref="JSException"/>
/// was indistinguishable from an expected <see cref="JSDisconnectedException"/>
/// and neither was logged. The fix narrows the catch and logs real interop
/// failures via <c>ILogger</c>, consistent with the CentralUI-018 fixes.
/// </summary>
public class JsInteropLoggingTests : BunitContext
{
/// <summary>Captures log entries so the test can assert on them.</summary>
private sealed class CapturingLoggerProvider : ILoggerProvider
{
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
public void Dispose() { }
private sealed class CapturingLogger : ILogger
{
private readonly List<(LogLevel, string, Exception?)> _entries;
public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
=> _entries.Add((logLevel, formatter(state, exception), exception));
}
}
[Fact]
public void DiffDialog_BodyLock_GenuineJsException_IsLogged()
{
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
// The body scroll-lock runs on OnAfterRender when the dialog is shown.
// Configure that JS call to throw a genuine JSException.
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("document.body.classList.add", "modal-open")
.SetException(new JSException("body lock failed"));
// Focus and any other interop is harmless here — allow it loosely.
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
cut.Render();
cut.WaitForAssertion(() =>
{
var warnings = provider.Entries.Where(e => e.Level >= LogLevel.Warning).ToList();
Assert.Contains(warnings, e => e.Exception is JSException);
});
}
[Fact]
public void DiffDialog_BodyLock_Disconnect_IsNotLogged()
{
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
// A circuit disconnect during the lock is expected — it must NOT log.
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("document.body.classList.add", "modal-open")
.SetException(new JSDisconnectedException("circuit gone"));
JSInterop.SetupVoid("document.body.classList.remove", "modal-open");
var cut = Render<DiffDialog>();
cut.InvokeAsync(() => cut.Instance.ShowAsync("Compare", "a", "b"));
cut.Render();
Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning);
}
[Fact]
public async Task ParkedMessages_Copy_GenuineJsException_IsLogged()
{
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
Services.AddSingleton(siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
var stubAuth = new StubAuthStateProvider(
new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
Services.AddScoped<IDialogService, DialogService>();
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("navigator.clipboard.writeText", _ => true)
.SetException(new JSException("clipboard permission denied"));
var cut = Render<ParkedMessagesPage>();
// CopyAsync is a private handler; invoke it directly with a clipboard
// call configured to fail. Pre-fix the bare catch swallowed it silently.
var copy = typeof(ParkedMessagesPage).GetMethod(
"CopyAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
await cut.InvokeAsync(() => (Task)copy.Invoke(cut.Instance, new object[] { "some-id" })!);
var warnings = provider.Entries.Where(e => e.Level >= LogLevel.Warning).ToList();
Assert.Contains(warnings, e => e.Exception is JSException);
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
}
@@ -0,0 +1,77 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-018. <c>MonacoEditor</c> wrapped every JS
/// interop call in a bare <c>try { ... } catch { }</c> with no logging — a
/// genuine Monaco init failure became invisible. The fix narrows the catch to
/// the expected prerender / disconnect cases and logs any real
/// <see cref="JSException"/> via <c>ILogger</c>.
/// </summary>
public class MonacoEditorLoggingTests : BunitContext
{
/// <summary>Captures log entries so the test can assert on them.</summary>
private sealed class CapturingLoggerProvider : ILoggerProvider
{
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
public void Dispose() { }
private sealed class CapturingLogger : ILogger
{
private readonly List<(LogLevel, string, Exception?)> _entries;
public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
=> _entries.Add((logLevel, formatter(state, exception), exception));
}
}
[Fact]
public void CreateEditor_GenuineJsException_IsLogged_NotSwallowed()
{
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
// createEditor is an InvokeVoidAsync call — configure it to throw a
// genuine JSException so we exercise the real-failure path.
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("MonacoBlazor.createEditor", _ => true)
.SetException(new JSException("Monaco failed to load"));
// Pre-fix: the bare catch {} swallowed this with no trace. Post-fix:
// the component renders fine but the failure is logged.
var cut = Render<MonacoEditor>(p => p.Add(c => c.ShowToolbar, false));
var errors = provider.Entries.Where(e => e.Level == LogLevel.Error).ToList();
Assert.NotEmpty(errors);
Assert.Contains(errors, e => e.Exception is JSException);
}
[Fact]
public void CreateEditor_Prerender_DoesNotLog()
{
// When JS interop is unavailable (prerender), createEditor throws
// InvalidOperationException — that is expected and must NOT be logged.
var provider = new CapturingLoggerProvider();
Services.AddLogging(b => b.AddProvider(provider));
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.SetupVoid("MonacoBlazor.createEditor", _ => true)
.SetException(new InvalidOperationException("JS interop not available during prerender"));
var cut = Render<MonacoEditor>(p => p.Add(c => c.ShowToolbar, false));
Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning);
}
}
@@ -0,0 +1,67 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Unit tests for the <see cref="PagerWindow"/> helper introduced for
/// CentralUI-016 — windowed pagination that keeps the rendered button count
/// bounded regardless of total page count.
/// </summary>
public class PagerWindowTests
{
[Fact]
public void Build_SmallPageCount_ReturnsEveryPage_NoEllipsis()
{
var pages = PagerWindow.Build(currentPage: 3, totalPages: 5);
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, pages);
}
[Fact]
public void Build_LargePageCount_IsBounded_AndIncludesFirstAndLast()
{
var pages = PagerWindow.Build(currentPage: 100, totalPages: 200);
Assert.Contains(1, pages);
Assert.Contains(200, pages);
Assert.Contains(100, pages);
// First, ellipsis, window of 5, ellipsis, last — never the full 200.
Assert.True(pages.Count <= 12, $"Expected a bounded window but got {pages.Count} entries.");
}
[Fact]
public void Build_LargePageCount_InsertsEllipsisForGaps()
{
// 0 is the ellipsis sentinel.
var pages = PagerWindow.Build(currentPage: 100, totalPages: 200);
Assert.Contains(0, pages);
}
[Fact]
public void Build_CurrentNearStart_NoLeadingEllipsis()
{
var pages = PagerWindow.Build(currentPage: 1, totalPages: 200);
// Pages 1..3 are contiguous from the start, so no ellipsis before them.
Assert.Equal(1, pages[0]);
Assert.NotEqual(0, pages[1]);
}
[Fact]
public void Build_ClampsOutOfRangeCurrentPage()
{
var pages = PagerWindow.Build(currentPage: 999, totalPages: 200);
Assert.Contains(200, pages);
Assert.True(pages.Count <= 12);
}
[Theory]
[InlineData(0)]
[InlineData(-3)]
public void Build_NonPositiveTotalPages_ReturnsEmpty(int totalPages)
{
Assert.Empty(PagerWindow.Build(currentPage: 1, totalPages: totalPages));
}
}
@@ -0,0 +1,141 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
public class SchemaBuilderModelTests
{
// ── Parse ─────────────────────────────────────────────────────────────────
[Fact]
public void Parse_Empty_ReturnsFallback()
{
var fallback = SchemaBuilderModel.NewObject();
Assert.Same(fallback, SchemaBuilderModel.Parse(null, fallback));
Assert.Same(fallback, SchemaBuilderModel.Parse("", fallback));
Assert.Same(fallback, SchemaBuilderModel.Parse(" ", fallback));
}
[Fact]
public void Parse_Malformed_ReturnsFallback()
{
var fallback = SchemaBuilderModel.NewObject();
Assert.Same(fallback, SchemaBuilderModel.Parse("{not json", fallback));
Assert.Same(fallback, SchemaBuilderModel.Parse("42", fallback));
}
[Fact]
public void Parse_ObjectSchema_ExtractsPropertiesAndRequired()
{
const string json = """
{"type":"object","properties":{
"id":{"type":"integer"},
"label":{"type":"string"},
"active":{"type":"boolean"}
},"required":["id","active"]}
""";
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
Assert.Equal("object", node.Type);
Assert.Collection(node.Properties,
p => { Assert.Equal("id", p.Name); Assert.Equal("integer", p.Schema.Type); Assert.True(p.Required); },
p => { Assert.Equal("label", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); },
p => { Assert.Equal("active", p.Name); Assert.Equal("boolean", p.Schema.Type); Assert.True(p.Required); });
}
[Fact]
public void Parse_ArrayOfPrimitive_PreservesItemType()
{
var node = SchemaBuilderModel.Parse(
@"{""type"":""array"",""items"":{""type"":""integer""}}",
SchemaBuilderModel.NewValue());
Assert.Equal("array", node.Type);
Assert.NotNull(node.Items);
Assert.Equal("integer", node.Items!.Type);
}
[Fact]
public void Parse_LegacyFlatArray_TranslatedToObjectSchema()
{
const string json = """[{"name":"x","type":"Integer"},{"name":"y","type":"String","required":false}]""";
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
Assert.Equal("object", node.Type);
Assert.Collection(node.Properties,
p => { Assert.Equal("x", p.Name); Assert.Equal("integer", p.Schema.Type); Assert.True(p.Required); },
p => { Assert.Equal("y", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); });
}
[Fact]
public void Parse_NestedObjects_Recurses()
{
const string json = """
{"type":"object","properties":{
"outer":{"type":"object","properties":{
"inner":{"type":"integer"}
},"required":["inner"]}
}}
""";
var node = SchemaBuilderModel.Parse(json, SchemaBuilderModel.NewObject());
var outer = Assert.Single(node.Properties);
Assert.Equal("outer", outer.Name);
Assert.Equal("object", outer.Schema.Type);
var inner = Assert.Single(outer.Schema.Properties);
Assert.Equal("inner", inner.Name);
Assert.Equal("integer", inner.Schema.Type);
Assert.True(inner.Required);
}
// ── Serialize ─────────────────────────────────────────────────────────────
[Fact]
public void Serialize_EmptyObject_OmitsRequired()
{
var node = new SchemaNode { Type = "object" };
var json = SchemaBuilderModel.Serialize(node);
Assert.Equal("""{"type":"object","properties":{}}""", json);
}
[Fact]
public void Serialize_ObjectWithMixedRequired_EmitsOnlyRequiredNames()
{
var node = new SchemaNode { Type = "object" };
node.Properties.Add(new SchemaProperty { Name = "id", Required = true, Schema = new SchemaNode { Type = "integer" } });
node.Properties.Add(new SchemaProperty { Name = "label", Required = false, Schema = new SchemaNode { Type = "string" } });
var json = SchemaBuilderModel.Serialize(node);
Assert.Equal(
"""{"type":"object","properties":{"id":{"type":"integer"},"label":{"type":"string"}},"required":["id"]}""",
json);
}
[Fact]
public void Serialize_Array_IncludesItems()
{
var node = new SchemaNode { Type = "array", Items = new SchemaNode { Type = "string" } };
Assert.Equal("""{"type":"array","items":{"type":"string"}}""", SchemaBuilderModel.Serialize(node));
}
[Fact]
public void Serialize_PropertiesWithBlankName_Skipped()
{
var node = new SchemaNode { Type = "object" };
node.Properties.Add(new SchemaProperty { Name = "", Schema = new SchemaNode { Type = "integer" } });
node.Properties.Add(new SchemaProperty { Name = "valid", Schema = new SchemaNode { Type = "string" } });
var json = SchemaBuilderModel.Serialize(node);
Assert.Equal("""{"type":"object","properties":{"valid":{"type":"string"}},"required":["valid"]}""", json);
}
// ── Round-trip ────────────────────────────────────────────────────────────
[Fact]
public void RoundTrip_Parse_Then_Serialize_Stable()
{
const string original = """{"type":"object","properties":{"id":{"type":"integer"},"tags":{"type":"array","items":{"type":"string"}}},"required":["id"]}""";
var node = SchemaBuilderModel.Parse(original, SchemaBuilderModel.NewObject());
var roundTripped = SchemaBuilderModel.Serialize(node);
Assert.Equal(original, roundTripped);
}
}
@@ -0,0 +1,158 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Round-trip coverage for the WhileTrue/OnTrue <c>mode</c> field on the
/// Conditional and Expression script triggers.
/// </summary>
public class ScriptTriggerConfigCodecTests
{
// ── Parse: mode field ──────────────────────────────────────────────────
[Fact]
public void Parse_Conditional_WithoutMode_DefaultsToOnTrue()
{
const string json = @"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
}
[Fact]
public void Parse_Conditional_WhileTrue_IsRead()
{
const string json =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80,""mode"":""WhileTrue""}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode);
}
[Fact]
public void Parse_Expression_WithoutMode_DefaultsToOnTrue()
{
const string json = @"{""expression"":""Attributes[\""T\""] > 1""}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
}
[Fact]
public void Parse_Expression_WhileTrue_IsRead()
{
const string json =
@"{""expression"":""Attributes[\""T\""] > 1"",""mode"":""WhileTrue""}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode);
}
[Fact]
public void Parse_UnrecognizedMode_DefaultsToOnTrue()
{
const string json =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80,""mode"":""Sometimes""}";
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
}
// ── Serialize: mode field ──────────────────────────────────────────────
[Fact]
public void Serialize_Conditional_WhileTrue_WritesMode()
{
var model = new ScriptTriggerModel
{
AttributeName = "Temp",
Operator = ">",
Threshold = 80,
Mode = ScriptTriggerMode.WhileTrue
};
var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Conditional);
Assert.Contains("\"mode\":\"WhileTrue\"", json);
}
[Fact]
public void Serialize_Expression_WhileTrue_WritesMode()
{
var model = new ScriptTriggerModel
{
Expression = "Attributes[\"T\"] > 1",
Mode = ScriptTriggerMode.WhileTrue
};
var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Expression);
Assert.Contains("\"mode\":\"WhileTrue\"", json);
}
// ── Round-trip ─────────────────────────────────────────────────────────
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RoundTrip_Conditional_PreservesMode(bool whileTrue)
{
var mode = whileTrue ? ScriptTriggerMode.WhileTrue : ScriptTriggerMode.OnTrue;
var original = new ScriptTriggerModel
{
AttributeName = "Temp",
Operator = ">=",
Threshold = 12.5,
Mode = mode
};
var json = ScriptTriggerConfigCodec.Serialize(original, ScriptTriggerKind.Conditional);
var reparsed = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
Assert.Equal(mode, reparsed.Mode);
}
// ── SupportsMinTimeBetweenRuns ─────────────────────────────────────────
[Theory]
[InlineData("ValueChange")]
[InlineData("Conditional")]
[InlineData("Expression")]
public void SupportsMinTimeBetweenRuns_TrueForAutoTriggersThatThrottle(string triggerType)
{
Assert.True(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
}
[Theory]
[InlineData("Interval")] // has its own period control
[InlineData("Call")] // invoked explicitly — no throttle applies
[InlineData(null)] // None — never runs automatically
[InlineData("Bogus")] // Unknown trigger type
public void SupportsMinTimeBetweenRuns_FalseForIntervalCallNoneAndUnknown(string? triggerType)
{
Assert.False(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RoundTrip_Expression_PreservesMode(bool whileTrue)
{
var mode = whileTrue ? ScriptTriggerMode.WhileTrue : ScriptTriggerMode.OnTrue;
var original = new ScriptTriggerModel
{
Expression = "Attributes[\"T\"] > 1",
Mode = mode
};
var json = ScriptTriggerConfigCodec.Serialize(original, ScriptTriggerKind.Expression);
var reparsed = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
Assert.Equal(mode, reparsed.Mode);
}
}
@@ -0,0 +1,69 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Component tests for the OnTrue/WhileTrue mode selector that
/// <see cref="ScriptTriggerEditor"/> exposes for Conditional and Expression
/// triggers.
/// </summary>
public class ScriptTriggerEditorTests : BunitContext
{
private const string ConditionalConfig =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50}";
private const string ConditionalWhileTrueConfig =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50,""mode"":""WhileTrue""}";
[Fact]
public void SelectingWhileTrue_EmitsConfigWithWhileTrueMode()
{
ScriptTriggerValue? captured = null;
var cut = Render<ScriptTriggerEditor>(ps => ps
.Add(p => p.TriggerType, "Conditional")
.Add(p => p.TriggerConfig, ConditionalConfig)
.Add(p => p.Changed,
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
cut.Find("#script-trigger-mode").Change("WhileTrue");
Assert.NotNull(captured);
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
}
[Fact]
public void ModeSelector_DefaultsToOnTrue_WhenConfigHasNoMode()
{
ScriptTriggerValue? captured = null;
var cut = Render<ScriptTriggerEditor>(ps => ps
.Add(p => p.TriggerType, "Conditional")
.Add(p => p.TriggerConfig, ConditionalConfig)
.Add(p => p.Changed,
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
// Change the threshold to force an emit without touching the mode.
cut.Find("input[type=number]").Input("75");
Assert.NotNull(captured);
Assert.Contains("\"mode\":\"OnTrue\"", captured!.Config);
}
[Fact]
public void LoadedWhileTrueMode_IsRetainedAcrossAnUnrelatedEdit()
{
ScriptTriggerValue? captured = null;
var cut = Render<ScriptTriggerEditor>(ps => ps
.Add(p => p.TriggerType, "Conditional")
.Add(p => p.TriggerConfig, ConditionalWhileTrueConfig)
.Add(p => p.Changed,
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
// Editing the threshold must not silently drop the loaded WhileTrue mode.
cut.Find("input[type=number]").Input("75");
Assert.NotNull(captured);
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
}
}
@@ -0,0 +1,50 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for HealthMonitoring-015. A heartbeat-only registered site has
/// a <c>null</c> <c>LastReportReceivedAt</c> ("no full report yet"). The health
/// dashboard passes that value straight into <see cref="TimestampDisplay"/>, so the
/// component's <c>Value</c> must accept <c>DateTimeOffset?</c> and render a
/// <c>null</c> as a human-readable placeholder ("never") instead of the
/// <c>DateTimeOffset.MinValue</c> year-0001 sentinel. Non-null callers must keep
/// rendering the formatted timestamp exactly as before.
/// </summary>
public class TimestampDisplayTests : BunitContext
{
[Fact]
public void Render_NonNullValue_ShowsFormattedTimestamp()
{
var value = new DateTimeOffset(2026, 5, 17, 14, 30, 45, TimeSpan.Zero);
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)value)
.Add(p => p.Format, "HH:mm:ss"));
var span = cut.Find("span");
Assert.Equal(value.LocalDateTime.ToString("HH:mm:ss"), span.TextContent.Trim());
Assert.Contains("2026-05-17 14:30:45 UTC", span.GetAttribute("title")!);
}
[Fact]
public void Render_NullValue_ShowsNeverPlaceholder()
{
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)null)
.Add(p => p.Format, "HH:mm:ss"));
Assert.Contains("never", cut.Markup, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Render_NullValue_DoesNotRenderYear0001Sentinel()
{
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)null));
// The year-0001 DateTimeOffset.MinValue sentinel must never reach the UI.
Assert.DoesNotContain("0001", cut.Markup);
}
}
@@ -0,0 +1,80 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-010. <c>ToastNotification.AddToast</c>
/// scheduled <c>Task.Delay(dismissMs).ContinueWith(...)</c> with the result
/// discarded; the continuation called <c>InvokeAsync(StateHasChanged)</c>. When
/// the host page is disposed before the delay elapses, the continuation ran
/// against a disposed component and threw <c>ObjectDisposedException</c> on a
/// thread-pool thread with no catch (an unobserved task exception). The fix
/// holds a <c>CancellationTokenSource</c> cancelled in <c>Dispose()</c>.
/// </summary>
public class ToastNotificationTests : BunitContext
{
[Fact]
public async Task ShowToast_AfterDisposal_IsNoOp_AndSchedulesNothing()
{
// Regression: the pre-fix AddToast always added the toast and scheduled
// a Task.Delay continuation, even after Dispose() — the continuation
// then ran InvokeAsync(StateHasChanged) against the disposed component.
// The fix short-circuits AddToast once the disposal token is cancelled.
var cut = Render<ToastNotification>();
await cut.InvokeAsync(() => cut.Instance.Dispose());
await cut.InvokeAsync(() => cut.Instance.ShowError("after dispose", autoDismissMs: 20));
Assert.Equal(0, cut.Instance.ToastCount);
}
[Fact]
public async Task AutoDismiss_AfterDisposal_DoesNotThrowUnobservedException()
{
var unobserved = new List<Exception>();
void Handler(object? s, UnobservedTaskExceptionEventArgs e)
{
unobserved.Add(e.Exception);
e.SetObserved();
}
TaskScheduler.UnobservedTaskException += Handler;
try
{
var cut = Render<ToastNotification>();
// Auto-dismiss after a very short delay so the continuation is
// guaranteed to fire well after we dispose the component.
await cut.InvokeAsync(() => cut.Instance.ShowSuccess("hello", autoDismissMs: 20));
// Dispose the component while the auto-dismiss is still pending.
await cut.InvokeAsync(() => cut.Instance.Dispose());
// Give the (now-cancelled) auto-dismiss well past its delay.
await Task.Delay(250);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
finally
{
TaskScheduler.UnobservedTaskException -= Handler;
}
Assert.Empty(unobserved);
}
[Fact]
public async Task AutoDismiss_BeforeDisposal_StillRemovesToast()
{
var cut = Render<ToastNotification>();
await cut.InvokeAsync(() => cut.Instance.ShowInfo("transient", autoDismissMs: 20));
// The toast is visible immediately.
Assert.Contains("transient", cut.Markup);
// After the dismiss delay it is removed (auto-dismiss still works).
cut.WaitForAssertion(
() => Assert.DoesNotContain("transient", cut.Markup),
timeout: TimeSpan.FromSeconds(2));
}
}
@@ -0,0 +1,62 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-018. <c>TreeView</c>'s storage-restore path
/// called <c>JsonSerializer.Deserialize</c> on the raw <c>treeviewStorage</c>
/// payload outside any try block — a corrupt payload threw an uncaught
/// <c>JsonException</c> during <c>OnAfterRenderAsync</c>, breaking the
/// component. The fix guards the deserialize and ignores a corrupt payload.
/// </summary>
public class TreeViewStorageResilienceTests : BunitContext
{
private record TestNode(string Key, string Label, List<TestNode> Children);
private static List<TestNode> Roots() => new()
{
new("a", "Alpha", new() { new("a1", "Alpha-1", new()) }),
new("b", "Beta", new()),
};
private IRenderedComponent<TreeView<TestNode>> BuildTree()
=> Render<TreeView<TestNode>>(parameters => parameters
.Add(p => p.Items, Roots())
.Add(p => p.ChildrenSelector, n => n.Children)
.Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
.Add(p => p.KeySelector, n => n.Key)
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => b =>
b.AddMarkupContent(0, $"<span>{node.Label}</span>")))
.Add(p => p.StorageKey, "corrupt-tree"));
[Fact]
public void StorageRestore_CorruptJsonPayload_DoesNotThrow_AndStillRenders()
{
// A garbage payload that is not valid JSON for a List<string>.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("{not json at all]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
// Pre-fix: OnAfterRenderAsync threw JsonException out of the unguarded
// Deserialize call. Post-fix: the corrupt payload is ignored.
var cut = BuildTree();
Assert.Contains("Alpha", cut.Markup);
Assert.Contains("Beta", cut.Markup);
}
[Fact]
public void StorageRestore_WrongShapeJson_DoesNotThrow()
{
// Valid JSON, but not a List<string> — an object, not an array.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true)
.SetResult("{\"unexpected\": true}");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = BuildTree();
Assert.Contains("Alpha", cut.Markup);
}
}