215 lines
7.2 KiB
C#
215 lines
7.2 KiB
C#
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;
|
|
|
|
/// <summary>Flow Debugger design 2026-09-05 §5.4: the per-run inline trace sink.</summary>
|
|
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(),
|
|
};
|
|
|
|
/// <summary>Low-priority-lane helper (D7, 2026-09-09): a canonical row built through the
|
|
/// factory, so <c>DetailsJson</c> carries a real envelope around <paramref name="details"/>.</summary>
|
|
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<ArgumentOutOfRangeException>(() => 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
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>DetailsJson</c> truncates first.
|
|
/// </summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>The byte budget has a documented default and is validated like the event cap.</summary>
|
|
[Fact]
|
|
public void DefaultByteBudget_Is1Mb_AndNonPositiveBudgetIsRejected()
|
|
{
|
|
Assert.Equal(1_048_576, FlowTraceCollector.DefaultMaxBytes);
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new FlowTraceCollector(maxBytes: 0));
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[Fact]
|
|
public void AddLowPriority_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.AddLowPriority(Evt());
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>D7 (2026-09-09): the low-priority lane also stops short of the byte budget.</summary>
|
|
[Fact]
|
|
public void AddLowPriority_RespectsByteShare()
|
|
{
|
|
var c = new FlowTraceCollector(maxEvents: 500, maxBytes: 1_000); // 75% = 750 chars
|
|
c.AddLowPriority(Evt(details: new string('x', 600)));
|
|
c.AddLowPriority(Evt(details: new string('x', 200)));
|
|
|
|
Assert.Equal(1, c.Count);
|
|
Assert.True(c.LogsTruncated);
|
|
}
|
|
|
|
/// <summary>D7 (2026-09-09): the reserve floors at 1, so a tiny cap still admits one log line.</summary>
|
|
[Fact]
|
|
public void AddLowPriority_WithTinyCap_StillAllowsOne()
|
|
{
|
|
var c = new FlowTraceCollector(maxEvents: 20, maxBytes: 1_000_000); // max(1, 20-50) = 1
|
|
|
|
c.AddLowPriority(Evt());
|
|
Assert.Equal(1, c.Count);
|
|
Assert.False(c.LogsTruncated);
|
|
|
|
c.AddLowPriority(Evt());
|
|
Assert.Equal(1, c.Count);
|
|
Assert.True(c.LogsTruncated);
|
|
}
|
|
|
|
/// <summary>D7 (2026-09-09): <see cref="FlowTraceCollector.Complete"/> closes the low-priority
|
|
/// lane too — a late log line is dropped silently, not counted as truncated.</summary>
|
|
[Fact]
|
|
public void AddLowPriority_IsNoOpAfterComplete()
|
|
{
|
|
var c = new FlowTraceCollector();
|
|
c.AddLowPriority(Evt());
|
|
c.Complete();
|
|
|
|
c.AddLowPriority(Evt());
|
|
|
|
Assert.Equal(1, c.Count);
|
|
Assert.False(c.LogsTruncated);
|
|
}
|
|
|
|
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
|
|
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 = "{}",
|
|
};
|
|
}
|