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 { ["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(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); } }