refactor!: rename the project from Monitor to MoShiro

Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
This commit is contained in:
justme committed 2026-10-07 01:49:04 +08:00
1 parent 17b35223aa
commit 83e5120377
309 files changed
+1505 -1499

No files matched your search

@@ -0,0 +1,162 @@
using MoShiro.Backend.Analysis;
namespace MoShiro.Backend.Analysis.Tests;
public sealed class AccumulatorTests
{
[Theory]
[InlineData(0.5)]
[InlineData(0.9)]
[InlineData(0.95)]
[InlineData(0.99)]
public void Histogram_Quantile_WithinRelativeError(double q)
{
var rng = new Random(42);
var values = new List<double>();
var hist = new HistogramAcc();
for (int i = 0; i < 20_000; i++)
{
// 对数正态分布:中位数约 50 ms,长尾
double v = 50 * Math.Exp(rng.NextDouble() * 2.5 - 1.2);
values.Add(v);
hist.Add(v);
}
values.Sort();
double exact = values[(int)(q * (values.Count - 1))];
double estimate = hist.Quantile(q)!.Value;
Assert.InRange(Math.Abs(estimate - exact) / exact, 0, 0.045);
}
[Fact]
public void Histogram_Merge_EqualsSingleAccumulator()
{
var rng = new Random(7);
var whole = new HistogramAcc();
var left = new HistogramAcc();
var right = new HistogramAcc();
for (int i = 0; i < 5000; i++)
{
double v = rng.NextDouble() * 200;
whole.Add(v);
(i % 2 == 0 ? left : right).Add(v);
}
left.Merge(right);
Assert.Equal(whole.Count, left.Count);
Assert.Equal(whole.Quantile(0.95), left.Quantile(0.95));
Assert.Equal(whole.Quantile(0.5), left.Quantile(0.5));
Assert.Equal(whole.Max, left.Max);
Assert.Equal(whole.NonEmptyBuckets, left.NonEmptyBuckets);
}
[Fact]
public void Histogram_Empty_HasNoQuantiles()
{
var hist = new HistogramAcc();
Assert.Null(hist.Quantile(0.5));
Assert.Null(hist.Mean);
}
[Fact]
public void Histogram_SingleSample_QuantilesClampToSample()
{
var hist = new HistogramAcc();
hist.Add(42);
Assert.Equal(42, hist.Quantile(0.01));
Assert.Equal(42, hist.Quantile(0.99));
}
[Fact]
public void Histogram_IgnoresNegativeAndNaN()
{
var hist = new HistogramAcc();
hist.Add(-1);
hist.Add(double.NaN);
Assert.Equal(0UL, hist.Count);
}
[Fact]
public void Histogram_ValuesBeyondRange_LandInEdgeBuckets()
{
var hist = new HistogramAcc();
hist.Add(0.01);
hist.Add(1_000_000);
Assert.Equal(2UL, hist.Count);
Assert.Equal(2, hist.NonEmptyBuckets);
}
[Fact]
public void Ratio_Merge_SumsNumeratorAndDenominator()
{
var a = new RatioAcc();
a.Add(1, 10);
var b = new RatioAcc();
b.Add(3, 10);
a.Merge(b);
Assert.Equal(20.0, a.Percent);
Assert.Null(new RatioAcc().Percent);
}
[Fact]
public void Merge_DifferentKinds_Throws()
{
Assert.Throws<InvalidOperationException>(() => new CounterAcc().Merge(new RatioAcc()));
}
[Fact]
public void Codec_RoundTrip_PreservesEveryKind()
{
var set = new MetricSet();
set.GetOrAdd("c", AccKind.Counter).Add(5);
set.GetOrAdd("r", AccKind.Ratio).Add(2, 9);
set.GetOrAdd("s", AccKind.Sum).Add(2.5);
set.GetOrAdd("n", AccKind.Min).Add(-3);
set.GetOrAdd("x", AccKind.Max).Add(11);
set.GetOrAdd("l", AccKind.RunLength).Add(4);
var hist = (HistogramAcc)set.GetOrAdd("h", AccKind.Histogram);
foreach (double v in new[] { 1.0, 20, 20, 300 })
{
hist.Add(v);
}
set.GetOrAdd("t", AccKind.Series).Add(2, 7.5);
MetricSet decoded = AccumulatorCodec.Decode(AccumulatorCodec.Encode(set)).Value;
Assert.Equal(5UL, ((CounterAcc)decoded.Items["c"]).Value);
Assert.Equal(9UL, ((RatioAcc)decoded.Items["r"]).Denominator);
Assert.Equal(2.5, ((SumAcc)decoded.Items["s"]).Sum);
Assert.Equal(-3, ((MinAcc)decoded.Items["n"]).Value);
Assert.Equal(11, ((MaxAcc)decoded.Items["x"]).Value);
Assert.Equal(4, ((RunLengthAcc)decoded.Items["l"]).Max);
var h2 = (HistogramAcc)decoded.Items["h"];
Assert.Equal(hist.Count, h2.Count);
Assert.Equal(hist.Quantile(0.5), h2.Quantile(0.5));
Assert.Equal(7.5, ((SeriesAcc)decoded.Items["t"]).Points[2]);
}
[Fact]
public void Codec_Decode_RejectsTruncatedData()
{
var set = new MetricSet();
set.GetOrAdd("h", AccKind.Histogram).Add(10);
byte[] bytes = AccumulatorCodec.Encode(set);
Assert.False(AccumulatorCodec.Decode(bytes.AsSpan(0, bytes.Length - 3)).IsSuccess);
Assert.False(AccumulatorCodec.Decode([200, 200]).IsSuccess);
}
[Fact]
public void MetricSet_Merge_CombinesDisjointAndSharedMetrics()
{
var a = new MetricSet();
a.GetOrAdd("x", AccKind.Counter).Add(1);
var b = new MetricSet();
b.GetOrAdd("x", AccKind.Counter).Add(2);
b.GetOrAdd("y", AccKind.Counter).Add(4);
a.Merge(b);
Assert.Equal(3UL, ((CounterAcc)a.Items["x"]).Value);
Assert.Equal(4UL, ((CounterAcc)a.Items["y"]).Value);
Assert.Equal(2UL, ((CounterAcc)b.Items["x"]).Value);
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA1861</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\src\MoShiro.Backend.Analysis\MoShiro.Backend.Analysis.csproj" />
<ProjectReference Include="..\MoShiro.TestSupport\MoShiro.TestSupport.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,166 @@
using Fleet.Core;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Analysis.Tests;
public sealed class ModelAndTestEvaluatorTests
{
private const long T0 = 1_700_000_000_000;
private static readonly NodeId s_agent = new("agt_test");
private static MetricView View(params (string Id, double Value)[] values) =>
new(values.ToDictionary(v => v.Id, v => v.Value));
[Fact]
public void Registry_ExportIds_AreUnique_AndPresetsResolve()
{
Assert.Equal(MetricRegistry.AllExports().Count(), MetricRegistry.AllExports().Select(e => e.Export.Id).Distinct().Count());
foreach (PresetDef preset in MetricRegistry.Presets)
{
foreach (string id in preset.MetricIds)
{
Assert.True(
MetricRegistry.TryFindExport(id, out _, out _) || id.StartsWith("score.", StringComparison.Ordinal),
$"preset {preset.Id} references unknown metric {id}");
}
}
Assert.All(MetricRegistry.All, m => Assert.NotEmpty(m.Fingerprint));
}
[Fact]
public void Fingerprint_ChangesWhenTheOpChanges()
{
var a = Metric.Counter("x", ["udp_echo.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 100), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
var b = Metric.Counter("x", ["udp_echo.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 200), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
Assert.NotEqual(a.Fingerprint, b.Fingerprint);
}
[Fact]
public void BaseModel_PerfectLine_ScoresHigh_AndBadLine_ScoresLow()
{
double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run_max", 1))).Score!.Value;
double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run_max", 40))).Score!.Value;
Assert.True(good > 95);
Assert.True(bad < 10);
}
[Fact]
public void Models_WithNoData_ReturnNullScore()
{
foreach (IServiceModel model in ModelRegistry.All)
{
Assert.Null(model.Score(View()).Score);
}
}
[Fact]
public void McModel_WithoutTcpData_EstimatesFromUdp()
{
ModelScore score = new McModel().Score(View(("rtt.p95", 60), ("loss.total", 2), ("loss.run_max", 2)));
Assert.True(score.Estimated);
Assert.NotNull(score.Score);
Assert.Contains("estimated_from_udp", score.Caveats);
Assert.False(new McModel().Score(View(("tcp.rtt.p95", 60), ("tcp.stall.per_min", 0))).Estimated);
}
[Fact]
public void Hy2Model_TestResultsDominate_AndNoTestIsEstimated()
{
ModelScore withTest = new Hy2Model().Score(View(("hy2.sustainable_mbps", 100), ("hy2.effective_mbps", 95), ("loss.total", 0.2)));
ModelScore without = new Hy2Model().Score(View(("loss.total", 0.2), ("rtt.p95", 50)));
Assert.False(withTest.Estimated);
Assert.True(without.Estimated);
Assert.Contains("no_load_test", without.Caveats);
Assert.True(withTest.Score > 90);
}
[Fact]
public void Models_AreMonotonic_WorseMetricsNeverRaiseTheScore()
{
// 对每个模型的每个所需指标:在“好”和“差”之间扫描,分数不能随指标变差而升高。
var ranges = new Dictionary<string, double[]>
{
["rtt.p95"] = [10, 30, 80, 150, 300, 600], ["loss.total"] = [0, 0.5, 1, 3, 10, 30],
["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run_max"] = [1, 3, 10, 30, 60],
["tcp.rtt.p95"] = [10, 40, 100, 200, 400, 800], ["tcp.stall.per_min"] = [0, 1, 5, 20, 60, 200], ["tcp.stall_max"] = [50, 150, 500, 2000, 5000],
["tcp.retrans"] = [0, 0.1, 1, 3, 10, 30], ["connect.tls_ms.p95"] = [50, 150, 400, 1000, 3000], ["hold.resets.per_h"] = [0, 1, 4, 12, 30],
["connect.success"] = [100, 99, 95, 80, 50], ["tcp.bulk.peak.max"] = [800, 400, 100, 10, 1], ["hy2.sustainable_mbps"] = [100, 50, 25, 10, 0],
["hy2.effective_mbps"] = [100, 50, 25, 10, 0], ["hy2.qos_onset_mbps"] = [100, 50, 25, 10],
};
foreach (IServiceModel model in ModelRegistry.All)
{
foreach (string need in model.Needs.Where(ranges.ContainsKey))
{
var baseline = ranges.Where(r => model.Needs.Contains(r.Key)).ToDictionary(r => r.Key, r => r.Value[0]);
double previous = double.MaxValue;
foreach (double value in ranges[need])
{
var values = new Dictionary<string, double>(baseline) { [need] = value };
double score = model.Score(new MetricView(values)).Score ?? 0;
Assert.True(score <= previous + 1e-9, $"{model.Id}: {need}={value} raised the score from {previous} to {score}");
previous = score;
}
}
}
}
[Fact]
public void TestEvaluator_Hy2Ladder_APhaseSplitIntoSeveralChunksIsOneStep()
{
// 真实的 Probe 把一个阶段切成几块(一块最多 50000 条记录):阶梯里的一步是一个阶段,不是一个块
RawChunk[] chunks =
[
ChunkFactory.Train(s_agent, T0, 10, 0),
ChunkFactory.Train(s_agent, T0 + 10_000, 50, 1, seconds: 5, lossEvery: 5),
ChunkFactory.Train(s_agent, T0 + 15_000, 50, 1, seconds: 5, lossEvery: 5),
];
TestResult result = TestEvaluator.Evaluate("hy2_ladder", chunks);
Assert.Equal(2, result.Steps.Count);
Assert.Equal(new double[] { 10, 50 }, result.Series["step_mbps"]);
Assert.InRange(result.Steps[1].LossPct, 19, 21);
Assert.InRange(result.Steps[1].DeliveredMbps, 38, 42); // 50 Mbit/s × (1 − 20% 丢包),按整个阶段的时长算
}
[Fact]
public void TestEvaluator_Hy2Ladder_FindsSustainableRateAndOnset()
{
// 10/25 Mbps 无丢包;50 Mbps 起被限速,丢包 20%、40%
RawChunk[] chunks =
[
ChunkFactory.Train(s_agent, T0, 10, 0),
ChunkFactory.Train(s_agent, T0 + 10_000, 25, 1),
ChunkFactory.Train(s_agent, T0 + 20_000, 50, 2, lossEvery: 5),
ChunkFactory.Train(s_agent, T0 + 30_000, 100, 3, lossEvery: 2),
];
TestResult result = TestEvaluator.Evaluate("hy2_ladder", chunks);
Assert.Equal(4, result.Steps.Count);
Assert.Equal(25, result.Metrics["hy2.sustainable_mbps"]);
Assert.Equal(50, result.Metrics["hy2.qos_onset_mbps"]);
Assert.InRange(result.Steps[2].LossPct, 19, 21);
Assert.InRange(result.Steps[3].LossPct, 49, 51);
Assert.InRange(result.Steps[0].DeliveredMbps, 9.5, 10.5);
Assert.Equal(new double[] { 10, 25, 50, 100 }, result.Series["step_mbps"]);
Assert.Contains("port_differs_from_service", result.Caveats);
// 模型能直接消费测试结果
ModelScore score = new Hy2Model().Score(new MetricView(result.Metrics));
Assert.False(score.Estimated);
Assert.InRange(score.Score!.Value, 20, 80);
}
[Fact]
public void TestEvaluator_PartialChunks_AddCaveat()
{
RawChunk chunk = ChunkFactory.Train(s_agent, T0, 10, 0);
chunk.Header.Partial = true;
Assert.Contains("partial_data", TestEvaluator.Evaluate("hy2_ladder", [chunk]).Caveats);
}
}
@@ -0,0 +1,254 @@
using Fleet.Core;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Analysis.Tests;
public sealed class OpTests
{
private const long T0 = 1_700_000_000_000;
private static readonly NodeId s_agent = new("agt_test");
private static MetricSet EvaluateOneBucket(RawChunk chunk, params string[] only)
{
var buckets = new SortedDictionary<long, MetricSet>();
WindowEvaluator.Default.Evaluate(chunk, 24L * 3600 * 1000, buckets, only.Length == 0 ? null : new HashSet<string>(only));
var merged = new MetricSet();
foreach (MetricSet set in buckets.Values)
{
merged.Merge(set);
}
return merged;
}
[Fact]
public void Rtt_RemovesAgentProcessingTime()
{
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, _ => new EchoPacket(false, false, 20_000, 30_000, ProcessUs: 5_000));
var rtt = (HistogramAcc)EvaluateOneBucket(chunk, "rtt").Items["rtt"];
Assert.Equal(100UL, rtt.Count);
Assert.InRange(rtt.Quantile(0.5)!.Value, 50 * 0.955, 50 * 1.045);
}
[Fact]
public void Loss_SeparatesUpAndDown()
{
RawChunk chunk = ChunkFactory.Echo(
s_agent, "udp_echo", T0, 100, 100,
i => new EchoPacket(LostUp: i < 10, LostDown: i is >= 10 and < 15, 20_000, 20_000));
MetricSet set = EvaluateOneBucket(chunk, "loss.up", "loss.down", "loss.total");
Assert.Equal(10.0, ((RatioAcc)set.Items["loss.up"]).Percent);
// 下行只统计上行已到达的 90 个包里的 5 个
Assert.Equal(100.0 * 5 / 90, ((RatioAcc)set.Items["loss.down"]).Percent!.Value, 6);
Assert.Equal(15.0, ((RatioAcc)set.Items["loss.total"]).Percent);
}
[Fact]
public void Jitter_ConstantDelayWithClockOffset_IsZero()
{
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0);
var jitter = (HistogramAcc)EvaluateOneBucket(chunk, "jitter.up", "jitter.down").Items["jitter.up"];
Assert.True(jitter.Count > 0);
Assert.True(jitter.Quantile(0.95)!.Value < 0.2, "a constant delay must not look like jitter despite the 5 s clock offset");
}
[Fact]
public void Jitter_OnlyOneDirectionVaries_IsAttributedToThatDirection()
{
// 下行时延每隔一个包多 20 ms
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, i => new EchoPacket(false, false, 20_000, 20_000 + (i % 2 == 0 ? 20_000 : 0)));
MetricSet set = EvaluateOneBucket(chunk, "jitter.up", "jitter.down");
Assert.True(((HistogramAcc)set.Items["jitter.up"]).Quantile(0.95) < 0.5);
Assert.InRange(((HistogramAcc)set.Items["jitter.down"]).Quantile(0.95)!.Value, 15, 25);
}
[Fact]
public void Jitter_TooFewSamples_ProducesNoObservations()
{
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0, count: 5);
MetricSet set = EvaluateOneBucket(chunk, "jitter.up");
Assert.False(set.Items.ContainsKey("jitter.up"));
}
[Fact]
public void LossRuns_ReportsConsecutiveLosses()
{
bool[] lost = [false, true, true, true, false, true, false, false, true, true];
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, lost.Length, 100, i => new EchoPacket(false, lost[i], 20_000, 20_000));
var runs = (RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"];
Assert.Equal(3UL, runs.Runs);
Assert.Equal(3, runs.Max);
Assert.Equal(2.0, runs.Mean);
}
[Fact]
public void LossRuns_TrailingRunIsReported()
{
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 4, 100, i => new EchoPacket(false, i >= 2, 20_000, 20_000));
Assert.Equal(2, ((RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"]).Max);
}
[Fact]
public void Reorder_CountsLateArrivals_AndIgnoresDuplicates()
{
// 包 3 的回复比包 4 晚到
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, 6, 100, i => new EchoPacket(false, false, 20_000, i == 3 ? 150_000 : 20_000));
MetricSet set = EvaluateOneBucket(chunk, "reorder", "dup");
Assert.Equal(1UL, ((CounterAcc)set.Items["reorder"]).Value);
Assert.False(set.Items.ContainsKey("dup"));
}
[Fact]
public void Stall_DetectsHeadOfLineBlocking()
{
// 第 40 个包之后的 10 个包被队头阻塞,全部在 2540 ms 之后几乎同时到达
RawChunk chunk = ChunkFactory.Echo(
s_agent, "tcp_echo", T0, 100, 50,
i => new EchoPacket(false, false, 20_000, i is >= 40 and < 50 ? ((2540 + (i - 40) - (50 * i) - 20) * 1000) - 50 : 20_000));
MetricSet set = EvaluateOneBucket(chunk, "tcp.stall", "tcp.stall_max");
Assert.Equal(1UL, ((CounterAcc)set.Items["tcp.stall"]).Value);
Assert.InRange(((MaxAcc)set.Items["tcp.stall_max"]).Value, 500, 600);
}
[Fact]
public void Stall_SteadyArrivals_AreNotStalls()
{
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 100, 50, _ => new EchoPacket(false, false, 20_000, 20_000));
MetricSet set = EvaluateOneBucket(chunk, "tcp.stall");
Assert.False(set.Items.ContainsKey("tcp.stall"));
}
[Fact]
public void Slow_CountsEchoesBeyondTheChunkLocalLongTailThreshold()
{
// 基线 50 ms(单程 25 ms),阈值 = max(90, 50 + max(15, 17.5)) = 90 ms;第 10、20、30 个回显的 RTT 是 200 ms
RawChunk chunk = ChunkFactory.Echo(
s_agent, "tcp_echo", T0, 100, 50,
i => new EchoPacket(false, false, 25_000, i % 10 == 0 && i > 0 && i < 40 ? 175_000 : 25_000, ProcessUs: 0));
var slow = (RatioAcc)EvaluateOneBucket(chunk, "tcp.slow").Items["tcp.slow"];
Assert.Equal(100UL, slow.Denominator);
Assert.Equal(3UL, slow.Numerator);
}
[Fact]
public void Slow_ASteadyConnection_HasNoLongTail_AndTooFewSamplesGiveNoObservation()
{
RawChunk steady = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 100, 50, _ => new EchoPacket(false, false, 25_000, 25_000));
Assert.Equal(0UL, ((RatioAcc)EvaluateOneBucket(steady, "tcp.slow").Items["tcp.slow"]).Numerator);
RawChunk few = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 50, i => new EchoPacket(false, false, 25_000, i == 5 ? 500_000 : 25_000));
Assert.False(EvaluateOneBucket(few, "tcp.slow").Items.ContainsKey("tcp.slow"));
}
[Fact]
public void RttTail_P999AndTailRatioComeFromTheSameMergedHistogram()
{
// 1000 个回显,其中 5 个 1 s 的长尾:p50 约 50 ms,p99 仍在 50 ms 附近,p99.9 落在长尾上
RawChunk chunk = ChunkFactory.Echo(
s_agent, "tcp_echo", T0, 1000, 10,
i => new EchoPacket(false, false, 25_000, i % 200 == 0 && i > 0 ? 975_000 : 25_000, ProcessUs: 0), durationMs: 20_000);
var rtt = (HistogramAcc)EvaluateOneBucket(chunk, "tcp.rtt").Items["tcp.rtt"];
MetricDef def = TcpMetrics.Rtt;
double? p999 = MetricDef.Export(rtt, def.Exports.Single(e => e.Stat == Stat.P999), 20);
double? tail = MetricDef.Export(rtt, def.Exports.Single(e => e.Stat == Stat.Tail), 20);
double? max = MetricDef.Export(rtt, def.Exports.Single(e => e.Stat == Stat.Max), 20);
Assert.InRange(p999!.Value, 500, 1100);
Assert.InRange(max!.Value, 999, 1001);
Assert.InRange(tail!.Value, 0.9, 1.2); // p99 / p50:偶发的尖峰不改变 p99,所以长尾比接近 1,真正的长尾由 p99.9 和 max 暴露
Assert.Equal(Unit.None, def.Exports.Single(e => e.Stat == Stat.Tail).Unit);
Assert.Equal("tcp.rtt.p999", def.Exports.Single(e => e.Stat == Stat.P999).Id);
}
[Fact]
public void Retrans_UsesKernelCounterDeltas()
{
long baseUs = ChunkFactory.ProbeUs(0);
RawStream info = ChunkFactory.TcpInfo(
[
(baseUs, 0, 40_000u, 0u, 100u),
(baseUs + 1_000_000, 0, 40_000u, 2u, 300u),
(baseUs + 2_000_000, 0, 40_000u, 2u, 500u),
]);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]);
var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"];
Assert.Equal(2UL, retrans.Numerator);
Assert.Equal(400UL, retrans.Denominator);
}
[Fact]
public void Retrans_CounterResetNeverGoesNegative()
{
long baseUs = ChunkFactory.ProbeUs(0);
RawStream info = ChunkFactory.TcpInfo([(baseUs, 0, 1u, 10u, 100u), (baseUs + 1_000_000, 0, 1u, 0u, 50u), (baseUs + 2_000_000, 0, 1u, 1u, 150u)]);
RawChunk chunk = ChunkFactory.Echo(s_agent, "tcp_echo", T0, 10, 100, _ => new EchoPacket(false, false, 20_000, 20_000), extraStreams: [info]);
var retrans = (RatioAcc)EvaluateOneBucket(chunk, "tcp.retrans").Items["tcp.retrans"];
Assert.Equal(1UL, retrans.Numerator);
Assert.Equal(100UL, retrans.Denominator);
}
[Fact]
public void Connect_Outcomes_AreCountedByClass()
{
RawChunk chunk = ChunkFactory.Connect(
s_agent, T0,
[
(30_000, 80_000, ConnectOutcomeCode.Ok),
(31_000, 90_000, ConnectOutcomeCode.Ok),
(0, null, ConnectOutcomeCode.Timeout),
(30_000, null, ConnectOutcomeCode.Reset),
]);
MetricSet set = EvaluateOneBucket(chunk);
Assert.Equal(50.0, ((RatioAcc)set.Items["connect.success"]).Percent);
Assert.Equal(1UL, ((CounterAcc)set.Items["connect.fail.timeout"]).Value);
Assert.Equal(1UL, ((CounterAcc)set.Items["connect.fail.rst"]).Value);
Assert.False(set.Items.ContainsKey("connect.fail.tls"));
Assert.Equal(3UL, ((HistogramAcc)set.Items["connect.tcp_ms"]).Count);
Assert.Equal(2UL, ((HistogramAcc)set.Items["connect.tls_ms"]).Count);
}
[Fact]
public void Evaluate_AssignsObservationsToTheirOwnBuckets()
{
// 10 秒的块,按 1 秒分桶:每个桶 10 个包
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0);
var buckets = new SortedDictionary<long, MetricSet>();
WindowEvaluator.Default.Evaluate(chunk, 1000, buckets, new HashSet<string> { "n.udp_echo" });
Assert.Equal(10, buckets.Count);
Assert.All(buckets.Values, set => Assert.Equal(10UL, ((CounterAcc)set.Items["n.udp_echo"]).Value));
Assert.Equal(T0, buckets.Keys.First());
}
[Fact]
public void Evaluate_ChunkOfAnotherKind_ProducesNothingForUnrelatedMetrics()
{
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0);
MetricSet set = EvaluateOneBucket(chunk);
Assert.False(set.Items.ContainsKey("tcp.rtt.p95"));
Assert.False(set.Items.ContainsKey("tcp.rtt"));
Assert.True(set.Items.ContainsKey("rtt"));
}
[Fact]
public void ChangePoint_FindsTheShift_AndIgnoresNoise()
{
double[] shifted = [10, 10, 11, 10, 9, 10, 4, 4, 5, 4, 4, 3];
Assert.Equal(6, ChangePoint.Detect(shifted, minShift: 3));
double[] flat = [10, 10.2, 9.8, 10.1, 9.9, 10, 10.1, 9.9];
Assert.Equal(-1, ChangePoint.Detect(flat, minShift: 3));
Assert.Equal(-1, ChangePoint.Detect([1, 2], minShift: 1));
Assert.Equal(2, ChangePoint.FirstJump([0, 1, 20, 50], minJump: 5));
Assert.Equal(-1, ChangePoint.FirstJump([0, 1, 2, 3], minJump: 5));
}
[Fact]
public void ClockDriftFit_RemovesLinearDrift()
{
double[] t = Enumerable.Range(0, 50).Select(i => i * 100_000.0).ToArray();
double[] delay = t.Select(x => 5_000_000 + (x * 50e-6) + 20_000).ToArray(); // 偏移 + 50 ppm 漂移 + 固定时延
double[] jitter = ClockDriftFit.Jitter(t, delay)!;
Assert.All(jitter, j => Assert.True(j < 1));
}
}