Files

167 lines
7.5 KiB
C#
Raw Permalink 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 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", ["quic_ping.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 100), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
var b = Metric.Counter("x", ["quic_ping.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.up_max", 1), ("loss.run.down_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.up_max", 40), ("loss.run.down_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.up_max"] = [1, 3, 10, 30, 60], ["loss.run.down_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()
{
// 真实的拨号侧 Agent 把一个阶段切成几块(一块最多 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);
}
}