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 };
}
}