Files
MoShiro/backend/tests/MoShiro.Backend.Tests/IngestAndQueryTests.cs
T

412 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
public sealed class IngestAndQueryTests : IClassFixture<BackendFixture>
{
private const long Minute = 60_000;
private readonly BackendFixture _backend;
public IngestAndQueryTests(BackendFixture backend)
{
_backend = backend;
}
/// <summary>当前分钟的起点:测试数据放在已经结束的分钟里,这样持久路径会立即计算这些窗口。</summary>
private static long ClosedMinuteStart(int minutesAgo) =>
((TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() / Minute) - minutesAgo) * Minute;
/// <summary>把 [baseMs, baseMs + chunks × 10 s) 的数据送给后端:每个块 10 s、100 个包;每 <paramref name="lossEvery"/> 个包丢一个回复。</summary>
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, "quic_ping", 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 dialerBase = ChunkFactory.DialerUs(0);
RawStream info = ChunkFactory.TcpInfo(Enumerable.Range(0, 10).Select(s => (dialerBase + (s * 1_000_000L), (byte)0, 45_000u, (uint)(s * 2), (uint)(s * 400))));
RawChunk tcpChunk = ChunkFactory.Echo(
agent.NodeId, "tcp_ping", 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<string>();
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<JsonNode> 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<DateTimeOffset>();
// 精确到秒(细的层);如果细的层已经被保留策略清掉,只剩 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"]!["quic_ping"]);
// 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<string>(), System.Globalization.CultureInfo.InvariantCulture).ToUnixTimeMilliseconds();
Assert.InRange(to, baseMs + (50 * 1000), baseMs + (80 * 1000));
Assert.Equal(600, anchored["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
Assert.False(anchored["live"]!.GetValue<bool>());
// 这个窗口是以“最后一个样本”为终点的绝对区间,但线路恢复之后同一个地址的内容就变了:不能被浏览器永久缓存
using HttpResponseMessage raw = await _backend.Admin.GetAsync($"/api/public/lines/{_backend.Line(id)}/series?range=5m&step=10s&metrics=loss.total&anchor=latest");
Assert.Equal("no-store", raw.Headers.CacheControl!.ToString());
// 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"]!["quic_ping"]);
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<bool>();
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<string>()));
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<string>());
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<string>() == "1m" && minute["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 1800
&& minute["summary"]!["samples"]!["tcp_ping"]?.GetValue<long>() == 3600;
});
JsonNode hotSummary = hot["summary"]!["metrics"]!;
JsonNode minuteSummary = minute["summary"]!["metrics"]!;
Assert.Equal(10.0, hotSummary["loss.total"]!.GetValue<double>(), 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<double>() - minuteSummary[id]!.GetValue<double>()) < 1e-9),
$"{id}: hot={hotSummary[id]} persisted={minuteSummary[id]}");
}
Assert.Equal(1800, hot["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
Assert.Equal(hot["summary"]!["samples"]!["quic_ping"]!.GetValue<long>(), minute["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
Assert.Equal(3600, minute["summary"]!["samples"]!["tcp_ping"]!.GetValue<long>());
// RTT = 2 × 20 ms,直方图的相对误差 ≤ 4.5%
Assert.InRange(minuteSummary["rtt.p95"]!.GetValue<double>(), 40 * 0.955, 40 * 1.045);
Assert.Equal(3, minute["t"]!.AsArray().Count);
Assert.Equal(1.0, minute["summary"]!["coverage"]!.GetValue<double>(), 3);
// 汇总是精确合并,不是各点的平均:每个分钟桶的丢包率都是 10%,整体也是 10%
Assert.All(minute["values"]!["loss.total"]!.AsArray(), v => Assert.Equal(10.0, v!.GetValue<double>(), 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<string>());
Assert.Equal(6, tenSeconds["t"]!.AsArray().Count);
Assert.All(tenSeconds["values"]!["loss.total"]!.AsArray(), v => Assert.Equal(0.0, v!.GetValue<double>()));
// 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<bool>());
Assert.Equal("30s", degraded["step"]!.GetValue<string>());
}
}
[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<string>() == goodName);
int badIndex = Array.FindIndex(lines, l => l["name"]!.GetValue<string>() == 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>();
double badScore = lines[badIndex]["summary"]!["metrics"]!["score.base"]!.GetValue<double>();
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<double>(), 6);
Assert.Equal(1200, lines[goodIndex]["summary"]!["samples"]!["quic_ping"]!.GetValue<long>());
// 按指标排序: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<string>())];
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>();
double rttB = compare["summary"]![_backend.Line(b.NodeId.Value)]!.GetValue<double>();
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<long>());
Assert.Equal(distribution["count"]!.GetValue<long>(), distribution["buckets"]!.AsArray().Sum(x => x!["count"]!.GetValue<long>()));
Assert.InRange(distribution["p50"]!.GetValue<double>(), 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<long>())) == 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<long>())));
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<string>(), problem["detail"]?.GetValue<string>() ?? "");
}
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<string>()).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<string>() == 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<string>() == 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"]!["quic_ping"]?.GetValue<long>() == 600);
// Agent 重连后补传一个迟到的块(落在同一个分钟窗口里)
RawChunk late = ChunkFactory.Echo(agent.NodeId, "quic_ping", 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"]!["quic_ping"]?.GetValue<long>() == 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"]!["quic_ping"]?.GetValue<long>() == 3000;
});
// 5 分钟层由降采样把 5 个 1 分钟窗口精确合并而来
JsonNode fiveMinutes = null!;
await BackendFixture.EventuallyAsync(async () =>
{
fiveMinutes = await SeriesAsync(agent.NodeId.Value, range + "&step=5m");
return fiveMinutes["source"]!.GetValue<string>() == "5m" && fiveMinutes["summary"]!["samples"]!["quic_ping"]?.GetValue<long>() == 3000;
});
Assert.Equal(oneMinute["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), fiveMinutes["summary"]!["metrics"]!["loss.total"]!.GetValue<double>(), 9);
Assert.Equal(oneMinute["summary"]!["metrics"]!["rtt.p99"]!.GetValue<double>(), fiveMinutes["summary"]!["metrics"]!["rtt.p99"]!.GetValue<double>(), 9);
}
}
}