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>
199 lines
6.5 KiB
C#
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); }
|
|
}
|
|
}
|