Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/ScriptLogHubTests.cs
Joseph Doherty 41f133a337 feat(admin-ui): add /virtual-tags, /scripted-alarms, and /script-log pages (tasks #25, #26, #27)
Gap 2 (#25): VirtualTagsTab.razor + /virtual-tags global page — list/create/toggle
virtual tags per draft generation with DataType, Script, trigger, Historize, Enabled
fields. Tab wired into DraftEditor.

Gap 3 (#26): ScriptedAlarmsTab.razor + /scripted-alarms global page — list/create
scripted alarms with AlarmType, Severity, MessageTemplate, PredicateScript,
HistorizeToAveva, Retain. SeverityBand helper shows Low/Medium/High/Critical label.
Tab wired into DraftEditor.

Gap 4 (#27): ScriptLogHub (SignalR IAsyncEnumerable stream) tails scripts-*.log with
optional ScriptName filter; ScriptLog.razor provides Start/Stop/Clear controls plus
level filter dropdown. Hub registered at /hubs/script-log in Program.cs.

Nav rail gains a "Scripting" eyebrow with entries for all three pages.
19 new unit tests for ScriptLogHub parse/filter/tail helpers (Category=Unit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 05:58:59 -04:00

199 lines
6.5 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// Unit tests for <see cref="ScriptLogHub"/> helper logic — line parsing, filter matching,
/// and the tail/append file reading utilities. The SignalR streaming method itself
/// (TailLogAsync) is not integration-tested here; the helpers are tested in isolation.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptLogHubTests
{
// ── ParseLine ──────────────────────────────────────────────────────────────
[Fact]
public void ParseLine_extracts_INF_level_from_serilog_format()
{
var line = ScriptLogHub.ParseLine("[12:34:56 INF] Script ran successfully");
line.Level.ShouldBe("INF");
}
[Fact]
public void ParseLine_extracts_WRN_level()
{
var line = ScriptLogHub.ParseLine("2026-05-18T12:34:56.000Z [WRN] Script timed out");
line.Level.ShouldBe("WRN");
}
[Fact]
public void ParseLine_extracts_ERR_level()
{
var line = ScriptLogHub.ParseLine("[ERR] NullReferenceException in script");
line.Level.ShouldBe("ERR");
}
[Fact]
public void ParseLine_defaults_to_INF_when_no_level_token()
{
var line = ScriptLogHub.ParseLine("Some unformatted log text with no level");
line.Level.ShouldBe("INF");
}
[Fact]
public void ParseLine_extracts_ScriptName_property()
{
var raw = """[INF] Evaluation complete ScriptName="line-rate-calc" Value=42""";
var line = ScriptLogHub.ParseLine(raw);
line.ScriptName.ShouldBe("line-rate-calc");
}
[Fact]
public void ParseLine_ScriptName_is_null_when_property_absent()
{
var raw = "[INF] Server started";
var line = ScriptLogHub.ParseLine(raw);
line.ScriptName.ShouldBeNull();
}
[Fact]
public void ParseLine_preserves_Raw_text_unchanged()
{
var raw = "[WRN] Script error ScriptName=\"my-alarm\" Details=\"bad value\"";
var line = ScriptLogHub.ParseLine(raw);
line.Raw.ShouldBe(raw);
}
// ── Matches ────────────────────────────────────────────────────────────────
[Fact]
public void Matches_null_filter_accepts_all_lines()
{
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
ScriptLogHub.Matches(line, null).ShouldBeTrue();
}
[Fact]
public void Matches_empty_filter_accepts_all_lines()
{
var line = new ScriptLogLine("raw", "INF", "some-script", DateTime.UtcNow);
ScriptLogHub.Matches(line, "").ShouldBeTrue();
}
[Fact]
public void Matches_whitespace_filter_accepts_all_lines()
{
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
ScriptLogHub.Matches(line, " ").ShouldBeTrue();
}
[Fact]
public void Matches_filter_matches_script_name_case_insensitive()
{
var line = new ScriptLogLine("raw", "INF", "line-rate-calc", DateTime.UtcNow);
ScriptLogHub.Matches(line, "Line-Rate").ShouldBeTrue();
}
[Fact]
public void Matches_filter_rejects_line_with_different_script_name()
{
var line = new ScriptLogLine("raw", "INF", "oven-temp-alarm", DateTime.UtcNow);
ScriptLogHub.Matches(line, "line-rate").ShouldBeFalse();
}
[Fact]
public void Matches_filter_rejects_line_with_null_script_name()
{
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
ScriptLogHub.Matches(line, "line-rate").ShouldBeFalse();
}
[Fact]
public void Matches_filter_supports_partial_match()
{
var line = new ScriptLogLine("raw", "INF", "line-rate-calc", DateTime.UtcNow);
ScriptLogHub.Matches(line, "rate").ShouldBeTrue();
}
// ── ReadTailLines / ReadNewLines ──────────────────────────────────────────
[Fact]
public void ReadTailLines_returns_empty_list_for_empty_file()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, string.Empty);
var lines = ScriptLogHub.ReadTailLines(path, 50, out var pos);
lines.ShouldBeEmpty();
pos.ShouldBe(0);
}
finally { File.Delete(path); }
}
[Fact]
public void ReadTailLines_returns_all_lines_when_fewer_than_n()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllLines(path, ["line1", "line2", "line3"]);
var lines = ScriptLogHub.ReadTailLines(path, 50, out _);
lines.ShouldContain("line1");
lines.ShouldContain("line2");
lines.ShouldContain("line3");
}
finally { File.Delete(path); }
}
[Fact]
public void ReadTailLines_returns_last_n_lines_when_file_is_large()
{
var path = Path.GetTempFileName();
try
{
var allLines = Enumerable.Range(1, 20).Select(i => $"line{i}").ToArray();
File.WriteAllLines(path, allLines);
var lines = ScriptLogHub.ReadTailLines(path, 5, out _);
lines.Count.ShouldBe(5);
lines[^1].ShouldBe("line20");
}
finally { File.Delete(path); }
}
[Fact]
public void ReadNewLines_returns_empty_when_nothing_appended()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, "existing content\n");
ScriptLogHub.ReadTailLines(path, 10, out var pos); // seed position
var newLines = ScriptLogHub.ReadNewLines(path, ref pos);
newLines.ShouldBeEmpty();
}
finally { File.Delete(path); }
}
[Fact]
public void ReadNewLines_returns_appended_lines()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, "existing\n");
ScriptLogHub.ReadTailLines(path, 10, out var pos); // set position to end
// Append new content
File.AppendAllText(path, "appended-line-1\nappended-line-2\n");
var newLines = ScriptLogHub.ReadNewLines(path, ref pos);
newLines.ShouldContain("appended-line-1");
newLines.ShouldContain("appended-line-2");
}
finally { File.Delete(path); }
}
}