using Fleet.Core; using Microsoft.Extensions.Time.Testing; using Monitor.Backend.Analysis; using Monitor.Backend.Application; using Monitor.Protocol; namespace Monitor.Backend.Tests; /// 时间段、步长与数据源的规划:与 docs/query.md 里的表格逐行对应。 public sealed class QueryPlanningTests { private const long Second = 1000; private const long Minute = 60 * Second; private const long Hour = 60 * Minute; private const long Day = 24 * Hour; private static readonly long s_now = new DateTimeOffset(2026, 10, 3, 12, 34, 56, TimeSpan.Zero).ToUnixTimeMilliseconds(); private readonly BackendOptions _options = new(); private RangeResolver Resolver => new(_options); private SourcePlanner Planner => new(_options); private QueryRange Resolve(string range, string? step = null) { Result result = Resolver.Resolve(new RangeRequest(range, null, null, step, null), s_now); Assert.True(result.IsSuccess, result.IsSuccess ? "" : result.Error.Message); return result.Value; } [Theory] [InlineData("5m", "1s", 300)] [InlineData("15m", "2s", 450)] [InlineData("30m", "5s", 360)] [InlineData("1h", "5s", 720)] [InlineData("6h", "30s", 720)] [InlineData("24h", "2m", 720)] [InlineData("7d", "15m", 672)] [InlineData("30d", "1h", 720)] [InlineData("90d", "3h", 720)] public void AutoStep_MatchesTheDocumentedTable(string range, string step, int points) { QueryRange resolved = Resolve(range); Assert.Equal(step, Steps.Format(resolved.StepMs)); Assert.InRange(resolved.Points - points, 0, 1); // 对齐到步长的整数倍,可能多出一个不完整的桶 } [Theory] [InlineData("5m", "hot")] [InlineData("15m", "hot")] [InlineData("30m", "hot")] [InlineData("1h", "hot")] [InlineData("6h", "10s")] [InlineData("24h", "1m")] [InlineData("7d", "5m")] [InlineData("30d", "1h")] [InlineData("90d", "1h")] public void SourceSelection_MatchesTheDocumentedTable(string range, string source) { QueryRange resolved = Resolve(range); SourcePlan plan = Planner.Plan(resolved, s_now, hotReady: true); Assert.Equal(source, plan.Name); Assert.False(plan.Degraded); Assert.Equal(resolved.StepMs, plan.StepMs); } [Fact] public void WhenTheHotLayerIsNotReady_FineStepsOverRecentDataComeFromRawChunks() { SourcePlan plan = Planner.Plan(Resolve("5m"), s_now, hotReady: false); Assert.Equal("raw", plan.Name); // 区间超过 15 分钟就不现算,降级到 10 s 层 SourcePlan degraded = Planner.Plan(Resolve("1h", step: "5s"), s_now, hotReady: false); Assert.Equal("10s", degraded.Name); Assert.True(degraded.Degraded); Assert.Equal(10 * Second, degraded.StepMs); } [Fact] public void StepsThatNoLayerCanServe_AreDegradedAndSnappedToACommonStep() { // 15 s 不能整除 10 s 层的宽度,而且已经超出热层 QueryRange old = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Hour)), DateTimeOffset.FromUnixTimeMilliseconds(s_now - (2 * Hour)), "15s", null), s_now).Value; SourcePlan plan = Planner.Plan(old, s_now, hotReady: true); Assert.Equal(("10s", true, "30s"), (plan.Name, plan.Degraded, Steps.Format(plan.StepMs))); Assert.Equal(30 * Second, old.WithStep(plan.StepMs).StepMs); Assert.Equal(0, old.WithStep(plan.StepMs).FromMs % plan.StepMs); // 超出 10 s 层的保留期(48 小时),1 分钟步长改用 1 分钟层(不降级:1m 层能整除) QueryRange week = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Day)), DateTimeOffset.FromUnixTimeMilliseconds(s_now - (3 * Day) + Hour), "10s", null), s_now).Value; SourcePlan expired = Planner.Plan(week, s_now, hotReady: true); Assert.True(expired.Degraded, "the 10 s layer only keeps 48 hours"); Assert.Equal("1m", expired.Name); Assert.Equal(Minute, expired.StepMs); } [Fact] public void RangeResolver_AlignsBucketsAndMarksPartialEdges() { long from = s_now - (s_now % Minute) + (13 * Second); long to = from + (4 * Minute) + (7 * Second); QueryRange range = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(from), DateTimeOffset.FromUnixTimeMilliseconds(to), "1m", null), s_now).Value; Assert.Equal(0, range.FromMs % Minute); Assert.Equal(0, range.ToMs % Minute); Assert.Equal(5, range.Points); Assert.True(range.FirstPartial); Assert.True(range.LastPartial); Assert.False(range.IsRelative); QueryRange aligned = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(range.FromMs), DateTimeOffset.FromUnixTimeMilliseconds(range.ToMs), "1m", null), s_now).Value; Assert.False(aligned.FirstPartial); Assert.False(aligned.LastPartial); } [Theory] [InlineData("0m")] [InlineData("-5m")] [InlineData("5")] [InlineData("5x")] [InlineData("m")] [InlineData("99999999d")] [InlineData("1000d")] public void RangeResolver_RejectsMalformedAndAbsurdRanges(string range) { Result result = Resolver.Resolve(new RangeRequest(range, null, null, null, null), s_now); Assert.False(result.IsSuccess); Assert.Contains(result.Error.Code, new[] { MonitorErrors.InvalidRange, MonitorErrors.RangeTooLarge }); } [Fact] public void RangeResolver_EnforcesThePointLimit_AndSuggestsAStep() { Result result = Resolver.Resolve(new RangeRequest("30d", null, null, "1m", null), s_now); Assert.Equal(MonitorErrors.RangeTooLarge, result.Error.Code); Assert.Contains("step=", result.Error.Message); Assert.Equal(MonitorErrors.InvalidStep, Resolver.Resolve(new RangeRequest("1h", null, null, "7s", null), s_now).Error.Code); Assert.Equal(MonitorErrors.InvalidRange, Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now), DateTimeOffset.FromUnixTimeMilliseconds(s_now), null, null), s_now).Error.Code); Assert.Equal(MonitorErrors.ValidationFailed, Resolver.Resolve(new RangeRequest("1h", DateTimeOffset.FromUnixTimeMilliseconds(s_now - Hour), null, null, null), s_now).Error.Code); } [Fact] public void OnlyUtcIsSupported_OtherTimeZonesAreRejectedInsteadOfSilentlyIgnored() { Assert.True(Resolver.Resolve(new RangeRequest("1h", null, null, null, "UTC"), s_now).IsSuccess); Assert.True(Resolver.Resolve(new RangeRequest("1h", null, null, null, null), s_now).IsSuccess); Result shanghai = Resolver.Resolve(new RangeRequest("7d", null, null, "1d", "Asia/Shanghai"), s_now); Assert.Equal(MonitorErrors.ValidationFailed, shanghai.Error.Code); Assert.IsType(shanghai.Error); } [Fact] public void RelativeRanges_AreLive_AbsoluteOnesAreNot() { Assert.True(Resolve("15m").IsRelative); QueryRange fromOnly = Resolver.Resolve(new RangeRequest(null, DateTimeOffset.FromUnixTimeMilliseconds(s_now - Hour), null, null, null), s_now).Value; Assert.True(fromOnly.IsRelative); // 没有 to:to = now,可以跟随实时数据 } [Fact] public void Steps_ParseFormatAndSnap() { foreach (long step in Steps.Common) { Assert.True(Steps.TryParse(Steps.Format(step), out long parsed)); Assert.Equal(step, parsed); } Assert.False(Steps.TryParse("3s", out _)); Assert.Equal(30 * Second, Steps.Snap(15 * Second, 10 * Second)); Assert.Equal(5 * Minute, Steps.Snap(Minute, 5 * Minute)); Assert.Equal(-60_000, Steps.FloorTo(-1, Minute)); Assert.Equal(0, Steps.CeilTo(0, Minute)); Assert.Equal(Minute, Steps.CeilTo(1, Minute)); } [Fact] public void BucketedRows_MergeExactly_AndDoNotMutateTheirSources() { QueryRange range = Resolve("5m", "1m"); var first = new MetricSet(); first.GetOrAdd("c", AccKind.Counter).Add(3); ((HistogramAcc)first.GetOrAdd("h", AccKind.Histogram)).Add(10); var second = new MetricSet(); second.GetOrAdd("c", AccKind.Counter).Add(4); ((HistogramAcc)second.GetOrAdd("h", AccKind.Histogram)).Add(20); BucketedRows rows = BucketedRows.Create( [new SourceRow(range.FromMs, first, false), new SourceRow(range.FromMs + (10 * Second), second, false), new SourceRow(range.FromMs - Minute, first, false), new SourceRow(range.ToMs, first, false)], range); Assert.Equal(range.Points, rows.T.Length); Assert.Equal(2, rows.RowCounts[0]); Assert.Equal(7UL, ((CounterAcc)rows.Sets[0]!.Items["c"]).Value); Assert.Equal(2UL, ((HistogramAcc)rows.Sets[0]!.Items["h"]).Count); Assert.Equal(3UL, ((CounterAcc)first.Items["c"]).Value); // 源行没有被改动 Assert.Equal(2, rows.TotalRows); // 区间之外的行被忽略 Assert.Null(rows.Sets[1]); } [Fact] public void MetricSelection_ResolvesPresetsAndScores_AndRejectsUnknownIds() { MetricSelection selection = MetricSelection.Resolve("rtt.p95,score.mc", "udp,scores", 100).Value; Assert.Contains("loss.total", selection.ExportIds); Assert.Contains("mc", selection.ScoreModelIds); Assert.Equal(4, selection.ScoreModelIds.Count); Assert.Equal(selection.ExportIds.Count, selection.ExportIds.Distinct().Count()); Assert.True(selection.WantsScores); Assert.Equal(MonitorErrors.UnknownMetric, MetricSelection.Resolve("nope", null, 100).Error.Code); Assert.Equal(MonitorErrors.UnknownMetric, MetricSelection.Resolve("score.nope", null, 100).Error.Code); Assert.Equal(MonitorErrors.UnknownPreset, MetricSelection.Resolve(null, "nope", 100).Error.Code); Assert.Equal(MonitorErrors.TooManyMetrics, MetricSelection.Resolve(null, "udp", 3).Error.Code); } [Fact] public void LoginRateLimiter_AllowsABurst_ThenBlocksUntilTheWindowSlides() { var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch); var limiter = new LoginRateLimiter(new BackendOptions { LoginAttemptsPerMinute = 3 }, time); for (int i = 0; i < 3; i++) { Assert.True(limiter.TryAcquire("1.2.3.4", out _)); time.Advance(TimeSpan.FromSeconds(10)); } Assert.False(limiter.TryAcquire("1.2.3.4", out int retry)); // 已过 30 秒,最早的一次还要 30 秒才滑出窗口 Assert.InRange(retry, 29, 31); Assert.True(limiter.TryAcquire("5.6.7.8", out _), "another source is independent"); time.Advance(TimeSpan.FromSeconds(31)); Assert.True(limiter.TryAcquire("1.2.3.4", out _)); limiter.Prune(); time.Advance(TimeSpan.FromMinutes(2)); limiter.Prune(); Assert.True(limiter.TryAcquire("1.2.3.4", out _)); } }