da948e5fb5
v2-ci / unit-tests (push) Has been cancelled
HistorianGateway answers a per-source ReadEvents (Source_Object = DevWinPlatform) with the object's own events (SourceName DevWinPlatform) AND its attribute alarms, which it reports as <Object>.<Attribute> (Source_ProcessVariable, e.g. DevWinPlatform.CPULoadAvg). The Gateway backend's defensive client-side source filter compared SourceName for equality, so it dropped the attribute alarms: 70 of the 72 DevWinPlatform events on vd03. The filter (and the past-the-cap "more events" signal) now uses EventSourceMatch: the source itself, or <source>.<anything> on the '.' boundary, ordinal. DevWinPlatform never matches DevWinPlatform2 or DevWinPlatform2.X. This needs no wire change and still makes one request. Each event keeps its own SourceName. SourceNode stays the condition its stored alarm id names, which is null for AVEVA rows. The attribute events ride the notifier's own read, so the per-folder HistoryRead verdict (#826) governs them: a denied folder answers BadUserAccessDenied before any backend call. The SqlServer and QuestDB backends still match exactly; they hold only OtOpcUa's own events. Tests: - GatewayAttributeEventSourceTests (16): the boundary theory, attribute-in/sibling-out through the data source, the drain signal, and an exact attribute-named read. - AttributeEventHistoryWireTests (2, over a real SDK session with the production Gateway backend on an AVEVA-shaped deterministic gateway): object + attribute events with their own SourceName, a prefix sibling excluded, paging, and the ACL-on deny with no backend call plus a granted positive control. - Mutation-checked: exact match fails both wire tests; dropping the '.' boundary fails the wire sibling check and 6 unit tests. - Live AVEVA harness re-run against vd03 HistorianGateway ea9f21c (read-only, no WinRM): folder DevWinPlatform 2 -> 72 events (whole, 72 1-event pages, reverse); the new #882 check passes; 221 of 225 shared cases are identical to the #801 SDK2 baseline, and the 4 that differ are exactly the DevWinPlatform event cases. Recorded in aveva-history-801.md. Follow-up #886: production folders name RawPaths/EquipmentIds, not Galaxy objects, plus the AVEVA severity/condition property mapping. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
118 lines
5.8 KiB
C#
118 lines
5.8 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #882: the gateway answers a per-source read (<c>Source_Object = DevWinPlatform</c>) with the object's own
|
|
/// events, reported as <c>DevWinPlatform</c>, AND its attribute alarms, reported as <c>DevWinPlatform.CPULoadAvg</c>
|
|
/// (<c>Source_ProcessVariable</c>). Both belong to the source; a sibling sharing only the text prefix does not.
|
|
/// </summary>
|
|
public sealed class GatewayAttributeEventSourceTests
|
|
{
|
|
private static readonly DateTime T0 = new(2026, 11, 10, 11, 0, 0, DateTimeKind.Utc);
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
[Theory]
|
|
[InlineData("DevWinPlatform", "DevWinPlatform", true)]
|
|
[InlineData("DevWinPlatform", "DevWinPlatform.CPULoadAvg", true)]
|
|
[InlineData("DevWinPlatform", "DevWinPlatform.CPULoadAvg.HiAlarm", true)]
|
|
[InlineData("DevWinPlatform", "DevWinPlatform2", false)]
|
|
[InlineData("DevWinPlatform", "DevWinPlatform2.CPULoadAvg", false)]
|
|
[InlineData("DevWinPlatform", "DevWinPlatform_CPULoadAvg", false)]
|
|
[InlineData("DevWinPlatform", "DevWinPlatform.", false)]
|
|
[InlineData("DevWinPlatform", "DevWin", false)]
|
|
[InlineData("DevWinPlatform", "devwinplatform.CPULoadAvg", false)]
|
|
[InlineData("DevWinPlatform", "Other.DevWinPlatform", false)]
|
|
[InlineData("DevWinPlatform", null, false)]
|
|
[InlineData("DevWinPlatform.CPULoadAvg", "DevWinPlatform", false)]
|
|
[InlineData(null, "anything", true)]
|
|
public void A_source_matches_itself_and_its_attributes_on_the_dot_boundary(string? requested, string? source, bool matches) =>
|
|
EventSourceMatch.Matches(requested, source).ShouldBe(matches);
|
|
|
|
[Fact]
|
|
public async Task An_object_read_serves_its_attribute_events_and_no_prefix_sibling()
|
|
{
|
|
// The fake streams every row whatever the filter, so the sibling rows really reach the client-side filter.
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
Events =
|
|
[
|
|
Event("E0", "DevWinPlatform", 0, "System.Stop"),
|
|
Event("E1", "DevWinPlatform.CPULoadAvg", 1, "Alarm.Set"),
|
|
Event("E2", "DevWinPlatform2", 2, "System.Stop"),
|
|
Event("E3", "DevWinPlatform2.CPULoadAvg", 3, "Alarm.Set"),
|
|
Event("E4", "DevWinPlatform.CPULoadAvg", 4, "Alarm.Clear"),
|
|
Event("E5", "DevWinPlatformX.CPULoadAvg", 5, "Alarm.Set"),
|
|
Event("E6", "DevWinPlatform", 6, "System.Start"),
|
|
],
|
|
};
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
|
|
var r = await ds.ReadEventsAsync("DevWinPlatform", T0, T0.AddHours(1), maxEvents: 0, Ct);
|
|
|
|
fake.LastReadEventsSourceName.ShouldBe("DevWinPlatform", "one request, filtered on the object");
|
|
fake.ReadEventsCallCount.ShouldBe(1);
|
|
fake.EventsYieldedCount.ShouldBe(7, "the sibling rows reached the filter, so their absence is the filter's doing");
|
|
r.Events.Select(e => (e.EventId, e.SourceName)).ShouldBe(new (string, string?)[]
|
|
{
|
|
("E0", "DevWinPlatform"),
|
|
("E1", "DevWinPlatform.CPULoadAvg"),
|
|
("E4", "DevWinPlatform.CPULoadAvg"),
|
|
("E6", "DevWinPlatform"),
|
|
});
|
|
r.ContinuationPoint.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_attribute_event_past_the_cap_signals_more_and_a_prefix_sibling_does_not()
|
|
{
|
|
// Past a full page only a MATCHING row is the "more events exist" signal: an attribute event is one, a
|
|
// DevWinPlatform2 row is not.
|
|
var withAttribute = new FakeHistorianGatewayClient
|
|
{
|
|
Events = [Event("E0", "DevWinPlatform", 0), Event("E1", "DevWinPlatform.CPULoadAvg", 1)],
|
|
};
|
|
var withSibling = new FakeHistorianGatewayClient
|
|
{
|
|
Events = [Event("E0", "DevWinPlatform", 0), Event("E1", "DevWinPlatform2.CPULoadAvg", 1)],
|
|
};
|
|
|
|
var more = await new GatewayHistorianDataSource(withAttribute, NullLogger<GatewayHistorianDataSource>.Instance)
|
|
.ReadEventsAsync("DevWinPlatform", T0, T0.AddHours(1), maxEvents: 1, Ct);
|
|
var complete = await new GatewayHistorianDataSource(withSibling, NullLogger<GatewayHistorianDataSource>.Instance)
|
|
.ReadEventsAsync("DevWinPlatform", T0, T0.AddHours(1), maxEvents: 1, Ct);
|
|
|
|
more.Events.Select(e => e.EventId).ShouldBe(new[] { "E0" });
|
|
more.ContinuationPoint.ShouldNotBeNull();
|
|
complete.Events.Select(e => e.EventId).ShouldBe(new[] { "E0" });
|
|
complete.ContinuationPoint.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_attribute_named_read_still_matches_exactly()
|
|
{
|
|
// No attribute-level notifier is exposed; a read named after an attribute keeps the exact match (the real
|
|
// gateway returns nothing for it: its Source_Object is the object).
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
Events = [Event("E0", "DevWinPlatform", 0), Event("E1", "DevWinPlatform.CPULoadAvg", 1)],
|
|
};
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
|
|
var r = await ds.ReadEventsAsync("DevWinPlatform.CPULoadAvg", T0, T0.AddHours(1), maxEvents: 0, Ct);
|
|
|
|
r.Events.Select(e => e.EventId).ShouldBe(new[] { "E1" });
|
|
}
|
|
|
|
private static HistorianEvent Event(string id, string source, int second, string type = "Alarm.Set")
|
|
{
|
|
var at = Timestamp.FromDateTime(T0.AddSeconds(second));
|
|
return new HistorianEvent { Id = id, SourceName = source, Type = type, EventTime = at, ReceivedTime = at };
|
|
}
|
|
}
|