using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
///
/// Service-level tests for (M6 K11). The
/// service fetches a raw series via
/// and reduces it with ; these tests pin the
/// argument-forwarding contract, the bucketer pass-through, and the
/// maxPoints ?? DefaultMaxSeriesPoints fallback.
///
public class KpiHistoryQueryServiceTests
{
private static readonly DateTime From = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
private static readonly DateTime To = new(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
private static IOptions Options(int defaultMax) =>
Microsoft.Extensions.Options.Options.Create(new KpiHistoryOptions { DefaultMaxSeriesPoints = defaultMax });
private static IReadOnlyList Series(params (int minute, double value)[] pts) =>
pts.Select(p => new KpiSeriesPoint(From.AddMinutes(p.minute), p.value)).ToList();
[Fact]
public async Task GetSeriesAsync_ForwardsAllArgs_ToRepository()
{
var repo = Substitute.For();
var raw = Series((0, 1d), (30, 2d));
repo.GetRawSeriesAsync(
"notification-outbox", "queue_depth", "site", "plant-a",
From, To, Arg.Any())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
await sut.GetSeriesAsync(
"notification-outbox", "queue_depth", "site", "plant-a", From, To);
await repo.Received(1).GetRawSeriesAsync(
"notification-outbox", "queue_depth", "site", "plant-a",
From, To, Arg.Any());
}
[Fact]
public async Task GetSeriesAsync_ForwardsNullScopeKey_ForGlobalScope()
{
var repo = Substitute.For();
repo.GetRawSeriesAsync(
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Task.FromResult(Series()));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
await sut.GetSeriesAsync(
"site-call-audit", "parked_count", "global", scopeKey: null, From, To);
await repo.Received(1).GetRawSeriesAsync(
"site-call-audit", "parked_count", "global",
Arg.Is(k => k == null),
From, To, Arg.Any());
}
[Fact]
public async Task GetSeriesAsync_ReturnsRawUnchanged_WhenCountWithinMax()
{
// raw.Count (3) <= max (200) → bucketer returns the same reference.
var repo = Substitute.For();
var raw = Series((0, 5d), (20, 6d), (40, 7d));
repo.GetRawSeriesAsync(
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
var result = await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
// Bucketer returns the raw reference unchanged when raw.Count <= maxPoints.
Assert.Same(raw, result);
}
[Fact]
public async Task GetSeriesAsync_NullMaxPoints_UsesDefaultFromOptions()
{
// raw has 4 points. With DefaultMaxSeriesPoints=2 the bucketer downsamples
// (4 > 2), so the result is NOT the raw reference and has at most 2 points.
var repo = Substitute.For();
var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d));
repo.GetRawSeriesAsync(
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2));
var result = await sut.GetSeriesAsync(
"src", "metric", "global", null, From, To, maxPoints: null);
Assert.NotSame(raw, result);
Assert.True(result.Count <= 2);
}
[Fact]
public async Task GetSeriesAsync_ExplicitMaxPoints_OverridesDefault()
{
// raw has 4 points; explicit maxPoints=10 (>= raw.Count) wins over the
// option's tiny default of 2, so the bucketer returns the raw reference.
var repo = Substitute.For();
var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d));
repo.GetRawSeriesAsync(
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Task.FromResult(raw));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2));
var result = await sut.GetSeriesAsync(
"src", "metric", "global", null, From, To, maxPoints: 10);
// Explicit max (10) >= raw.Count (4) → raw returned unchanged, proving the
// explicit value beat the default of 2 (which would have downsampled).
Assert.Same(raw, result);
}
[Fact]
public async Task GetSeriesAsync_EmptySeries_ReturnsEmpty()
{
var repo = Substitute.For();
repo.GetRawSeriesAsync(
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Task.FromResult>(Array.Empty()));
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
var result = await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
Assert.Empty(result);
}
[Fact]
public async Task GetSeriesAsync_OpensFreshScopePerCall_OnProductionCtor()
{
// The production (IServiceScopeFactory) ctor must resolve a fresh repository
// per call — same scope-per-query contract AuditLogQueryService upholds, so a
// chart's auto-load never shares the circuit-scoped DbContext.
var resolvedRepos = new List();
var services = new ServiceCollection();
services.AddScoped(_ =>
{
var repo = Substitute.For();
repo.GetRawSeriesAsync(
Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(),
Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Task.FromResult>(Array.Empty()));
resolvedRepos.Add(repo);
return repo;
});
await using var provider = services.BuildServiceProvider();
var sut = new KpiHistoryQueryService(
provider.GetRequiredService(),
Options(defaultMax: 200));
await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
// Each call opened its own scope → two distinct repo instances.
Assert.Equal(2, resolvedRepos.Count);
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
}