using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Admin.Hubs; namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; /// /// Unit tests for 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. /// [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); } } }