fix(alarms): atomic snapshot+truncation read; direct tests for the flag plumbing (review)
Review found AlarmDispatcher.SnapshotActiveAlarms reading the snapshot and the truncation verdict through two independent lock acquisitions, defended by a comment claiming read-order made a race "widen only, never narrow". That claim was false: a not-truncated -> truncated poll landing between the two reads pairs a stale false with a capped snapshot, which is exactly the false all-clear the feature exists to prevent. It was safe only because AlarmCommandHandler STA-serializes consumer calls — an accident of the call graph, not an invariant. Made the invariant structural instead of documented. IMxAccessAlarmConsumer now exposes ONE accessor, `IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms( out bool truncated)`, which implementations must satisfy from a single acquisition of the lock guarding the retained snapshot — mirroring the write side, where FoldFetch already updates snapshot and verdict together. The separate LastSnapshotTruncated property is gone from every layer, so there is no second read left to pair badly. `out` over a result struct follows the file's established idiom (FoldFetch, ParseSnapshotXml). The same threading applies one level up: IAlarmCommandHandler.QueryActive now carries `out bool snapshotTruncated`, so MxAccessCommandExecutor stamps the reply payload from the value the records were stamped with rather than reading the state a second time. Direct tests for the three hops that were only covered end-to-end: - AlarmDispatcherTests: truncated consumer snapshot stamps FromTruncatedSnapshot on every mapped record, with a complete-snapshot control, plus an assertion that the independent per-record Degraded flag is not dragged along. - AlarmCommandHandlerTests: the verdict delegates through the dispatcher (Theory over both values), and survives a prefix filter that removes every record — the case the per-record flag cannot cover. - AlarmCommandExecutorTests: the reply payload's SnapshotTruncated comes from the handler (Theory over both values), including the zero-record case. The WnWrapAlarmConsumer truncation tests now assert through SnapshotActiveAlarms(out ...) rather than an internal field, because the pairing is the contract. Also: GatewayAlarmMonitor's _snapshotTruncated comment now says "as of the last full reconcile" rather than implying it tracks the current _alarms contents, which live transitions keep moving via ApplyTransition between passes. Detection heuristic still untouched (fetchedRecordCount >= maxAlarmsPerFetch); no @COUNT parsing, per docs/AlarmProbeFindings.md. Still additive gateway metadata about our fetch mechanics, not MXAccess behavior — not a parity deviation, and no event is synthesized. Gateway: NonWindows.slnx builds clean (0 warnings); ~Alarm filter 107/107 pass. Worker + Worker.Tests are windev-gated; the signature change was reviewed by inspection across all 7 IMxAccessAlarmConsumer implementers, all 3 IAlarmCommandHandler implementers, and every call site.
This commit is contained in:
@@ -262,6 +262,72 @@ public sealed class AlarmCommandExecutorTests
|
||||
Assert.Equal("Galaxy!A", handler.LastFilterPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reply payload's <c>SnapshotTruncated</c> comes from the handler's
|
||||
/// verdict. This is the last hop before the IPC frame; if the executor
|
||||
/// dropped it, a filtered query returning no records would carry no
|
||||
/// completeness caveat at all.
|
||||
/// </summary>
|
||||
/// <param name="handlerReportsTruncated">The verdict the fake handler reports.</param>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void QueryActiveAlarms_StampsSnapshotTruncatedFromHandler(bool handlerReportsTruncated)
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
SnapshotTruncated = handlerReportsTruncated,
|
||||
QueryResult = new[]
|
||||
{
|
||||
new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T1" },
|
||||
},
|
||||
};
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
|
||||
StaCommand command = new StaCommand(
|
||||
SessionId, CorrelationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.QueryActiveAlarms,
|
||||
QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand(),
|
||||
});
|
||||
|
||||
MxCommandReply reply = executor.Execute(command);
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.NotNull(reply.QueryActiveAlarms);
|
||||
Assert.Equal(handlerReportsTruncated, reply.QueryActiveAlarms.SnapshotTruncated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A truncated fetch whose records all filtered out still reports the
|
||||
/// caveat on the payload — the case the per-record flag cannot cover.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QueryActiveAlarms_WithTruncatedFetchAndNoRecords_StillReportsTruncation()
|
||||
{
|
||||
FakeAlarmHandler handler = new FakeAlarmHandler
|
||||
{
|
||||
SnapshotTruncated = true,
|
||||
QueryResult = Array.Empty<ActiveAlarmSnapshot>(),
|
||||
};
|
||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||
|
||||
StaCommand command = new StaCommand(
|
||||
SessionId, CorrelationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.QueryActiveAlarms,
|
||||
QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand(),
|
||||
});
|
||||
|
||||
MxCommandReply reply = executor.Execute(command);
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Empty(reply.QueryActiveAlarms.Snapshots);
|
||||
Assert.True(reply.QueryActiveAlarms.SnapshotTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribe routes to handler.</summary>
|
||||
[Fact]
|
||||
public void UnsubscribeAlarms_WithHandler_RoutesToHandler()
|
||||
@@ -371,8 +437,8 @@ public sealed class AlarmCommandExecutorTests
|
||||
/// <summary>Gets the last alarm filter prefix.</summary>
|
||||
public string? LastFilterPrefix { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict the executor stamps onto the reply payload.</summary>
|
||||
public bool LastSnapshotTruncated { get; set; }
|
||||
/// <summary>Gets or sets the truncation verdict this handler reports with its query result.</summary>
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
|
||||
@@ -416,9 +482,12 @@ public sealed class AlarmCommandExecutorTests
|
||||
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
|
||||
string? alarmFilterPrefix,
|
||||
out bool snapshotTruncated)
|
||||
{
|
||||
LastFilterPrefix = alarmFilterPrefix;
|
||||
snapshotTruncated = SnapshotTruncated;
|
||||
return QueryResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
() => consumer);
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null);
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null, out _);
|
||||
|
||||
Assert.Single(snapshots);
|
||||
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
|
||||
@@ -175,12 +175,66 @@ public sealed class AlarmCommandHandlerTests
|
||||
() => consumer);
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> filtered = handler.QueryActive("Galaxy!AreaA");
|
||||
IReadOnlyList<ActiveAlarmSnapshot> filtered = handler.QueryActive("Galaxy!AreaA", out _);
|
||||
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The consumer's truncation verdict reaches the caller through the
|
||||
/// handler and its dispatcher. This is the middle hop of the flag's
|
||||
/// journey to the QueryActiveAlarms reply payload; without it the reply
|
||||
/// builder would have nothing to stamp.
|
||||
/// </summary>
|
||||
/// <param name="consumerReportsTruncated">The verdict the fake consumer reports.</param>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void QueryActive_ReportsConsumerTruncationVerdict(bool consumerReportsTruncated)
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer
|
||||
{
|
||||
SnapshotTruncated = consumerReportsTruncated,
|
||||
SnapshotResult = new[] { NewRecord("Galaxy", "AreaA", "Tag1") },
|
||||
};
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
() => consumer);
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null, out bool snapshotTruncated);
|
||||
|
||||
Assert.Equal(consumerReportsTruncated, snapshotTruncated);
|
||||
Assert.Equal(consumerReportsTruncated, Assert.Single(snapshots).FromTruncatedSnapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A prefix filter that removes every record must not remove the verdict
|
||||
/// with them. This is exactly why the flag rides out separately as well as
|
||||
/// on each record: a scoped query over a truncated fetch can legitimately
|
||||
/// return nothing and still owe the caller the completeness caveat.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QueryActive_WhenPrefixFiltersOutEveryRecord_StillReportsTruncation()
|
||||
{
|
||||
FakeConsumer consumer = new FakeConsumer
|
||||
{
|
||||
SnapshotTruncated = true,
|
||||
SnapshotResult = new[] { NewRecord("Galaxy", "AreaB", "Tag2") },
|
||||
};
|
||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
() => consumer);
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> filtered =
|
||||
handler.QueryActive("Galaxy!AreaA", out bool snapshotTruncated);
|
||||
|
||||
Assert.Empty(filtered);
|
||||
Assert.True(snapshotTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dispose unsubscribes and disposes consumer when subscribed.</summary>
|
||||
[Fact]
|
||||
public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer()
|
||||
@@ -227,7 +281,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F");
|
||||
Assert.Equal(3, guardInvocations);
|
||||
|
||||
_ = handler.QueryActive(null);
|
||||
_ = handler.QueryActive(null, out _);
|
||||
Assert.Equal(4, guardInvocations);
|
||||
|
||||
handler.PollOnce();
|
||||
@@ -268,7 +322,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
|
||||
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null));
|
||||
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
|
||||
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
|
||||
Assert.Throws<InvalidOperationException>(() => handler.Unsubscribe());
|
||||
}
|
||||
@@ -470,11 +524,15 @@ public sealed class AlarmCommandHandlerTests
|
||||
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
|
||||
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult;
|
||||
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict reported for the last fetch.</summary>
|
||||
public bool LastSnapshotTruncated { get; set; }
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
|
||||
{
|
||||
truncated = SnapshotTruncated;
|
||||
return SnapshotResult;
|
||||
}
|
||||
|
||||
/// <summary>Gets the number of times polled.</summary>
|
||||
public int PollCount { get; private set; }
|
||||
|
||||
@@ -267,7 +267,7 @@ public sealed class AlarmDispatcherTests
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms();
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
|
||||
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
|
||||
@@ -323,7 +323,7 @@ public sealed class AlarmDispatcherTests
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms();
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
|
||||
Assert.True(snapshots[0].Degraded);
|
||||
@@ -333,6 +333,82 @@ public sealed class AlarmDispatcherTests
|
||||
Assert.Equal(AlarmProviderMode.Alarmmgr, snapshots[1].SourceProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A truncated consumer snapshot stamps every mapped record with
|
||||
/// <c>FromTruncatedSnapshot</c> and reports the verdict out of the same
|
||||
/// call. Every record carries it because the public QueryActiveAlarms RPC
|
||||
/// streams bare snapshots with no envelope to hold set-level status, so
|
||||
/// a client that reads only one record must still learn the set may be
|
||||
/// incomplete.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SnapshotActiveAlarms_WhenConsumerReportsTruncated_StampsEveryRecord()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer
|
||||
{
|
||||
SnapshotTruncated = true,
|
||||
SnapshotResult = new[]
|
||||
{
|
||||
NewSnapshotRecord("Tag1", degraded: false),
|
||||
NewSnapshotRecord("Tag2", degraded: true),
|
||||
},
|
||||
};
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
||||
|
||||
Assert.True(truncated);
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
Assert.All(snapshots, snapshot => Assert.True(snapshot.FromTruncatedSnapshot));
|
||||
// Truncation is about the SET; the per-record provider fidelity flag is
|
||||
// independent and must not be dragged along with it.
|
||||
Assert.False(snapshots[0].Degraded);
|
||||
Assert.True(snapshots[1].Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control. A complete consumer snapshot must leave every record's
|
||||
/// <c>FromTruncatedSnapshot</c> unset — without this, a field hard-wired
|
||||
/// to true would satisfy the test above and every snapshot would read as
|
||||
/// possibly-incomplete.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SnapshotActiveAlarms_WhenConsumerReportsComplete_LeavesRecordsUnstamped()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer
|
||||
{
|
||||
SnapshotTruncated = false,
|
||||
SnapshotResult = new[] { NewSnapshotRecord("Tag1", degraded: false) },
|
||||
};
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
||||
|
||||
Assert.False(truncated);
|
||||
Assert.False(Assert.Single(snapshots).FromTruncatedSnapshot);
|
||||
}
|
||||
|
||||
private static MxAlarmSnapshotRecord NewSnapshotRecord(string tagName, bool degraded)
|
||||
{
|
||||
return new MxAlarmSnapshotRecord
|
||||
{
|
||||
AlarmGuid = Guid.NewGuid(),
|
||||
ProviderName = "Galaxy",
|
||||
Group = "TestArea",
|
||||
TagName = tagName,
|
||||
Type = "DSC",
|
||||
Priority = 500,
|
||||
State = MxAlarmStateKind.UnackAlm,
|
||||
Degraded = degraded,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dispose unsubscribes the handler and disposes the consumer.</summary>
|
||||
[Fact]
|
||||
public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer()
|
||||
@@ -432,15 +508,16 @@ public sealed class AlarmDispatcherTests
|
||||
/// <summary>Gets the last acknowledge-by-name tuple (alarm name, provider, group).</summary>
|
||||
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
|
||||
{
|
||||
truncated = SnapshotTruncated;
|
||||
return SnapshotResult;
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict the dispatcher stamps onto snapshots.</summary>
|
||||
public bool LastSnapshotTruncated { get; set; }
|
||||
|
||||
/// <summary>Gets the count of poll operations.</summary>
|
||||
public int PollCount { get; private set; }
|
||||
|
||||
|
||||
@@ -78,10 +78,14 @@ public sealed class FailoverAlarmConsumerTests
|
||||
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => Array.Empty<MxAlarmSnapshotRecord>();
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
|
||||
{
|
||||
truncated = SnapshotTruncated;
|
||||
return Array.Empty<MxAlarmSnapshotRecord>();
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict this child reports, so delegation is observable.</summary>
|
||||
public bool LastSnapshotTruncated { get; set; }
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() { }
|
||||
@@ -124,7 +128,7 @@ public sealed class FailoverAlarmConsumerTests
|
||||
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
|
||||
{
|
||||
SnapshotCalls++;
|
||||
if (ThrowOnSnapshot)
|
||||
@@ -132,11 +136,12 @@ public sealed class FailoverAlarmConsumerTests
|
||||
throw new InvalidOperationException("priming snapshot failed");
|
||||
}
|
||||
|
||||
truncated = SnapshotTruncated;
|
||||
return Array.Empty<MxAlarmSnapshotRecord>();
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict this child reports, so delegation is observable.</summary>
|
||||
public bool LastSnapshotTruncated { get; set; }
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() { }
|
||||
|
||||
@@ -643,9 +643,6 @@ public sealed class MxAccessStaSessionTests
|
||||
get { lock (gate) return lastPollThreadId; }
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict reported for the last fetch.</summary>
|
||||
public bool LastSnapshotTruncated { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
|
||||
{
|
||||
@@ -671,8 +668,13 @@ public sealed class MxAccessStaSessionTests
|
||||
=> 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
|
||||
=> Array.Empty<ActiveAlarmSnapshot>();
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
|
||||
string? alarmFilterPrefix,
|
||||
out bool snapshotTruncated)
|
||||
{
|
||||
snapshotTruncated = false;
|
||||
return Array.Empty<ActiveAlarmSnapshot>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void PollOnce()
|
||||
|
||||
@@ -130,7 +130,7 @@ public sealed class SubtagAlarmConsumerTests
|
||||
|
||||
source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> snapshot = consumer.SnapshotActiveAlarms();
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> snapshot = consumer.SnapshotActiveAlarms(out _);
|
||||
|
||||
Assert.Single(snapshot);
|
||||
Assert.True(snapshot[0].Degraded);
|
||||
@@ -150,7 +150,7 @@ public sealed class SubtagAlarmConsumerTests
|
||||
|
||||
source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> snapshot = consumer.SnapshotActiveAlarms();
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> snapshot = consumer.SnapshotActiveAlarms(out _);
|
||||
|
||||
Assert.NotNull(emitted);
|
||||
Assert.Single(snapshot);
|
||||
|
||||
@@ -691,23 +691,29 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
// -------------------------------------------------------------------------
|
||||
// Degraded-status signal. The truncation guard above keeps a capped fetch
|
||||
// from broadcasting phantom Clears, but it does so silently: the retained
|
||||
// snapshot simply stops shrinking. LastSnapshotTruncated is what makes that
|
||||
// suppression visible to the QueryActiveAlarms reply and, through it, the
|
||||
// dashboard banner — so its set/reset behaviour is the contract, not detail.
|
||||
// snapshot simply stops shrinking. The truncation verdict SnapshotActiveAlarms
|
||||
// hands back alongside the records is what makes that suppression visible to
|
||||
// the QueryActiveAlarms reply and, through it, the dashboard banner — so its
|
||||
// set/reset behaviour is the contract, not detail.
|
||||
//
|
||||
// These assert through SnapshotActiveAlarms(out ...) rather than any internal
|
||||
// field, because the pairing IS the contract: records and verdict must come
|
||||
// out of one call, produced under one lock acquisition.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A capped fetch sets the retained truncation verdict. Without this the
|
||||
/// signal never leaves the consumer and the reply builder stamps a
|
||||
/// complete-looking snapshot over a capped one.
|
||||
/// A capped fetch sets the truncation verdict handed out with the
|
||||
/// snapshot. Without this the signal never leaves the consumer and the
|
||||
/// reply builder stamps a complete-looking snapshot over a capped one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FoldFetch_WhenFetchTruncated_SetsLastSnapshotTruncated()
|
||||
public void SnapshotActiveAlarms_AfterTruncatedFetch_ReportsTruncated()
|
||||
{
|
||||
const int Cap = 8;
|
||||
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
|
||||
|
||||
Assert.False(consumer.LastSnapshotTruncated);
|
||||
consumer.SnapshotActiveAlarms(out bool truncatedBeforeAnyFetch);
|
||||
Assert.False(truncatedBeforeAnyFetch);
|
||||
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next =
|
||||
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out int fetchedRecordCount);
|
||||
@@ -715,8 +721,14 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
|
||||
consumer.FoldFetch(next, truncated: true, out int retainedCount);
|
||||
|
||||
Assert.True(consumer.LastSnapshotTruncated);
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> snapshot =
|
||||
consumer.SnapshotActiveAlarms(out bool truncated);
|
||||
|
||||
Assert.True(truncated);
|
||||
Assert.Equal(Cap, retainedCount);
|
||||
// The verdict describes THIS set — assert they arrive together, not just
|
||||
// that the boolean flipped somewhere.
|
||||
Assert.Equal(Cap, snapshot.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -727,7 +739,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
/// it — the opposite of what the signal is for.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FoldFetch_AfterTruncatedFetch_SubCapFetchClearsLastSnapshotTruncated()
|
||||
public void SnapshotActiveAlarms_AfterSubCapFetchFollowingTruncation_ReportsComplete()
|
||||
{
|
||||
const int Cap = 8;
|
||||
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
|
||||
@@ -735,7 +747,8 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> capped =
|
||||
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
|
||||
consumer.FoldFetch(capped, truncated: true, out _);
|
||||
Assert.True(consumer.LastSnapshotTruncated);
|
||||
consumer.SnapshotActiveAlarms(out bool truncatedAfterCappedFetch);
|
||||
Assert.True(truncatedAfterCappedFetch);
|
||||
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> complete =
|
||||
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap - 1), out int fetchedRecordCount);
|
||||
@@ -743,11 +756,15 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
|
||||
consumer.FoldFetch(complete, truncated: false, out int retainedCount);
|
||||
|
||||
Assert.False(consumer.LastSnapshotTruncated);
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> snapshot =
|
||||
consumer.SnapshotActiveAlarms(out bool truncated);
|
||||
|
||||
Assert.False(truncated);
|
||||
// The complete fetch also replaced the snapshot wholesale, which is what
|
||||
// makes it authoritative about absence — pinned here so a future change
|
||||
// cannot clear the verdict while keeping the merge semantics.
|
||||
Assert.Equal(Cap - 1, retainedCount);
|
||||
Assert.Equal(Cap - 1, snapshot.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -756,7 +773,7 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
/// still see the caveat.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FoldFetch_WithConsecutiveTruncatedFetches_KeepsLastSnapshotTruncatedSet()
|
||||
public void SnapshotActiveAlarms_WithConsecutiveTruncatedFetches_KeepsReportingTruncated()
|
||||
{
|
||||
const int Cap = 8;
|
||||
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
|
||||
@@ -766,7 +783,9 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> capped =
|
||||
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
|
||||
consumer.FoldFetch(capped, truncated: true, out _);
|
||||
Assert.True(consumer.LastSnapshotTruncated);
|
||||
|
||||
consumer.SnapshotActiveAlarms(out bool truncated);
|
||||
Assert.True(truncated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user