Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EventSourceFanOutTests.cs
T
Joseph Doherty 3a312ffdd1
v2-ci / unit-tests (push) Has been cancelled
fix(history): a folder's event history reads the Galaxy objects behind its native conditions (#886)
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a
Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event
history reached no production folder. Its lower-case property keys were also missed, so every row read
Severity 1 with no ConditionName.

Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition
that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use
the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef,
up to the first '.':
- The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for
  a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality).
- The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory,
  forwarded by the deferred sink). A rebuild or the condition's removal forgets it.
Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling.

Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire
contract is unchanged. EventSourceFanOut merges the reads as one source:
- order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId,
  so the boundary skip stays stable;
- duplicates by EventId are dropped and the merge is cut to the cap;
- a truncated source bounds the page at the time it stopped.
Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster
over-fetch are unchanged. A folder with one source is read exactly as before.

ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied
folder reads nothing and fails closed.

Mapping (EventMapper, property names now case-insensitive):
- Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use
  (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own
  rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a
  higher priority as more severe, which inverts AVEVA's convention: filed as #887.
- An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that
  condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition
  variable's attribute.
- alarm_id/alarmid map to AlarmId.

Tests:
- GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake
  gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from
  both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page,
  reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend
  call plus a granted positive control, and a rebuild forgetting the bindings.
- EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and
  an applier ordering test.
- Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive
  condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at
  the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no
  applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise
  guard survives as belt-and-braces: the rebuild clear covers it.
- Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects
  DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both
  objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition.
  diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity
  1 -> 750/1000, and #881 capabilities.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 11:45:48 -05:00

203 lines
10 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #886 — a folder's several event sources read as one: merged in <c>(EventTimeUtc, EventId)</c> order, the cap
/// applied to the merge, and "more" never claimed for a stretch a truncated source has not read yet.
/// </summary>
public sealed class EventSourceFanOutTests
{
private static readonly DateTime T0 = new(2026, 11, 12, 10, 0, 0, DateTimeKind.Utc);
private static HistoricalEvent Ev(string id, string source, int second) =>
new(id, source, T0.AddSeconds(second), T0.AddSeconds(second), null, 1);
[Fact]
public async Task One_source_is_read_straight_through()
{
var backend = new StoredEvents(("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2)));
var r = await EventSourceFanOut.ReadAsync(backend, ["A"], T0, T0.AddMinutes(1), 10, reverse: false, TestContext.Current.CancellationToken);
r.Events.Select(e => e.EventId).ShouldBe(["a1", "a2"]);
backend.Requests.ShouldBe(["A"]);
}
[Fact]
public async Task Two_sources_merge_in_time_then_source_then_arrival_order_and_drop_a_duplicate()
{
// Ties keep each source's own order (z-tie before y-tie: A's arrival), and A's before B's (list order) — never
// re-sorted by EventId, which could move an unread tie in front of one already sent.
var backend = new StoredEvents(
("A", Ev("a1", "A", 1)), ("A", Ev("a3", "A.X", 3)), ("A", Ev("z-tie", "A", 5)), ("A", Ev("y-tie", "A", 5)),
("B", Ev("b2", "B.Y", 2)), ("B", Ev("a-tie", "B", 5)), ("B", Ev("a1", "A", 1)));
var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 10, reverse: false, TestContext.Current.CancellationToken);
r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2", "a3", "z-tie", "y-tie", "a-tie"]);
r.ContinuationPoint.ShouldBeNull();
backend.Requests.ShouldBe(["A", "B"]);
}
[Fact]
public async Task A_reverse_read_is_the_forward_merge_backwards()
{
var backend = new StoredEvents(
("A", Ev("a1", "A", 1)), ("A", Ev("z-tie", "A", 5)), ("A", Ev("y-tie", "A", 5)),
("B", Ev("b2", "B", 2)), ("B", Ev("a-tie", "B", 5)));
var ct = TestContext.Current.CancellationToken;
var forward = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 10, reverse: false, ct);
var reverse = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0.AddMinutes(1), T0, 10, reverse: true, ct);
// What the server's tie-cluster over-fetch relies on: a forward read of a cluster, reversed, is the reverse order.
reverse.Events.Select(e => e.EventId).ShouldBe(forward.Events.Select(e => e.EventId).Reverse());
reverse.Events.Select(e => e.EventId).ShouldBe(["a-tie", "y-tie", "z-tie", "b2", "a1"]);
}
[Fact]
public async Task The_cap_applies_to_the_merge_and_flags_more()
{
var backend = new StoredEvents(("A", Ev("a1", "A", 1)), ("A", Ev("a3", "A", 3)), ("B", Ev("b2", "B", 2)));
var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 2, reverse: false, TestContext.Current.CancellationToken);
r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2"]);
r.ContinuationPoint.ShouldNotBeNull();
}
[Fact]
public async Task A_truncated_source_bounds_the_page_at_the_time_it_stopped()
{
// A default-capped read (maxEvents 0): A stops after two of its three events (at 2); B returns all of its own.
// Unbounded, the page would run on to b4 although A's a3 (at 3) is still unread, so the next page would start
// after it. The page ends at A's stop, with B's tie at 2 left out because it follows A's (unread) ties there.
var backend = new StoredEvents(
("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2)), ("A", Ev("a3", "A", 3)),
("B", Ev("b2", "B", 2)), ("B", Ev("b4", "B", 4)))
{ DefaultCaps = { ["A"] = 2 } };
var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 0, reverse: false, TestContext.Current.CancellationToken);
r.Events.Select(e => e.EventId).ShouldBe(["a1", "a2"]);
r.ContinuationPoint.ShouldNotBeNull();
}
[Fact]
public async Task At_the_bound_a_complete_source_ahead_of_the_truncated_one_keeps_its_ties()
{
var backend = new StoredEvents(
("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2)), ("A", Ev("a3", "A", 3)),
("B", Ev("b2", "B", 2)), ("B", Ev("b4", "B", 4)))
{ DefaultCaps = { ["A"] = 2 } };
// B is listed first, so at 2 its tie precedes A's in merge order and is complete: it stays.
var r = await EventSourceFanOut.ReadAsync(backend, ["B", "A"], T0, T0.AddMinutes(1), 0, reverse: false, TestContext.Current.CancellationToken);
r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2", "a2"]);
r.ContinuationPoint.ShouldNotBeNull();
}
[Fact]
public async Task A_truncated_source_bounds_a_reverse_page_at_the_oldest_time_it_returned()
{
var backend = new StoredEvents(
("A", Ev("a3", "A", 3)), ("A", Ev("a4", "A", 4)), ("A", Ev("a5", "A", 5)),
("B", Ev("b4", "B", 4)), ("B", Ev("b1", "B", 1)))
{ DefaultCaps = { ["A"] = 2 } };
var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0.AddMinutes(1), T0, 0, reverse: true, TestContext.Current.CancellationToken);
// A returned 5, 4 and stopped at 4. Reverse merge order at 4 visits B (listed later) first, then A: b4 is
// complete and kept, A's a4 closes the page, and b1 (older than A's unread a3) is left for the next page.
r.Events.Select(e => e.EventId).ShouldBe(["a5", "b4", "a4"]);
r.ContinuationPoint.ShouldNotBeNull();
}
[Fact]
public async Task Paging_the_merge_from_each_last_event_walks_every_event_once()
{
// The server's resume rule: read again from the last event's time INCLUSIVE and skip the ties already sent.
var stored = new List<(string, HistoricalEvent)>();
for (var i = 0; i < 9; i++) stored.Add(("A", Ev($"a{i}", "A", i)));
for (var i = 0; i < 9; i += 2) stored.Add(("B", Ev($"b{i}", "B.X", i)));
stored.Add(("B", Ev("b-tie", "B", 4)));
var backend = new StoredEvents([.. stored]);
var whole = (await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 100, false, TestContext.Current.CancellationToken))
.Events.Select(e => e.EventId).ToList();
foreach (var cap in new[] { 1, 2, 3, 5 })
{
var got = new List<string>();
var start = T0;
var skip = 0;
for (var guard = 0; guard < 100; guard++)
{
var page = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], start, T0.AddMinutes(1), cap + skip, false, TestContext.Current.CancellationToken);
var events = page.Events.SkipWhile((e, i) => i < skip && e.EventTimeUtc == start).Take(cap).ToList();
got.AddRange(events.Select(e => e.EventId));
var more = page.ContinuationPoint is not null || page.Events.Count - Math.Min(skip, page.Events.Count) > cap;
if (!more) break;
if (events.Count == 0) { skip += cap; continue; }
var boundary = events[^1].EventTimeUtc;
var trailing = events.AsEnumerable().Reverse().TakeWhile(e => e.EventTimeUtc == boundary).Count();
skip = boundary == start ? skip + trailing : trailing;
start = boundary;
}
got.ShouldBe(whole, $"cap {cap}");
}
}
/// <summary>A backend holding events per source; a read returns that source's events in the window, forward or reverse,
/// cut to the cap with a continuation point when more remain.</summary>
private sealed class StoredEvents(params (string Source, HistoricalEvent Event)[] stored) : IHistorianDataSource
{
public List<string?> Requests { get; } = [];
/// <summary>A source's own cap for a default-capped read (maxEvents &lt;= 0); uncapped when absent.</summary>
public Dictionary<string, int> DefaultCaps { get; } = [];
public bool SupportsReverseEvents => true;
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken cancellationToken)
{
Requests.Add(sourceName);
var (lo, hi) = reverse ? (endUtc, startUtc) : (startUtc, endUtc);
var rows = stored.Where(s => s.Source == sourceName)
.Select(s => s.Event)
.Where(e => reverse ? e.EventTimeUtc > lo && e.EventTimeUtc <= hi : e.EventTimeUtc >= lo && e.EventTimeUtc < hi)
.OrderBy(e => e.EventTimeUtc) // stable: ties keep storage order, as the gateway's do
.ToList();
if (reverse) rows.Reverse();
var cap = maxEvents > 0 ? maxEvents : DefaultCaps.GetValueOrDefault(sourceName ?? string.Empty, 0);
var more = cap > 0 && rows.Count > cap;
return Task.FromResult(new HistoricalEventsResult(more ? rows.Take(cap).ToList() : rows, more ? [0x01] : null));
}
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) =>
ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, reverse: false, cancellationToken);
public Task<HistorianRead> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<HistorianRead> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<HistorianRead> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken) => throw new NotSupportedException();
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose()
{
}
}
}