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; /// /// Gitea #882: the gateway answers a per-source read (Source_Object = DevWinPlatform) with the object's own /// events, reported as DevWinPlatform, AND its attribute alarms, reported as DevWinPlatform.CPULoadAvg /// (Source_ProcessVariable). Both belong to the source; a sibling sharing only the text prefix does not. /// 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.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.Instance) .ReadEventsAsync("DevWinPlatform", T0, T0.AddHours(1), maxEvents: 1, Ct); var complete = await new GatewayHistorianDataSource(withSibling, NullLogger.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.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 }; } }