using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; 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(), }; [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)); } /// 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 = "{}", }; }