425 lines
19 KiB
C#
425 lines
19 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Service-level tests for <see cref="KpiHistoryQueryService"/> (M6 K11). The
|
|
/// service fetches a raw series via <see cref="IKpiHistoryRepository.GetRawSeriesAsync"/>
|
|
/// and reduces it with <see cref="KpiSeriesBucketer"/>; these tests pin the
|
|
/// argument-forwarding contract, the bucketer pass-through, and the
|
|
/// <c>maxPoints ?? DefaultMaxSeriesPoints</c> fallback.
|
|
/// </summary>
|
|
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<KpiHistoryOptions> Options(int defaultMax, int rollupThresholdHours = 168) =>
|
|
Microsoft.Extensions.Options.Options.Create(new KpiHistoryOptions
|
|
{
|
|
DefaultMaxSeriesPoints = defaultMax,
|
|
RollupThresholdHours = rollupThresholdHours,
|
|
});
|
|
|
|
private static IReadOnlyList<KpiSeriesPoint> 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<IKpiHistoryRepository>();
|
|
var raw = Series((0, 1d), (30, 2d));
|
|
repo.GetRawSeriesAsync(
|
|
"notification-outbox", "queue_depth", "site", "plant-a",
|
|
From, To, Arg.Any<CancellationToken>())
|
|
.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<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_ForwardsNullScopeKey_ForGlobalScope()
|
|
{
|
|
var repo = Substitute.For<IKpiHistoryRepository>();
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.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<string?>(k => k == null),
|
|
From, To, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_ReturnsRawUnchanged_WhenCountWithinMax()
|
|
{
|
|
// raw.Count (3) <= max (200) → bucketer returns the same reference.
|
|
var repo = Substitute.For<IKpiHistoryRepository>();
|
|
var raw = Series((0, 5d), (20, 6d), (40, 7d));
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.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<IKpiHistoryRepository>();
|
|
var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d));
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.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<IKpiHistoryRepository>();
|
|
var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d));
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.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<IKpiHistoryRepository>();
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
|
|
|
|
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<IKpiHistoryRepository>();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<IKpiHistoryRepository>(_ =>
|
|
{
|
|
var repo = Substitute.For<IKpiHistoryRepository>();
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
|
|
resolvedRepos.Add(repo);
|
|
return repo;
|
|
});
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
var sut = new KpiHistoryQueryService(
|
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
|
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]);
|
|
}
|
|
|
|
// ---- Task 5: raw-vs-rollup range routing by RollupThresholdHours ----------------
|
|
|
|
private static IKpiHistoryRepository RepoReturningEmptyForBoth()
|
|
{
|
|
var repo = Substitute.For<IKpiHistoryRepository>();
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
|
|
repo.GetHourlySeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
|
|
return repo;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_24hWindow_UsesRawPath()
|
|
{
|
|
var repo = RepoReturningEmptyForBoth();
|
|
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var to = from.AddHours(24);
|
|
|
|
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
|
|
|
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
|
|
|
await repo.Received(1).GetRawSeriesAsync(
|
|
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
|
await repo.DidNotReceive().GetHourlySeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_WindowExactlyAtThreshold_UsesRawPath()
|
|
{
|
|
// Boundary is strictly greater-than: exactly 168 h stays on the raw path.
|
|
var repo = RepoReturningEmptyForBoth();
|
|
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var to = from.AddHours(168);
|
|
|
|
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168));
|
|
|
|
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
|
|
|
await repo.Received(1).GetRawSeriesAsync(
|
|
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
|
await repo.DidNotReceive().GetHourlySeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_WindowJustOverThreshold_UsesRollupPath()
|
|
{
|
|
// 169 h (threshold + 1) crosses the boundary → hourly rollup path.
|
|
var repo = RepoReturningEmptyForBoth();
|
|
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var to = from.AddHours(169);
|
|
|
|
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168));
|
|
|
|
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
|
|
|
await repo.Received(1).GetHourlySeriesAsync(
|
|
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
|
await repo.DidNotReceive().GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_90dWindow_UsesRollupPath()
|
|
{
|
|
var repo = RepoReturningEmptyForBoth();
|
|
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var to = from.AddDays(90);
|
|
|
|
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
|
|
|
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
|
|
|
await repo.Received(1).GetHourlySeriesAsync(
|
|
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
|
await repo.DidNotReceive().GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_RollupPath_StillBucketsWhenOverMax()
|
|
{
|
|
// A rollup series longer than maxPoints must still be downsampled by the
|
|
// bucketer on top of the rollup read — the bucketer path is unchanged.
|
|
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var to = from.AddDays(90);
|
|
|
|
// 6 hourly rollup points, spread across the window in ascending order.
|
|
var rollup = Enumerable.Range(0, 6)
|
|
.Select(i => new KpiSeriesPoint(from.AddHours(i * 300), i))
|
|
.ToList();
|
|
|
|
var repo = Substitute.For<IKpiHistoryRepository>();
|
|
repo.GetHourlySeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(rollup));
|
|
|
|
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 3));
|
|
|
|
var result = await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
|
|
|
// Routed to the rollup path...
|
|
await repo.Received(1).GetHourlySeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
|
// ...and the bucketer still applied (6 rollup rows > max of 3 → downsampled).
|
|
Assert.NotSame(rollup, result);
|
|
Assert.True(result.Count <= 3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_LongWindow_UsesRollupPath_OnProductionCtor()
|
|
{
|
|
// The production (IServiceScopeFactory) ctor must apply the same threshold
|
|
// routing — a long window resolves the repo from a fresh scope and calls the
|
|
// hourly rollup method.
|
|
var repo = RepoReturningEmptyForBoth();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<IKpiHistoryRepository>(_ => repo);
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
var sut = new KpiHistoryQueryService(
|
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
|
Options(defaultMax: 200));
|
|
|
|
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var to = from.AddDays(30);
|
|
|
|
await sut.GetSeriesAsync("src", "metric", "global", null, from, to);
|
|
|
|
await repo.Received(1).GetHourlySeriesAsync(
|
|
"src", "metric", "global", null, from, to, Arg.Any<CancellationToken>());
|
|
await repo.DidNotReceive().GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ---- Task 6: catalog-driven aggregation at the bucketer boundary (R3) -----------
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_RateMetric_SumsPerBucket_OnRollupPath()
|
|
{
|
|
// (SiteCallAudit, FailedLastInterval) is a catalog Rate pair. 400 hourly points of
|
|
// value 1 over a 400 h (>168 h) window, maxPoints 200 → 2 points per 2 h bucket →
|
|
// every output value is 2.0 (the summed pair), not 1.0 (last value).
|
|
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var to = from.AddHours(400);
|
|
var rollup = Enumerable.Range(0, 400)
|
|
.Select(i => new KpiSeriesPoint(from.AddHours(i), 1d))
|
|
.ToList();
|
|
|
|
var repo = Substitute.For<IKpiHistoryRepository>();
|
|
repo.GetHourlySeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(rollup));
|
|
|
|
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
|
|
|
var result = await sut.GetSeriesAsync(
|
|
KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval,
|
|
"global", null, from, to);
|
|
|
|
Assert.Equal(200, result.Count);
|
|
Assert.All(result, p => Assert.Equal(2.0, p.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_RateMetric_PreservesTotal_AcrossRoutingBoundary()
|
|
{
|
|
// Same total event count (50) served as per-minute deltas on the raw path
|
|
// (window == threshold) and as hourly sums on the rollup path (window just over
|
|
// threshold): the summed chart total must be equal on both routes — the boundary
|
|
// is visually seamless for a Rate metric.
|
|
var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
var rawDeltas = Enumerable.Range(0, 10)
|
|
.Select(i => new KpiSeriesPoint(from.AddMinutes(i * 10), 5d)).ToList(); // total 50
|
|
var hourlySums = Enumerable.Range(0, 5)
|
|
.Select(i => new KpiSeriesPoint(from.AddHours(i), 10d)).ToList(); // total 50
|
|
|
|
var repo = Substitute.For<IKpiHistoryRepository>();
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(rawDeltas));
|
|
repo.GetHourlySeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(hourlySums));
|
|
|
|
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168));
|
|
|
|
var rawResult = await sut.GetSeriesAsync(
|
|
KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval,
|
|
"global", null, from, from.AddHours(168)); // raw path
|
|
var rollupResult = await sut.GetSeriesAsync(
|
|
KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval,
|
|
"global", null, from, from.AddHours(169)); // rollup path
|
|
|
|
Assert.Equal(50d, rawResult.Sum(p => p.Value));
|
|
Assert.Equal(50d, rollupResult.Sum(p => p.Value));
|
|
Assert.Equal(rawResult.Sum(p => p.Value), rollupResult.Sum(p => p.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetSeriesAsync_GaugeMetric_KeepsLastValuePerBucket()
|
|
{
|
|
// queueDepth (uncatalogued for this source pairing → Gauge) keeps the existing
|
|
// last-value-per-bucket output unchanged. 4 points, maxPoints 2, raw path.
|
|
var repo = Substitute.For<IKpiHistoryRepository>();
|
|
var raw = Series((0, 1d), (15, 2d), (25, 99d), (35, 5d));
|
|
repo.GetRawSeriesAsync(
|
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult(raw));
|
|
|
|
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2));
|
|
|
|
var result = await sut.GetSeriesAsync(
|
|
KpiSources.NotificationOutbox, KpiMetrics.NotificationOutbox.QueueDepth,
|
|
"global", null, From, To);
|
|
|
|
Assert.Equal(2, result.Count);
|
|
Assert.Equal(99d, result[0].Value); // last value in bucket 0, not a sum
|
|
Assert.Equal(5d, result[1].Value);
|
|
}
|
|
}
|