using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using Fleet.Core;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
[Collection("backend")]
public sealed class IngestAndQueryTests
{
private const long Minute = 60_000;
private readonly BackendFixture _backend;
public IngestAndQueryTests(BackendFixture backend)
{
_backend = backend;
}
/// 当前分钟的起点:测试数据放在已经结束的分钟里,这样持久路径会立即计算这些窗口。
private static long ClosedMinuteStart(int minutesAgo) =>
((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / Minute) - minutesAgo) * Minute;
/// 把 [baseMs, baseMs + chunks × 10 s) 的数据送给后端:每个块 10 s、100 个包;每 个包丢一个回复。
private static async Task SendLineAsync(AgentSimulator agent, long baseMs, int chunks, int lossEvery = 0, bool tcp = true, double owdMs = 20)
{
for (int k = 0; k < chunks; k++)
{
long start = baseMs + (k * 10_000L);
RawChunk udp = ChunkFactory.Echo(
agent.NodeId, "udp_echo", start, 100, 100,
i => new EchoPacket(false, lossEvery > 0 && i % lossEvery == 0, (long)(owdMs * 1000), (long)(owdMs * 1000)));
Assert.True(await agent.SendChunkAsync(udp));
if (tcp)
{
long probeBase = ChunkFactory.ProbeUs(0);
RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (probeBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400))));
RawChunk tcpChunk = ChunkFactory.Echo(
agent.NodeId, "tcp_echo", start, 200, 50, _ => new EchoPacket(false, false, (long)(owdMs * 1000), (long)(owdMs * 1000)), extraStreams: [info]);
Assert.True(await agent.SendChunkAsync(tcpChunk));
}
}
}
private async Task<(AgentSimulator Agent, string Name)> LineAsync(long baseMs, int chunks, int lossEvery = 0, bool tcp = true, double owdMs = 20)
{
AgentSimulator agent = await _backend.ConnectAgentAsync();
await SendLineAsync(agent, baseMs, chunks, lossEvery, tcp, owdMs);
string name = (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["name"]!.GetValue();
return (agent, name);
}
private static string Iso(long ms) => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
private async Task SeriesAsync(string agentId, string query) =>
await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agentId)}/series?{query}");
[Fact]
public async Task Line_ReportsWhenItsDataStarted_SoTheDisplayOnlyOffersRangesThatHaveData()
{
await _backend.SiteMustBePublicAsync();
long baseMs = ClosedMinuteStart(30);
(AgentSimulator agent, _) = await LineAsync(baseMs, 6);
await using (agent)
{
JsonNode before = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}");
DateTimeOffset since = before["data_since"]!.GetValue();
// 精确到秒(细的层);如果细的层已经被保留策略清掉,只剩 5 分钟层,起点最多早 5 分钟
Assert.InRange(since.ToUnixTimeMilliseconds(), baseMs - 300_000, baseMs + 70_000);
// 一条没有任何数据的线路:没有 data_since
await using AgentSimulator empty = await _backend.ConnectAgentAsync();
JsonNode none = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(empty.NodeId.Value)}");
Assert.Null(none["data_since"]);
}
}
[Fact]
public async Task Series_AnchorLatest_ShowsTheLastWindowOfALineThatHasStoppedReporting()
{
long baseMs = ClosedMinuteStart(20);
(AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 6, tcp: false); // one minute of data, 20 minutes ago
await using (agent)
{
string id = agent.NodeId.Value;
// Anchored at now: the last 5 minutes are empty
JsonNode atNow = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total");
Assert.Null(atNow["summary"]!["samples"]!["udp_echo"]);
// Anchored at the latest sample: the window ends where the data ends and carries all of it
JsonNode anchored = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total&anchor=latest");
long to = DateTimeOffset.Parse(anchored["to"]!.GetValue(), System.Globalization.CultureInfo.InvariantCulture).ToUnixTimeMilliseconds();
Assert.InRange(to, baseMs + (50 * 1000), baseMs + (80 * 1000));
Assert.Equal(600, anchored["summary"]!["samples"]!["udp_echo"]!.GetValue());
Assert.False(anchored["live"]!.GetValue());
// anchor=now is the default behaviour; anything else is rejected
JsonNode explicitNow = await SeriesAsync(id, "range=5m&step=10s&metrics=loss.total&anchor=now");
Assert.Null(explicitNow["summary"]!["samples"]!["udp_echo"]);
using HttpResponseMessage bad = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(id)}/series?range=5m&anchor=yesterday");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, bad.StatusCode);
}
}
[Fact]
public async Task Chunks_AreStoredAndAcknowledged_DuplicatesAndGarbageAreDiscardedWithAReason()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
long start = ClosedMinuteStart(3);
RawChunk chunk = ChunkFactory.UdpEchoClean(agent.NodeId, start);
Assert.True(await agent.SendChunkAsync(chunk), "the first delivery is stored");
Assert.False(await agent.SendChunkAsync(chunk), "the second delivery is acknowledged as a duplicate");
// 损坏的块:确认掉(否则节点会无限重发)并记入事件
var garbage = new ChunkId(Guid.CreateVersion7());
var ack = new TaskCompletionSource();
byte[] corrupt = RawChunkCodec.Encode(ChunkFactory.UdpEchoClean(agent.NodeId, start));
corrupt[^1] ^= 0xFF;
await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MoShiroChannels.RawChunk, garbage.Value, corrupt));
// 冒名的块:agent_id 与认证的节点不一致
RawChunk spoofed = ChunkFactory.UdpEchoClean(new NodeId("agt_someone_else"), start);
await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MoShiroChannels.RawChunk, spoofed.Header.ChunkId.Value, RawChunkCodec.Encode(spoofed)));
await BackendFixture.EventuallyAsync(async () =>
{
JsonNode events = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.NodeId.Value}&kind=chunk_rejected");
return events["items"]!.AsArray().Count >= 2;
});
JsonNode rejected = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/events?agent_id={agent.NodeId.Value}&kind=chunk_rejected");
string details = string.Join('|', rejected["items"]!.AsArray().Select(e => e!["detail"]!.GetValue()));
Assert.Contains("undecodable chunk", details);
Assert.Contains("does not match the authenticated node", details);
JsonNode stored = await _backend.GetJsonAsync(_backend.Admin, $"/api/admin/raw/chunks?agent_id={agent.NodeId.Value}");
Assert.Single(stored["items"]!.AsArray());
}
[Fact]
public async Task Series_HotAndPersistedLayersGiveTheSameExactSummary()
{
long baseMs = ClosedMinuteStart(4);
(AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 18, lossEvery: 10);
await using (agent)
{
long to = baseMs + (3 * Minute);
string range = $"from={Iso(baseMs)}&to={Iso(to)}&preset=udp,tcp";
JsonNode hot = await SeriesAsync(agent.NodeId.Value, range + "&step=1s");
Assert.Equal("hot", hot["source"]!.GetValue());
Assert.Equal(180, hot["t"]!.AsArray().Count);
JsonNode minute = null!;
await BackendFixture.EventuallyAsync(async () =>
{
minute = await SeriesAsync(agent.NodeId.Value, range + "&step=1m");
// 窗口会随着后续的块到达而重算:等到 18 个块的样本都计入之后再比较
return minute["source"]!.GetValue() == "1m" && minute["summary"]!["samples"]!["udp_echo"]?.GetValue() == 1800
&& minute["summary"]!["samples"]!["tcp_echo"]?.GetValue() == 3600;
});
JsonNode hotSummary = hot["summary"]!["metrics"]!;
JsonNode minuteSummary = minute["summary"]!["metrics"]!;
Assert.Equal(10.0, hotSummary["loss.total"]!.GetValue(), 6);
foreach (string id in new[] { "loss.total", "loss.up", "loss.down", "rtt.p50", "rtt.p95", "rtt.p99", "tcp.rtt.p95", "tcp.retrans", "jitter.down.p95" })
{
Assert.True(
hotSummary[id] is null == minuteSummary[id] is null && (hotSummary[id] is null || Math.Abs(hotSummary[id]!.GetValue() - minuteSummary[id]!.GetValue()) < 1e-9),
$"{id}: hot={hotSummary[id]} persisted={minuteSummary[id]}");
}
Assert.Equal(1800, hot["summary"]!["samples"]!["udp_echo"]!.GetValue());
Assert.Equal(hot["summary"]!["samples"]!["udp_echo"]!.GetValue(), minute["summary"]!["samples"]!["udp_echo"]!.GetValue());
Assert.Equal(3600, minute["summary"]!["samples"]!["tcp_echo"]!.GetValue());
// RTT = 2 × 20 ms,直方图的相对误差 ≤ 4.5%
Assert.InRange(minuteSummary["rtt.p95"]!.GetValue(), 40 * 0.955, 40 * 1.045);
Assert.Equal(3, minute["t"]!.AsArray().Count);
Assert.Equal(1.0, minute["summary"]!["coverage"]!.GetValue(), 3);
// 汇总是精确合并,不是各点的平均:每个分钟桶的丢包率都是 10%,整体也是 10%
Assert.All(minute["values"]!["loss.total"]!.AsArray(), v => Assert.Equal(10.0, v!.GetValue(), 6));
}
}
[Fact]
public async Task Series_TenSecondLayer_AndTheFivePointFiveSecondStepsDegradeGracefully()
{
long baseMs = ClosedMinuteStart(3);
(AgentSimulator agent, _) = await LineAsync(baseMs, chunks: 6);
await using (agent)
{
string range = $"from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&preset=udp";
JsonNode tenSeconds = await SeriesAsync(agent.NodeId.Value, range + "&step=10s");
Assert.Equal("10s", tenSeconds["source"]!.GetValue());
Assert.Equal(6, tenSeconds["t"]!.AsArray().Count);
Assert.All(tenSeconds["values"]!["loss.total"]!.AsArray(), v => Assert.Equal(0.0, v!.GetValue()));
// 15 s 不能整除任何层的宽度(热层以外):对更早的数据降级到可用的最细层,并如实标记
long farBase = ClosedMinuteStart(120);
JsonNode degraded = await SeriesAsync(agent.NodeId.Value, $"from={Iso(farBase)}&to={Iso(farBase + (10 * Minute))}&step=15s&preset=udp");
Assert.True(degraded["degraded"]!.GetValue());
Assert.Equal("30s", degraded["step"]!.GetValue());
}
}
[Fact]
public async Task Overview_RanksLinesByScore_AndTheLossyLineScoresLower()
{
await _backend.SiteMustBePublicAsync();
long baseMs = ClosedMinuteStart(3);
(AgentSimulator good, string goodName) = await LineAsync(baseMs, 12);
(AgentSimulator bad, string badName) = await LineAsync(baseMs, 12, lossEvery: 5, owdMs: 60);
await using (good)
await using (bad)
{
string range = $"from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}&preset=udp,scores";
JsonNode overview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?" + range);
JsonNode[] lines = [.. overview["lines"]!.AsArray().Select(l => l!)];
int goodIndex = Array.FindIndex(lines, l => l["name"]!.GetValue() == goodName);
int badIndex = Array.FindIndex(lines, l => l["name"]!.GetValue() == badName);
Assert.True(goodIndex >= 0 && badIndex >= 0);
Assert.True(goodIndex < badIndex, "the clean line must rank above the lossy one (default sort: first requested score, descending)");
double goodScore = lines[goodIndex]["summary"]!["metrics"]!["score.base"]!.GetValue();
double badScore = lines[badIndex]["summary"]!["metrics"]!["score.base"]!.GetValue();
Assert.True(goodScore > 90, $"clean line scored {goodScore}");
Assert.True(badScore < goodScore - 30, $"lossy line scored {badScore}");
Assert.Equal(20.0, lines[badIndex]["summary"]!["metrics"]!["loss.total"]!.GetValue(), 6);
Assert.Equal(1200, lines[goodIndex]["summary"]!["samples"]!["udp_echo"]!.GetValue());
// 按指标排序:loss.total 升序时好线路在前
JsonNode byLoss = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?" + range + "&sort=loss.total&order=asc");
string[] names = [.. byLoss["lines"]!.AsArray().Select(l => l!["name"]!.GetValue())];
Assert.True(Array.IndexOf(names, goodName) < Array.IndexOf(names, badName));
}
}
[Fact]
public async Task Compare_Distribution_AndHeatmap_AreConsistentWithTheSeries()
{
long baseMs = ClosedMinuteStart(3);
(AgentSimulator a, _) = await LineAsync(baseMs, 12, owdMs: 20);
(AgentSimulator b, _) = await LineAsync(baseMs, 12, owdMs: 40);
await using (a)
await using (b)
{
string window = $"from={Iso(baseMs)}&to={Iso(baseMs + (2 * Minute))}";
JsonNode compare = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/compare?lines={_backend.Line(a.NodeId.Value)},{_backend.Line(b.NodeId.Value)}&metric=rtt.p50&{window}&step=10s");
Assert.Equal(12, compare["t"]!.AsArray().Count);
double rttA = compare["summary"]![_backend.Line(a.NodeId.Value)]!.GetValue();
double rttB = compare["summary"]![_backend.Line(b.NodeId.Value)]!.GetValue();
Assert.InRange(rttA, 40 * 0.955, 40 * 1.045);
Assert.InRange(rttB, 80 * 0.955, 80 * 1.045);
JsonNode distribution = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(b.NodeId.Value)}/distribution?metric=rtt&{window}");
Assert.Equal(1200, distribution["count"]!.GetValue());
Assert.Equal(distribution["count"]!.GetValue(), distribution["buckets"]!.AsArray().Sum(x => x!["count"]!.GetValue()));
Assert.InRange(distribution["p50"]!.GetValue(), 80 * 0.955, 80 * 1.045);
// step=1m 读的是持久层,由后台分析异步算出:等它完整
JsonNode heatmap = null!;
await BackendFixture.EventuallyAsync(async () =>
{
heatmap = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/lines/{_backend.Line(b.NodeId.Value)}/heatmap?metric=rtt&{window}&step=1m");
return heatmap["counts"]!.AsArray().Sum(r => r!.AsArray().Sum(c => c!.GetValue())) == 1200;
});
Assert.Equal(2, heatmap["t"]!.AsArray().Count);
Assert.Equal(2, heatmap["counts"]!.AsArray().Count);
Assert.All(heatmap["counts"]!.AsArray(), row => Assert.Equal(heatmap["edges"]!.AsArray().Count, row!.AsArray().Count));
Assert.Equal(1200, heatmap["counts"]!.AsArray().Sum(r => r!.AsArray().Sum(c => c!.GetValue())));
using HttpResponseMessage notHistogram = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(b.NodeId.Value)}/distribution?metric=loss.up&{window}");
Assert.Equal(HttpStatusCode.UnprocessableEntity, notHistogram.StatusCode);
}
}
[Fact]
public async Task Queries_ValidateTheirParameters_WithActionableErrors()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string id = agent.NodeId.Value;
async Task<(HttpStatusCode Status, string Code, string Detail)> Fail(string url)
{
using HttpResponseMessage response = await _backend.Admin.GetAsync(url);
JsonNode problem = JsonNode.Parse(await response.Content.ReadAsStringAsync())!;
return (response.StatusCode, problem["code"]!.GetValue(), problem["detail"]?.GetValue() ?? "");
}
Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_range"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=abc")));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_range"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?from=2026-01-02T00:00:00Z&to=2026-01-01T00:00:00Z")));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "invalid_step"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&step=7s")));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_metric"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&metrics=bogus.metric")));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "unknown_preset"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&preset=zzz")));
Assert.Equal((HttpStatusCode.BadRequest, "validation_failed"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=1h&from=2026-01-01T00:00:00Z")));
Assert.Equal((HttpStatusCode.NotFound, "agent_not_found"), Trim(await Fail("/api/public/lines/agt_nope/series?range=1h")));
(HttpStatusCode, string, string Detail) tooLarge = await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=30d&step=1m");
Assert.Equal(HttpStatusCode.UnprocessableEntity, tooLarge.Item1);
Assert.Equal("range_too_large", tooLarge.Item2);
Assert.Contains("step=", tooLarge.Detail); // 给出可行的替代方案,而不是悄悄截断
// 重复的指标 id 只算一个;真正超过 32 个不同的指标才报错
string repeated = string.Join(',', Enumerable.Range(0, 40).Select(_ => "rtt.p95"));
using HttpResponseMessage deduplicated = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(id)}/series?range=5m&metrics={repeated}");
Assert.Equal(HttpStatusCode.OK, deduplicated.StatusCode);
JsonNode catalog = await _backend.GetJsonAsync(_backend.Admin, "/api/public/catalog/metrics");
string distinct = string.Join(',', catalog["items"]!.AsArray().Select(m => m!["id"]!.GetValue()).Take(40));
Assert.Equal((HttpStatusCode.UnprocessableEntity, "too_many_metrics"), Trim(await Fail($"/api/public/lines/{_backend.Line(id)}/series?range=5m&metrics={distinct}")));
static (HttpStatusCode, string) Trim((HttpStatusCode Status, string Code, string Detail) f) => (f.Status, f.Code);
}
[Fact]
public async Task ClosedRanges_AreImmutableAndConditional_RelativeRangesAreNeverCached()
{
long baseMs = ClosedMinuteStart(10);
(AgentSimulator agent, _) = await LineAsync(baseMs, 6, tcp: false);
await using (agent)
{
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&step=10s&preset=udp";
using HttpResponseMessage closed = await _backend.Admin.GetAsync(url);
Assert.Equal("public, max-age=31536000, immutable", closed.Headers.CacheControl!.ToString());
string etag = closed.Headers.ETag!.Tag;
using var conditional = new HttpRequestMessage(HttpMethod.Get, url);
conditional.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(etag));
using HttpResponseMessage notModified = await _backend.Admin.SendAsync(conditional);
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
using HttpResponseMessage relative = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=5m&preset=udp");
Assert.Equal("no-store", relative.Headers.CacheControl!.ToString());
Assert.Null(relative.Headers.ETag);
}
}
[Fact]
public async Task HiddenLines_AreInvisibleToAnonymousVisitors_ButNotToSignedInOnes()
{
await _backend.SiteMustBePublicAsync();
long baseMs = ClosedMinuteStart(3);
(AgentSimulator agent, string name) = await LineAsync(baseMs, 3, tcp: false);
await using (agent)
{
using HttpResponseMessage hide = await _backend.Admin.PatchAsync(
"/api/admin/agents/" + agent.NodeId.Value, BackendFixture.Json(new JsonObject { ["public_visible"] = false }, "application/merge-patch+json"));
Assert.Equal(HttpStatusCode.OK, hide.StatusCode);
JsonNode anonymousOverview = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/overview?range=15m");
Assert.DoesNotContain(anonymousOverview["lines"]!.AsArray(), l => l!["name"]!.GetValue() == name);
using HttpResponseMessage anonymousSeries = await _backend.Anonymous.GetAsync($"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?range=5m");
Assert.Equal(HttpStatusCode.NotFound, anonymousSeries.StatusCode); // 404,不是 403:不暴露线路是否存在
JsonNode adminOverview = await _backend.GetJsonAsync(_backend.Admin, "/api/public/overview?range=15m");
Assert.Contains(adminOverview["lines"]!.AsArray(), l => l!["name"]!.GetValue() == name);
}
}
[Fact]
public async Task LateChunks_UpdateAlreadyComputedWindows()
{
long baseMs = ClosedMinuteStart(5);
(AgentSimulator agent, _) = await LineAsync(baseMs, 6, tcp: false);
await using (agent)
{
string url = $"/api/public/lines/{_backend.Line(agent.NodeId.Value)}/series?from={Iso(baseMs)}&to={Iso(baseMs + Minute)}&step=1m&preset=udp";
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["udp_echo"]?.GetValue() == 600);
// Agent 重连后补传一个迟到的块(落在同一个分钟窗口里)
RawChunk late = ChunkFactory.Echo(agent.NodeId, "udp_echo", baseMs + 5_000, 100, 10, _ => new EchoPacket(false, false, 20_000, 20_000));
Assert.True(await agent.SendChunkAsync(late));
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, url))["summary"]!["samples"]!["udp_echo"]?.GetValue() == 700, because: "the late chunk must re-enter the persistent path");
}
}
[Fact]
public async Task RolledUpLayers_AgreeWithTheMinuteLayer()
{
long baseMs = ClosedMinuteStart(7) / (5 * Minute) * (5 * Minute); // 对齐到 5 分钟,整段都在已经结束的时间里
(AgentSimulator agent, _) = await LineAsync(baseMs, 30, lossEvery: 20, tcp: false);
await using (agent)
{
long to = baseMs + (5 * Minute);
string range = $"from={Iso(baseMs)}&to={Iso(to)}&preset=udp";
JsonNode oneMinute = null!;
await BackendFixture.EventuallyAsync(async () =>
{
oneMinute = await SeriesAsync(agent.NodeId.Value, range + "&step=1m");
return oneMinute["summary"]!["samples"]!["udp_echo"]?.GetValue() == 3000;
});
// 5 分钟层由降采样把 5 个 1 分钟窗口精确合并而来
JsonNode fiveMinutes = null!;
await BackendFixture.EventuallyAsync(async () =>
{
fiveMinutes = await SeriesAsync(agent.NodeId.Value, range + "&step=5m");
return fiveMinutes["source"]!.GetValue() == "5m" && fiveMinutes["summary"]!["samples"]!["udp_echo"]?.GetValue() == 3000;
});
Assert.Equal(oneMinute["summary"]!["metrics"]!["loss.total"]!.GetValue(), fiveMinutes["summary"]!["metrics"]!["loss.total"]!.GetValue(), 9);
Assert.Equal(oneMinute["summary"]!["metrics"]!["rtt.p99"]!.GetValue(), fiveMinutes["summary"]!["metrics"]!["rtt.p99"]!.GetValue(), 9);
}
}
}