chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

@@ -0,0 +1,131 @@
using System.Text.Json;
using Fleet.Core;
using Monitor.Protocol;
namespace Monitor.Protocol.Tests;
public sealed class CatalogTests
{
private static JsonElement Json(string text) => JsonDocument.Parse(text).RootElement.Clone();
[Fact]
public void EveryKind_HasBoundedParams_ValidDefaults_AndUniqueNames()
{
Assert.Equal(Kinds.All.Count, Kinds.All.Select(k => k.Id).Distinct().Count());
foreach (KindSpec kind in Kinds.All)
{
ParamResolution defaults = ParamResolver.Resolve(kind.Params, null);
Assert.True(defaults.IsValid, $"{kind.Id}: defaults must be valid");
Assert.Equal(kind.Params.Count, kind.Params.Select(p => p.Name).Distinct().Count());
Assert.True(kind.BudgetBitsPerSecond(defaults.Values!) >= 0);
foreach (ParamSpec spec in kind.Params)
{
// 每个参数的默认值必须能通过自己的校验(上下限在构造时已写明)
Assert.True(spec.TryNormalize(spec.DefaultValue, out _, out string error), $"{kind.Id}.{spec.Name}: {error}");
}
foreach (StreamSpec stream in kind.Streams)
{
Assert.Equal(stream.Columns.Count, stream.Columns.Select(c => c.Name).Distinct().Count());
}
}
}
[Fact]
public void DefaultLineTemplates_StayWithinTheLineBudget()
{
long total = 0;
foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho })
{
total += kind.BudgetBitsPerSecond(ParamResolver.Resolve(kind.Params, null).Values!);
}
Assert.InRange(total, 1, ProtocolLimits.MaxLineBudgetBitsPerSecond);
}
[Fact]
public void Resolve_FillsDefaults_AndReportsEveryProblem()
{
var input = new Dictionary<string, JsonElement>
{
["rate_hz"] = Json("500"),
["size"] = Json("\"big\""),
["bogus"] = Json("1"),
};
ParamResolution result = ParamResolver.Resolve(Kinds.UdpEcho.Params, input);
Assert.False(result.IsValid);
Assert.Contains("between 1 and 200", result.Errors["rate_hz"]);
Assert.Equal("must be an integer", result.Errors["size"]);
Assert.Contains("not a parameter", result.Errors["bogus"]);
Assert.False(result.Errors.ContainsKey("port"));
}
[Fact]
public void Resolve_AcceptsBoundariesAndRejectsJustOutside()
{
Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1") }).IsValid);
Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("200") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("0") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("201") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1.5") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpTrain.Params, new Dictionary<string, JsonElement> { ["direction"] = Json("\"sideways\"") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.TcpEcho.Params, new Dictionary<string, JsonElement> { ["tls"] = Json("1") }).IsValid);
}
[Fact]
public void Kinds_AreUnknownWhenNotDeclared()
{
Assert.Null(Kinds.Find("nope"));
}
[Theory]
[InlineData("continuous", true)]
[InlineData("once", true)]
[InlineData("every:60", true)]
[InlineData("every:59", false)]
[InlineData("every:", false)]
[InlineData("every:-5", false)]
[InlineData("hourly", false)]
public void Schedule_Validation(string schedule, bool valid)
{
Assert.Equal(valid, TaskSchedule.Validate(schedule).IsSuccess);
}
[Fact]
public void TaskSet_RoundTripsThroughTheJsonContext()
{
var set = new TaskSet
{
Version = 7,
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }],
};
string text = JsonSerializer.Serialize(set, MonitorJsonContext.Default.TaskSet);
TaskSet back = JsonSerializer.Deserialize(text, MonitorJsonContext.Default.TaskSet)!;
Assert.Contains("\"id\":\"tsk_1\"", text);
Assert.Equal(7, back.Version);
Assert.Equal("tsk_1", back.Tasks[0].Id.Value);
Assert.Equal(10, back.Tasks[0].Params["rate_hz"].GetInt64());
Assert.Equal("prb_1", back.Peers[0].ProbeId.Value);
}
[Fact]
public void AgentEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
{
AgentEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MonitorJsonContext.Default.AgentEnrollMeta)!;
Assert.Equal(7443, meta.ProbeTcpPort);
Assert.Equal(["udp_echo"], meta.Kinds);
}
[Fact]
public void ErrorCodes_AreSnakeCase()
{
foreach (System.Reflection.FieldInfo field in typeof(MonitorErrors).GetFields())
{
string value = ((ErrorCode)field.GetValue(null)!).Value;
Assert.Matches("^[a-z]+(_[a-z]+)*$", value);
}
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\Monitor.Protocol\Monitor.Protocol.csproj" />
<ProjectReference Include="..\..\..\tests\Monitor.TestSupport\Monitor.TestSupport.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,199 @@
using System.Buffers.Binary;
using System.IO.Compression;
using Fleet.Core;
using Monitor.Protocol;
using Monitor.TestSupport;
namespace Monitor.Protocol.Tests;
public sealed class RawChunkCodecTests
{
private static readonly NodeId s_agent = new("agt_test");
private static RawChunk SampleChunk()
{
RawChunk chunk = ChunkFactory.Echo(
s_agent, "udp_echo", 1_700_000_000_000, 6, 100,
i => new EchoPacket(LostUp: i == 2, LostDown: i == 4, 20_000 + (i * 100), 21_000));
chunk.Header.ChunkId = new ChunkId(Guid.Parse("01890000-0000-7000-8000-000000000001"));
return chunk;
}
[Fact]
public void RoundTrip_PreservesEveryValue_IncludingMissingOnes()
{
RawChunk original = SampleChunk();
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(original)).Value;
Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId);
Assert.Equal("udp_echo", decoded.Header.Kind);
Assert.Equal(original.Header.Clock.ProbeOffsetUs, decoded.Header.Clock.ProbeOffsetUs);
RawStream before = original.Find("msg")!;
RawStream after = decoded.Find("msg")!;
Assert.Equal(before.Count, after.Count);
foreach (RawColumn column in before.Columns)
{
RawColumn other = after.Find(column.Name)!;
Assert.Equal(column.Role, other.Role);
for (int row = 0; row < before.Count; row++)
{
Assert.Equal(column.IsValid(row), other.IsValid(row));
if (column.IsValid(row))
{
Assert.Equal(column.Values[row], other.Values[row]);
}
}
}
Assert.False(after.Find("t2")!.IsValid(2));
Assert.False(after.Find("t4")!.IsValid(4));
}
[Fact]
public void RoundTrip_HandlesTypeExtremes_AndNegativeDeltas()
{
var spec = new StreamSpec("x", [
Col.U8("a"), Col.U16("b"), Col.U32("c"), Col.U64("d"), Col.Seq("e"),
new ColumnSpec("f", ColumnType.I32), new ColumnSpec("g", ColumnType.I64, ColumnEnc.Delta, true),
]);
var builder = new StreamBuilder(spec)
.Add(byte.MaxValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, 5, int.MinValue, long.MinValue / 2)
.Add(0, 0, 0, 0, 2, int.MaxValue, null)
.Add(1, 1, 1, 1, 9, -1, long.MaxValue / 2);
var header = new RawChunkHeader { Kind = "x", AgentId = s_agent };
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(new RawChunk(header, [builder.Build()]))).Value;
RawStream s = decoded.Find("x")!;
Assert.Equal(uint.MaxValue, s.Find("c")!.Values[0]);
Assert.Equal(long.MaxValue, s.Find("d")!.Values[0]);
Assert.Equal(new long[] { 5, 2, 9 }, s.Find("e")!.Values);
Assert.Equal(int.MinValue, s.Find("f")!.Values[0]);
Assert.Equal(long.MinValue / 2, s.Find("g")!.Values[0]);
Assert.False(s.Find("g")!.IsValid(1));
Assert.Equal(long.MaxValue / 2, s.Find("g")!.Values[2]);
}
[Fact]
public void GoldenBody_LayoutIsStable()
{
// 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。
RawChunk chunk = SampleChunk();
chunk.Header.Clock = new ChunkClock { AgentMonoUs = 1000, UtcMs = 1_700_000_000_000, ProbeOffsetUs = 500 };
byte[] body = RawChunkCodec.EncodeBody(chunk);
uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(body);
string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength));
string header = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)headerLength));
Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"udp_echo\"", header);
Assert.Contains("\"streams\":[{\"name\":\"msg\",\"count\":6,\"columns\":[{\"name\":\"seq\",\"type\":\"u32\",\"enc\":\"delta\",\"nullable\":false},", header);
Assert.Equal(GoldenColumns, columns);
}
private const string GoldenColumns = "00020202020240404040404080fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818";
[Fact]
public void Decode_RejectsBadMagicVersionCrcAndTruncation()
{
byte[] good = RawChunkCodec.Encode(SampleChunk());
byte[] badMagic = (byte[])good.Clone();
badMagic[0] = (byte)'X';
byte[] badVersion = (byte[])good.Clone();
badVersion[4] = 99;
byte[] badCrc = (byte[])good.Clone();
badCrc[^1] ^= 0xFF;
Assert.Equal(MonitorErrors.RawCorrupt, RawChunkCodec.Decode(badMagic).Error.Code);
Assert.Contains("version", RawChunkCodec.Decode(badVersion).Error.Message);
Assert.Contains("crc", RawChunkCodec.Decode(badCrc).Error.Message);
Assert.False(RawChunkCodec.Decode(good.AsSpan(0, good.Length / 2)).IsSuccess);
Assert.False(RawChunkCodec.Decode([]).IsSuccess);
Assert.False(RawChunkCodec.Decode(good.AsSpan(0, 8)).IsSuccess);
}
[Fact]
public void Decode_RejectsDecompressionBombs()
{
byte[] zeros = new byte[ProtocolLimits.MaxDecodedChunkBytes + 1024];
using var compressed = new MemoryStream();
using (var brotli = new BrotliStream(compressed, CompressionLevel.Fastest, leaveOpen: true))
{
brotli.Write(zeros);
}
byte[] payload = compressed.ToArray();
Assert.True(payload.Length < 1_000_000, "the bomb must be small on the wire");
Assert.False(RawChunkCodec.Decode(Wrap(payload)).IsSuccess);
}
[Fact]
public void Decode_RejectsHeaderLengthBeyondData_AndTrailingBytes()
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, 1_000_000);
Assert.Contains("header length", RawChunkCodec.Decode(Wrap(Brotli(body))).Error.Message);
byte[] valid = RawChunkCodec.EncodeBody(SampleChunk());
byte[] withTrailing = [.. valid, 1, 2, 3];
Assert.Contains("trailing", RawChunkCodec.Decode(Wrap(Brotli(withTrailing))).Error.Message);
}
[Fact]
public void Decode_RejectsOutOfRangeValues_AndTooManyRecords()
{
// u8 列里写入 300
var spec = new StreamSpec("x", [new ColumnSpec("a", ColumnType.I32)]);
RawChunk chunk = new(new RawChunkHeader { Kind = "x", AgentId = s_agent }, [new StreamBuilder(spec).Add(300).Build()]);
byte[] body = RawChunkCodec.EncodeBody(chunk);
string json = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)BinaryPrimitives.ReadUInt32LittleEndian(body)));
string patched = json.Replace("\"type\":\"i32\"", "\"type\":\"u8\"");
byte[] patchedJson = System.Text.Encoding.UTF8.GetBytes(patched);
byte[] rebuilt = new byte[4 + patchedJson.Length + (body.Length - 4 - json.Length)];
BinaryPrimitives.WriteUInt32LittleEndian(rebuilt, (uint)patchedJson.Length);
patchedJson.CopyTo(rebuilt, 4);
body.AsSpan(4 + json.Length).CopyTo(rebuilt.AsSpan(4 + patchedJson.Length));
Assert.Contains("out of range", RawChunkCodec.Decode(Wrap(Brotli(rebuilt))).Error.Message);
string tooMany = json.Replace("\"count\":1", $"\"count\":{ProtocolLimits.MaxRecordsPerChunk + 1}");
byte[] tooManyJson = System.Text.Encoding.UTF8.GetBytes(tooMany);
byte[] big = new byte[4 + tooManyJson.Length];
BinaryPrimitives.WriteUInt32LittleEndian(big, (uint)tooManyJson.Length);
tooManyJson.CopyTo(big, 4);
Assert.Contains("invalid size", RawChunkCodec.Decode(Wrap(Brotli(big))).Error.Message);
}
[Fact]
public void Encode_IsSmallerThanTheNaiveRepresentation()
{
// 10 Hz 的 udp_echo 一个 10 秒块应当远小于每行 6×8 字节
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, 1_700_000_000_000);
Assert.True(RawChunkCodec.Encode(chunk).Length < 100 * 6 * 8 / 4);
}
[Fact]
public void Crc32c_MatchesTheStandardCheckValue()
{
Assert.Equal(0xE3069283u, Crc32c.Compute("123456789"u8));
}
private static byte[] Brotli(byte[] data)
{
using var output = new MemoryStream();
using (var brotli = new BrotliStream(output, CompressionLevel.Fastest, leaveOpen: true))
{
brotli.Write(data);
}
return output.ToArray();
}
private static byte[] Wrap(byte[] payload)
{
byte[] result = new byte[9 + payload.Length];
"MNRC"u8.CopyTo(result);
result[4] = ProtocolVersion.Container;
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload));
payload.CopyTo(result, 9);
return result;
}
}