using System.Net; using System.Net.Http.Headers; using System.Text.Json.Nodes; using Fleet.Core; using Monitor.Protocol; using Monitor.TestSupport; namespace Monitor.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(MonitorChannels.RawChunk, garbage.Value, corrupt)); // 冒名的块:agent_id 与认证的节点不一致 RawChunk spoofed = ChunkFactory.UdpEchoClean(new NodeId("agt_someone_else"), start); await agent.SendBinaryAsync(Fleet.Rpc.OutboxFrame.Build(MonitorChannels.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); } } }