Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry. Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
167 lines
7.5 KiB
C#
167 lines
7.5 KiB
C#
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.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);
|
||
}
|
||
}
|