using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging; /// Flow Debugger design 2026-09-05 §5.4: the per-run inline trace sink. public class FlowTraceCollectorTests { private static AuditEvent Evt(int i) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "t", Action = "ScriptTrace.AttributeWrite", Outcome = AuditOutcome.Success, Target = i.ToString(), }; /// Low-priority-lane helper (D7, 2026-09-09): a canonical row built through the /// factory, so DetailsJson carries a real envelope around . private static AuditEvent Evt(string? details = null) => ScadaBridgeAuditEventFactory.Create( AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, target: "S", requestSummary: details); [Fact] public void Add_BeyondCap_KeepsFirstN_AndFlagsTruncated() { var c = new FlowTraceCollector(maxEvents: 500); Assert.False(c.Truncated); for (var i = 0; i < 501; i++) c.Add(Evt(i)); var snap = c.Snapshot(); Assert.Equal(500, snap.Count); Assert.True(c.Truncated); Assert.Equal("0", snap[0].Target); Assert.Equal("499", snap[499].Target); } [Fact] public void Snapshot_IsACopy_InEmissionOrder() { var c = new FlowTraceCollector(); c.Add(Evt(1)); var first = c.Snapshot(); c.Add(Evt(2)); Assert.Single(first); Assert.Equal(2, c.Count); Assert.Equal(new[] { "1", "2" }, c.Snapshot().Select(e => e.Target)); } [Fact] public async Task ConcurrentAdds_NeverThrow_AndNeverExceedCap() { var c = new FlowTraceCollector(maxEvents: 100); var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() => { for (var i = 0; i < 50; i++) c.Add(Evt(t * 100 + i)); })); await Task.WhenAll(tasks); Assert.Equal(100, c.Snapshot().Count); Assert.True(c.Truncated); } [Fact] public void DefaultCap_Is500_AndNonPositiveCapIsRejected() { Assert.Equal(500, FlowTraceCollector.DefaultMaxEvents); Assert.Throws(() => new FlowTraceCollector(0)); } [Fact] public void Complete_ClosesTheCollector_LaterAddsAreIgnored_AndNeverTruncate() { var c = new FlowTraceCollector(maxEvents: 2); var first = NewEvent(); c.Add(first); Assert.False(c.Completed); var snapshot = c.Complete(); Assert.True(c.Completed); Assert.Same(first, Assert.Single(snapshot)); c.Add(NewEvent()); c.Add(NewEvent()); c.Add(NewEvent()); Assert.Equal(1, c.Count); Assert.False(c.Truncated); Assert.Equal(snapshot, c.Complete()); // idempotent } /// /// Arch-review F1: the inline copy must be the SAME filtered record the writer persists — /// the collector applies the redactor itself rather than handing on the raw event. /// [Fact] public void Add_AppliesTheRedactor_SoTheInlineCopyIsTheFilteredEvent() { var c = new FlowTraceCollector(redactor: new UpperRedactor()); var evt = Evt(1) with { DetailsJson = "{\"secret\":\"abc\"}" }; c.Add(evt); var stored = Assert.Single(c.Complete()); Assert.Equal("{\"SECRET\":\"ABC\"}", stored.DetailsJson); Assert.NotSame(evt, stored); } /// /// Arch-review F1: the 500-event cap alone does not bound the gRPC reply the inline copy /// rides on — a byte budget over the redacted DetailsJson truncates first. /// [Fact] public void Add_OverByteBudget_Truncates_KeepsEarlierEvents() { var c = new FlowTraceCollector(maxEvents: 500, maxBytes: 100); var big = Evt(1) with { DetailsJson = new string('x', 80) }; var big2 = Evt(2) with { DetailsJson = new string('y', 80) }; c.Add(big); c.Add(big2); Assert.True(c.Truncated); Assert.Single(c.Complete()); } /// The byte budget has a documented default and is validated like the event cap. [Fact] public void DefaultByteBudget_Is1Mb_AndNonPositiveBudgetIsRejected() { Assert.Equal(1_048_576, FlowTraceCollector.DefaultMaxBytes); Assert.Throws(() => new FlowTraceCollector(maxBytes: 0)); } /// D7 (2026-09-09): the low-priority lane stops short of the event cap so the /// step rows a run emits at the end always have room. [Fact] public void Add_LowPriority_StopsBeforeTheReserve_AndSetsLogsTruncated() { var c = new FlowTraceCollector(maxEvents: 60, maxBytes: 1_000_000); // reserve = 50 → low-priority stops at 10 for (var i = 0; i < 12; i++) c.Add(Evt(), lowPriority: true); Assert.Equal(10, c.Count); Assert.True(c.LogsTruncated); Assert.False(c.Truncated); c.Add(Evt()); // a step row still fits Assert.Equal(11, c.Count); } /// D7 (2026-09-09): the low-priority lane also stops short of the byte budget. [Fact] public void Add_LowPriority_RespectsByteShare() { var c = new FlowTraceCollector(maxEvents: 500, maxBytes: 1_000); // 75% = 750 chars c.Add(Evt(details: new string('x', 600)), lowPriority: true); c.Add(Evt(details: new string('x', 200)), lowPriority: true); Assert.Equal(1, c.Count); Assert.True(c.LogsTruncated); } /// D7 (2026-09-09): the reserve floors at 1, so a tiny cap still admits one log line. [Fact] public void Add_LowPriority_WithTinyCap_StillAllowsOne() { var c = new FlowTraceCollector(maxEvents: 20, maxBytes: 1_000_000); // max(1, 20-50) = 1 c.Add(Evt(), lowPriority: true); Assert.Equal(1, c.Count); Assert.False(c.LogsTruncated); c.Add(Evt(), lowPriority: true); Assert.Equal(1, c.Count); Assert.True(c.LogsTruncated); } /// D7 (2026-09-09): closes the low-priority /// lane too — a late log line is dropped silently, not counted as truncated. [Fact] public void Add_LowPriority_IsNoOpAfterComplete() { var c = new FlowTraceCollector(); c.Add(Evt(), lowPriority: true); c.Complete(); c.Add(Evt(), lowPriority: true); Assert.Equal(1, c.Count); Assert.False(c.LogsTruncated); } /// Upper-cases DetailsJson so a test can see the filtered copy land. private sealed class UpperRedactor : IAuditRedactor { public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = rawEvent.DetailsJson?.ToUpperInvariant() }; } private static AuditEvent NewEvent() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "t", Action = "ScriptTrace.ScriptRun", Outcome = AuditOutcome.Success, DetailsJson = "{}", }; }