Files
AnotherReplayReader/AiV2.Tests/Program.cs
T
2026-09-08 18:39:04 +02:00

1202 lines
66 KiB
C#
Raw 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 System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using AnotherReplayReader;
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
using AIAnalyze = AiV2.Tests.TestAIAnalyze;
using MainAIAnalyze = AnotherReplayReader.Utils.AIAnalyze;
namespace AiV2.Tests
{
internal static class Program
{
private static int _passed;
private static int _failed;
private static int Main()
{
Run("AiTimeParser", AiTimeParserTests.Run);
Run("MechanicalSegmenter", MechanicalSegmenterTests.Run);
Run("FocusPlanner", FocusPlannerTests.Run);
Run("OverviewParser", OverviewParserTests.Run);
Run("BackqueryParser", BackqueryParserTests.Run);
Run("BackquerySliceExtractor", BackquerySliceExtractorTests.Run);
Run("StructuredEvidence", StructuredEvidenceTests.Run);
Run("MachineReadableClaims", MachineReadableClaimsTests.Run);
Run("ClaimFindingsFormatter", ClaimFindingsFormatterTests.Run);
Run("AiSettingsPersistence", AiSettingsPersistenceTests.Run);
Run("AiContextBudget", AiContextBudgetTests.Run);
Run("MatchDigestBuilder", MatchDigestBuilderTests.Run);
Run("OwnershipAndTimelineValidation", OwnershipAndTimelineValidationTests.Run);
Run("JsonBlockMerging", JsonBlockMergingTests.Run);
Run("FactIndexBuild", FactIndexBuildTests.Run);
Run("StructuredKnowledge", StructuredKnowledgeTests.Run);
Run("KnowledgeSetRendering", KnowledgeSetRenderingTests.Run);
Run("UserKnowledgeOverlay", UserKnowledgeOverlayTests.Run);
Run("PromptBuilders", PromptBuildersTests.Run);
Run("RevisionFactsAndSerialization", RevisionFactsAndSerializationTests.Run);
Run("UserReplayFactIndexRepro", UserReplayFactIndexReproTests.Run);
Console.WriteLine();
Console.WriteLine($"总计: {_passed} 通过, {_failed} 失败");
return _failed == 0 ? 0 : 1;
}
private static void Run(string name, Action action)
{
var before = _passed + _failed;
try
{
action();
Console.WriteLine($"[OK] {name}{_passed + _failed - before} 项)");
}
catch (Exception ex)
{
_failed++;
Console.WriteLine($"[FAIL] {name}: {ex.Message}");
}
}
public static void Assert(bool condition, string message)
{
if (condition)
{
_passed++;
}
else
{
_failed++;
Console.WriteLine($" [断言失败] {message}");
throw new Exception($"断言失败: {message}");
}
}
public static void AssertEqual<T>(T expected, T actual, string message)
{
if (EqualityComparer<T>.Default.Equals(expected, actual))
{
_passed++;
}
else
{
_failed++;
Console.WriteLine($" [断言失败] {message}: 期望 {expected},实际 {actual}");
throw new Exception($"断言失败: {message}");
}
}
}
internal static class AiTimeParserTests
{
public static void Run()
{
Program.AssertEqual(true, AiTimeParser.TryParse("1:24.00", out var t1) && t1 == TimeSpan.FromSeconds(84), "1:24.00");
Program.AssertEqual(true, AiTimeParser.TryParse("0:01.06", out var t2) && t2 > TimeSpan.FromSeconds(1) && t2 < TimeSpan.FromSeconds(1.1), "0:01.06");
Program.AssertEqual(true, AiTimeParser.TryParse("17:23", out var t3) && t3 == TimeSpan.FromSeconds(1043), "17:23");
Program.AssertEqual(false, AiTimeParser.TryParse("abc", out _), "非法输入");
Program.AssertEqual(false, AiTimeParser.TryParse("-1:20", out _), "负数");
Program.AssertEqual(false, AiTimeParser.TryParse("", out _), "空输入");
}
}
internal static class MechanicalSegmenterTests
{
public static void Run()
{
// 10 个 span,每个 1000 token,预算 3000,重叠 500
var spans = ImmutableArray.CreateBuilder<EventSpan>();
var sb = new StringBuilder();
for (var i = 0; i < 10; ++i)
{
var text = $"[{i}:00] 事件 {i}\n\n";
spans.Add(new EventSpan(TimeSpan.FromMinutes(i), sb.Length, text.Length, 1000));
sb.Append(text);
}
var fullText = sb.ToString();
var (slices, warnings) = MechanicalSegmenter.Slice(fullText, spans.ToImmutable(), 3000, 1500);
Program.Assert(!slices.IsEmpty, "不应为空");
Program.AssertEqual(5, slices.Length, "10k/3k(重叠 1.5k)→ 5 段");
Program.Assert(warnings.IsEmpty, "无警告");
foreach (var slice in slices)
{
Program.Assert(slice.GetText(fullText).Length == slice.Length, "切片文本长度一致");
}
// 重叠:第 2 段起点应早于第 1 段终点
Program.Assert(slices[1].StartIndex < slices[0].StartIndex + slices[0].Length, "存在重叠");
// 空输入
var (emptySlices, _) = MechanicalSegmenter.Slice("", ImmutableArray<EventSpan>.Empty, 3000, 500);
Program.Assert(emptySlices.IsEmpty, "空输入返回空");
// 小预算 → 至少 MinSliceTokens 才切
var (minSlices, _) = MechanicalSegmenter.Slice(fullText, spans.ToImmutable(), 100, 50);
Program.Assert(!minSlices.IsEmpty, "小预算仍应有切片");
// 超过 20 段时优先压缩纯选择块
var mixedBuilder = ImmutableArray.CreateBuilder<EventSpan>();
var mixedTextBuilder = new StringBuilder();
for (var i = 0; i < 30; ++i)
{
var text = $"[{i}:00]\nPlayerA: 选择单位\n [UnitId]{i}\n\n";
mixedBuilder.Add(new EventSpan(TimeSpan.FromMinutes(i), mixedTextBuilder.Length, text.Length, 1000));
mixedTextBuilder.Append(text);
}
for (var i = 30; i < 60; ++i)
{
var text = $"[{i}:00]\nPlayerA: 开始建造\n [UnitId]{i}\n\n";
mixedBuilder.Add(new EventSpan(TimeSpan.FromMinutes(i), mixedTextBuilder.Length, text.Length, 1000));
mixedTextBuilder.Append(text);
}
var mixedText = mixedTextBuilder.ToString();
var (mixedSlices, mixedWarnings) = MechanicalSegmenter.Slice(
mixedText, mixedBuilder.ToImmutable(), 2000, 400);
Program.Assert(mixedSlices.Length <= MechanicalSegmenter.MaxSlices, "压缩后分段数不超过上限");
Program.Assert(mixedWarnings.Any(w => w.Contains("过滤纯选择")), "超过上限时先压缩噪声块");
}
}
internal static class FocusPlannerTests
{
public static void Run()
{
var sb = new StringBuilder();
var spans = ImmutableArray.CreateBuilder<EventSpan>();
for (var i = 0; i < 10; ++i)
{
var text = $"[{i}:00] 事件 {i}\n\n";
spans.Add(new EventSpan(TimeSpan.FromMinutes(i), sb.Length, text.Length, 5000));
sb.Append(text);
}
var fullText = sb.ToString();
var all = spans.ToImmutable();
// 整段 10 个 span5K/span = 50K,窗口 12K → 应切分为 5 个窗口(每个 ~12K)
var slice = new ReplaySlice(0, TimeSpan.Zero, TimeSpan.FromMinutes(9), 0, fullText.Length, 10, 50000);
var windows = FocusPlanner.Plan(slice, all);
Program.Assert(!windows.IsEmpty, "不应为空");
Program.AssertEqual(5, windows.Length, "50K/12K → 5 窗口");
Program.AssertEqual(TimeSpan.Zero, windows[0].Start, "第一个窗口起点");
Program.Assert(windows[0].End <= windows[1].Start, "窗口时间顺序(允许跨度间隔)");
Program.Assert(windows[windows.Length - 1].End <= slice.End, "最后一个窗口不越界");
Program.Assert(windows.All(w => w.EventCount > 0), "窗口均有事件");
// 小切片(< MinSliceForSplitTokens)→ 单窗口
var smallSlice = new ReplaySlice(
0, TimeSpan.Zero, TimeSpan.FromMinutes(1),
0, all[1].StartIndex + all[1].Length, 2, 10000);
var smallWindows = FocusPlanner.Plan(smallSlice, all);
Program.AssertEqual(1, smallWindows.Length, "小切片为单窗口");
// 空/无事件 → 空
Program.Assert(FocusPlanner.Plan(new ReplaySlice(0, TimeSpan.Zero, TimeSpan.Zero, 0, 0, 0, 0), all).IsEmpty, "无事件返回空");
// 窗口合并:超过 MaxWindowsPerSlice 时合并(50K 每段约 5K × 30 = 150K → 12K 窗口 13 个 → 合并到 5)
var manySb = new StringBuilder();
var manySpans = ImmutableArray.CreateBuilder<EventSpan>();
for (var i = 0; i < 30; ++i)
{
var text = $"[{i}:00] 事件 {i}\n\n";
manySpans.Add(new EventSpan(TimeSpan.FromMinutes(i), manySb.Length, text.Length, 5000));
manySb.Append(text);
}
var manyAll = manySpans.ToImmutable();
var manySlice = new ReplaySlice(
0, TimeSpan.Zero, TimeSpan.FromMinutes(29),
0, manySb.Length, 30, 150000);
var manyWindows = FocusPlanner.Plan(manySlice, manyAll);
Program.Assert(manyWindows.Length <= FocusPlanner.MaxWindowsPerSlice, "窗口数不超过上限");
}
}
internal static class OverviewParserTests
{
public static void Run()
{
var response = "整局走势:开局平稳,中期发生基地迁移。\n\n[分段概述]\n#1 开局:双方正常发育\n#2 中期:基地迁移\n回查: 1:20~1:45\n";
var overview = OverviewParser.Parse(response);
Program.Assert(overview.Narrative.Contains("基地迁移"), "叙述文本");
Program.AssertEqual(2, overview.Segments.Length, "两段");
Program.AssertEqual("开局", overview.Segments[0].Title, "第 1 段标题");
Program.AssertEqual("双方正常发育", overview.Segments[0].Description, "第 1 段概述");
Program.AssertEqual("中期", overview.Segments[1].Title, "第 2 段标题");
Program.AssertEqual("基地迁移", overview.Segments[1].Description, "第 2 段概述");
Program.AssertEqual(1, overview.Segments[1].BackqueryHints.Length, "回查提示");
Program.Assert(overview.Segments[1].BackqueryHints[0].Contains("1:20"), "回查内容");
var noMarker = OverviewParser.Parse("只有叙述");
Program.Assert(noMarker.Segments.IsEmpty, "无标记 → 空段列表");
Program.AssertEqual("只有叙述", noMarker.Narrative, "无标记 → 全为叙述");
}
}
internal static class BackqueryParserTests
{
public static void Run()
{
var response = "正文内容\n[回查] 1:20~1:45\n[回查] 5:00~6:10.50\n回查: 0:10~0:20\n无关行\n[回查] 非法区间\n";
var ranges = BackqueryParser.Parse(response);
Program.AssertEqual(3, ranges.Length, "三个有效回查");
Program.Assert(ranges[0].Start == TimeSpan.FromSeconds(80) && ranges[0].End == TimeSpan.FromSeconds(105), "1:20~1:45");
Program.Assert(ranges[2].Start == TimeSpan.FromSeconds(10), "0:10~0:20");
}
}
internal static class StructuredEvidenceTests
{
public static void Run()
{
var power = AIAnalysisValidation.ParseStructuredEvidence("power|1:24.00|SpecialPower_PackReplaceSelf|246");
Program.AssertEqual(AIAnalysisValidation.AIEvidenceType.Power, power.Type, "power 类型");
Program.AssertEqual("SpecialPower_PackReplaceSelf", power.GetSpecialPowerName(), "技能名");
Program.AssertEqual("246", power.GetUnitId(), "UnitId");
var move = AIAnalysisValidation.ParseStructuredEvidence("move|1:24.26|2026,2800,280");
Program.AssertEqual(AIAnalysisValidation.AIEvidenceType.Move, move.Type, "move 类型");
Program.AssertEqual(null, move.GetUnitId(), "move 无 UnitId");
var build = AIAnalysisValidation.ParseStructuredEvidence("build|0:01.26|AlliedBarracks|246");
Program.AssertEqual("AlliedBarracks", build.GetAssetName(), "建筑名");
Program.AssertEqual("246", build.GetUnitId(), "建造者");
var unknown = AIAnalysisValidation.ParseStructuredEvidence("自然语言描述");
Program.AssertEqual(AIAnalysisValidation.AIEvidenceType.Unknown, unknown.Type, "未知类型");
}
}
internal static class BackquerySliceExtractorTests
{
public static void Run()
{
var sb = new StringBuilder();
var spans = ImmutableArray.CreateBuilder<EventSpan>();
for (var i = 0; i < 5; ++i)
{
var text = $"[{i}:00] 事件 {i}\n\n";
spans.Add(new EventSpan(TimeSpan.FromMinutes(i), sb.Length, text.Length, 10));
sb.Append(text);
}
var fullText = sb.ToString();
var all = spans.ToImmutable();
var (middle, reason1) = BackquerySliceExtractor.Extract(
fullText, all, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2));
Program.Assert(middle is not null && reason1 is null, "中间区间可提取");
Program.Assert(middle!.Contains("事件 1") && middle.Contains("事件 2"), "区间内容");
Program.Assert(!middle.Contains("事件 0") && !middle.Contains("事件 3"), "不含区间外内容");
var (empty, reason2) = BackquerySliceExtractor.Extract(
fullText, all, TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(11));
Program.Assert(empty is null && reason2 is not null, "空区间返回原因");
var (noSpans, reason3) = BackquerySliceExtractor.Extract(fullText, ImmutableArray<EventSpan>.Empty,
TimeSpan.Zero, TimeSpan.FromMinutes(1));
Program.Assert(noSpans is null && reason3 is not null, "无索引返回原因");
}
}
internal static class MachineReadableClaimsTests
{
public static void Run()
{
var json = @"[机器可读声明]
```json
{
""unitClaims"": [
{ ""unitId"": 587, ""player"": ""PlayerA"", ""claim"": ""AlliedMCV"", ""evidenceLevel"": ""possible"",
""evidence"": [""power|1:41.00|SpecialPower_UnpackReplaceSelf|587""],
""alternatives"": [""AlliedMiner 指挥中心""], ""needsConfirmation"": [""后续是否作为建造者出现""] }
],
""eventClaims"": [
{ ""event"": ""PlayerA 基地迁移"", ""evidenceLevel"": ""confirmed"", ""evidence"": [""power|1:24.00|SpecialPower_PackReplaceSelf|246""] }
],
""timelineClaims"": [
{ ""claim"": ""2 分钟内完成迁移"", ""evidenceLevel"": ""confirmed"", ""evidence"": [] }
]
}
```";
var result = AIAnalysisValidation.ValidateMachineReadableClaims(json);
Program.AssertEqual(1, result.Claims.UnitClaims.Length, "unitClaims 数量");
Program.AssertEqual("587", result.Claims.UnitClaims[0].UnitId, "unitId 数字转字符串");
Program.AssertEqual(AIEvidenceLevel.Possible, result.Claims.UnitClaims[0].EvidenceLevel, "证据等级");
Program.AssertEqual(1, result.Claims.EventClaims.Length, "eventClaims 数量(event 字段回退)");
Program.AssertEqual("PlayerA 基地迁移", result.Claims.EventClaims[0].Claim, "event 回退");
Program.AssertEqual(1, result.Claims.TimelineClaims.Length, "timelineClaims 数量");
// 未知证据等级 → Info issue + Uncertain
var badLevel = AIAnalysisValidation.ValidateMachineReadableClaims(
"[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"X\",\"evidenceLevel\":\"很确定\",\"evidence\":[]}]}\n```");
Program.Assert(badLevel.Issues.Any(i => i.Kind == AIValidationIssueKind.InvalidEvidenceLevel), "未知证据等级产生 Info");
Program.AssertEqual(AIEvidenceLevel.Uncertain, badLevel.Claims.UnitClaims[0].EvidenceLevel, "降级为不确定");
// 无 JSON → Warning
var noJson = AIAnalysisValidation.ValidateMachineReadableClaims("纯文本回复");
Program.Assert(noJson.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingMachineReadableClaims), "无声明产生 Warning");
Program.Assert(noJson.RequiresRevision, "无声明现在会触发一次修订");
// 高置信无证据 → MissingEvidence
var highNoEvidence = AIAnalysisValidation.ValidateMachineReadableClaims(
"[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}]}\n```");
Program.Assert(highNoEvidence.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingEvidence), "高置信无证据");
Program.Assert(highNoEvidence.RequiresRevision, "高置信无证据会触发修订");
// 低置信无备选/待确认 → MissingAlternative
var lowNoAlt = AIAnalysisValidation.ValidateMachineReadableClaims(
"[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"possible\",\"evidence\":[\"select|1:00|1\"]}]}\n```");
Program.Assert(lowNoAlt.Issues.Any(i => i.Kind == AIValidationIssueKind.MissingAlternative), "低置信无备选");
Program.Assert(lowNoAlt.RequiresRevision, "低置信无备选会触发修订");
// 仅 Info(例如声明数量超限)不触发修订
var infoOnlyJson = "[机器可读声明]\n```json\n{\"eventClaims\":["
+ string.Join(",", Enumerable.Range(1, 6).Select(i =>
$"{{\"claim\":\"事件{i}\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}}"))
+ "]}\n```";
var infoOnly = AIAnalysisValidation.ValidateMachineReadableClaims(infoOnlyJson);
Program.Assert(!infoOnly.RequiresRevision, "仅 Info 不触发修订");
// 全部 JSON 块都不可解析 → Fatal
var allInvalid = AIAnalysisValidation.ValidateMachineReadableClaims(
"[机器可读声明]\n```json\n{\"bad\": }\n```");
Program.Assert(allInvalid.Issues.Any(i => i.Severity == AIValidationSeverity.Fatal),
"全无效 JSON 产生 Fatal");
Program.Assert(allInvalid.RequiresRevision, "Fatal 触发修订");
}
}
internal static class ClaimFindingsFormatterTests
{
public static void Run()
{
var claims = new AIMachineReadableClaims(
ImmutableArray.Create(new AIUnitClaim("587", "PlayerA", "AlliedMCV", AIEvidenceLevel.Possible,
ImmutableArray.Create("power|1:41.00|SpecialPower_UnpackReplaceSelf|587"),
ImmutableArray.Create("AlliedMiner 指挥中心"),
ImmutableArray<string>.Empty)),
ImmutableArray<AIEventClaim>.Empty,
ImmutableArray<AITimelineClaim>.Empty);
var text = ClaimFindingsFormatter.Format(claims);
Program.Assert(text.Contains("587"), "包含 UnitId");
Program.Assert(text.Contains("AlliedMCV"), "包含推测");
Program.Assert(text.Contains("AlliedMiner"), "包含备选");
var summary = ClaimFindingsFormatter.ExtractSummary("正文\n[小结] 基地迁移完成。");
Program.AssertEqual("基地迁移完成。", summary, "小结提取");
Program.AssertEqual(null, ClaimFindingsFormatter.ExtractSummary("无小结"), "无小结返回 null");
}
}
internal static class AiSettingsPersistenceTests
{
public static void Run()
{
// 基础往返:SetCurrentSelection 后 ResolveLastSelection 命中
var provider = new AiProvider
{
Name = "测试服务",
Models = [new AiModel { ModelId = "model-a" }]
};
var model = provider.Models[0];
var settings = new AiSettings { Providers = [provider] };
settings.SetCurrentSelection(provider, model);
var resolved = settings.ResolveLastSelection();
Program.Assert(resolved is { } r && ReferenceEquals(r.Provider, provider) && ReferenceEquals(r.Model, model),
"选择往返命中");
// 空标识 → null
var empty = new AiSettings { Providers = [provider] };
Program.Assert(empty.ResolveLastSelection() is null, "空选择返回 null");
// Provider 名称大小写不敏感
var caseProvider = new AiProvider { Name = "MiXeD", Models = [new AiModel { ModelId = "Model.B" }] };
var caseSettings = new AiSettings
{
Providers = [caseProvider],
CurrentProviderName = "mixed",
CurrentModelId = "model.b"
};
Program.Assert(caseSettings.ResolveLastSelection() is { } cr
&& ReferenceEquals(cr.Provider, caseProvider)
&& ReferenceEquals(cr.Model, caseProvider.Models[0]), "大小写不敏感");
// 失效:Provider 被删除 → null
var deletedProvider = new AiSettings
{
Providers = [new AiProvider { Name = "新服务", Models = [new AiModel { ModelId = "m1" }] }],
CurrentProviderName = "旧服务",
CurrentModelId = "m1"
};
Program.Assert(deletedProvider.ResolveLastSelection() is null, "Provider 被删除 → null");
// 失效:模型被删除 → null
var deletedModel = new AiSettings
{
Providers = [new AiProvider { Name = "服务", Models = [new AiModel { ModelId = "m1" }] }],
CurrentProviderName = "服务",
CurrentModelId = "m2"
};
Program.Assert(deletedModel.ResolveLastSelection() is null, "模型被删除 → null");
// Provider 被重命名:名称不再匹配,但模型仍存在 → 也回退(名称即标识的代价)
var renamed = new AiSettings
{
Providers = [new AiProvider { Name = "服务2", Models = [new AiModel { ModelId = "m1" }] }],
CurrentProviderName = "服务1",
CurrentModelId = "m1"
};
Program.Assert(renamed.ResolveLastSelection() is null, "Provider 重命名 → null(用第一个回退)");
}
}
internal static class AiContextBudgetTests
{
public static void Run()
{
Program.AssertEqual(160_000, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 1_000_000 }), "1M 档");
Program.AssertEqual(100_000, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 256_000 }), "256K 档");
Program.AssertEqual(100_000, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 200_000 }), "200K 档");
Program.AssertEqual(0, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 128_000 }), "128K 不支持长录像");
Program.AssertEqual(50_000, AiContextBudget.GetContextBudget(new AiModel { ContextLength = 128_000, ContextBudget = 50_000 }), "显式覆盖");
var provider = new AiProvider { DefaultMaxTokens = 16384 };
var block = AiContextBudget.CheckPromptUsage(200_000, provider, new AiModel { ContextLength = 200_000 });
Program.Assert(block.Block, "超过 90% 硬上限 → 拒绝");
var warn = AiContextBudget.CheckPromptUsage(200_000, provider, new AiModel { ContextLength = 1_000_000 });
Program.Assert(!warn.Block && !warn.IsOk, "超过预算 → 警告");
var ok = AiContextBudget.CheckPromptUsage(20_000, provider, new AiModel { ContextLength = 1_000_000 });
Program.Assert(ok.IsOk, "预算内 → 通过");
// 含输出余量的请求检查:输入 170K + 余量 32K 超过 200K 模型的 90% 硬上限
var blockTotal = AiContextBudget.CheckRequestUsage(170_000, provider, new AiModel { ContextLength = 200_000 });
Program.Assert(blockTotal.Block, "输入+输出余量超过硬上限 → 拒绝");
var warnTotal = AiContextBudget.CheckRequestUsage(160_000, provider, new AiModel { ContextLength = 1_000_000 });
Program.Assert(!warnTotal.Block && !warnTotal.IsOk, "输入+输出余量超过预算 → 警告");
var okTotal = AiContextBudget.CheckRequestUsage(20_000, provider, new AiModel { ContextLength = 1_000_000 });
Program.Assert(okTotal.IsOk, "输入+输出余量在预算内 → 通过");
}
}
internal static class MatchDigestBuilderTests
{
public static void Run()
{
var player = new Player(new[] { "PTest", "0", "", "", "", "4", "", "-1" });
var players = ImmutableSortedDictionary<int, Player>.Empty.Add(4, player);
var factIndex = TestData.BuildIndex(
ImmutableDictionary<uint, TimeSpan>.Empty.Add(1, TimeSpan.FromSeconds(80)),
ImmutableDictionary<uint, ImmutableHashSet<string>>.Empty.Add(
1, ImmutableHashSet.Create("SpecialPower_PackReplaceSelf")),
ImmutableArray.Create(
new SpecialPowerEvent(
TimeSpan.FromSeconds(80),
4,
1,
"SpecialPower_PackReplaceSelf")),
ImmutableHashSet<uint>.Empty.Add(1),
ImmutableHashSet<uint>.Empty,
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>.Empty.Add(
4, ImmutableDictionary<string, TimeSpan>.Empty.Add("AlliedMCV", TimeSpan.FromSeconds(100))),
ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty.Add(
4, ImmutableHashSet.Create<uint>(1)),
ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
ImmutableDictionary<int, ImmutableHashSet<string>>.Empty);
const string fullText = "[0:00]\n玩家 A,开始建造建筑\n [UnitId]1(建造者)\n AlliedBarracks\n\n[4:00]\n玩家 A,释放特殊能力\n SpecialPower_PackReplaceSelf\n [UnitId]1\n\n";
var slices = ImmutableArray.Create(
new ReplaySlice(0, TimeSpan.Zero, TimeSpan.FromMinutes(5), 0, fullText.Length, 4, 500));
var digest = MatchDigestBuilder.Build(factIndex, players, new Mod("RA3"), slices, fullText);
Program.Assert(digest.Contains("# 玩家"), "玩家段");
Program.Assert(digest.Contains("Test"), "玩家名");
Program.Assert(digest.Contains("盟军"), "阵营名");
Program.Assert(digest.Contains("无队伍"), "无队伍格式");
Program.Assert(digest.Contains("AlliedMCV@1:40"), "首次出兵时间");
Program.Assert(digest.Contains("Pack@1:20.00"), "打包事件真实时间");
Program.Assert(digest.Contains("建造者"), "建造者段");
Program.Assert(digest.Contains("第1段"), "分段元数据");
Program.Assert(digest.Contains("开始建造建筑"), "关键事件采样");
Program.Assert(digest.Contains("# 协议选择"), "摘要包含协议选择");
Program.Assert(digest.Contains("# 所有权证据(节选)"), "摘要包含所有权证据");
// 无队伍/解说员格式
var observer = new Player(new[] { "PObserver", "0", "", "", "", "3", "", "-1" });
var observerPlayers = ImmutableSortedDictionary<int, Player>.Empty
.Add(2, observer)
.Add(4, player);
var observerDigest = MatchDigestBuilder.Build(
factIndex, observerPlayers, new Mod("RA3"), slices, fullText);
Program.Assert(
observerDigest.Contains("解说员(观战),不参与对局"),
"解说员不参与对局");
Program.Assert(observerDigest.Contains("无队伍"), "无队伍格式");
Program.Assert(!observerDigest.Contains("自由对战/FFA"), "不引入 FFA 说明");
// 空表明确输出(无)
var emptyIndex = TestData.BuildIndex();
var emptyDigest = MatchDigestBuilder.Build(
emptyIndex, players, new Mod("RA3"), slices, fullText);
Program.Assert(emptyDigest.Contains("- (无)"), "首次出兵/协议空表输出(无)");
}
}
internal static class JsonBlockMergingTests
{
public static void Run()
{
var response = "[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"旧推测\",\"evidenceLevel\":\"possible\",\"evidence\":[],\"alternatives\":[],\"needsConfirmation\":[]}]}\n```\n"
+ "```json\n{\"unitClaims\":[{\"unitId\":1,\"claim\":\"新推测\",\"evidenceLevel\":\"possible\",\"evidence\":[],\"alternatives\":[],\"needsConfirmation\":[]}],\"eventClaims\":[{\"claim\":\"事件A\",\"evidenceLevel\":\"confirmed\",\"evidence\":[]}]}\n```";
var result = AIAnalysisValidation.ValidateMachineReadableClaims(response);
Program.AssertEqual(1, result.Claims.UnitClaims.Length, "合并后 unitClaims 数量");
Program.AssertEqual("新推测", result.Claims.UnitClaims[0].Claim, "重复 unitId 取后块");
Program.AssertEqual(1, result.Claims.EventClaims.Length, "合并后 eventClaims");
}
}
internal static class OwnershipAndTimelineValidationTests
{
private static readonly IReadOnlyDictionary<string, int> Mapping =
new Dictionary<string, int> { { "PlayerA", 4 }, { "PlayerB", 5 } };
public static void Run()
{
var index = TestData.BuildIndex(
firstObserved: ImmutableDictionary<uint, TimeSpan>.Empty
.Add(1, TimeSpan.FromSeconds(60))
.Add(2, TimeSpan.FromSeconds(90))
.Add(3, TimeSpan.FromSeconds(120))
.Add(7, TimeSpan.FromSeconds(60)),
strong: ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty
.Add(4, ImmutableHashSet.Create<uint>(1)),
powers: ImmutableDictionary<uint, ImmutableHashSet<string>>.Empty
.Add(7, ImmutableHashSet.Create("SpecialPower_PackReplaceSelf")),
weak: ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty
.Add(5, ImmutableHashSet.Create<uint>(1, 3)),
productions: ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>.Empty
.Add(4, ImmutableDictionary<string, TimeSpan>.Empty
.Add("AlliedBomberAircraft", TimeSpan.FromSeconds(300))),
tech: ImmutableDictionary<int, ImmutableHashSet<string>>.Empty
.Add(4, ImmutableHashSet.Create("PlayerTech_Allied_AirPower")));
// 规则 2:PlayerA 有强证据(1),声称属于 PlayerB → Contradiction
var wrongOwner = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerB\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"select|0:01.00|1\"]}]}\n```", index);
Program.Assert(wrongOwner.Issues.Any(i => i.Kind == AIValidationIssueKind.OwnershipConflict
&& i.Severity == AIValidationSeverity.Contradiction), "他人强证据 → Contradiction");
// 规则 1 + 3:声称属于 PlayerA,但 A 无证据、B 仅弱证据 → WeakEvidence + Warning
var weakOnly = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":3,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"select|0:02.00|3\"]}]}\n```", index);
Program.Assert(weakOnly.Issues.Any(i => i.Kind == AIValidationIssueKind.OwnershipMissingEvidence), "无己方证据 → WeakEvidence");
Program.Assert(weakOnly.Issues.Any(i => i.Kind == AIValidationIssueKind.OwnershipWeakConflict
&& i.Severity == AIValidationSeverity.Warning), "他人弱证据 → Warning");
// 正确归属:PlayerA 强证据 → 无所有权问题
var correctOwner = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_UnpackReplaceSelf|1\"]}]}\n```", index);
Program.Assert(!correctOwner.Issues.Any(i => i.Kind == AIValidationIssueKind.OwnershipConflict
|| i.Kind == AIValidationIssueKind.OwnershipMissingEvidence), "正确归属无所有权问题");
// 协议校验:未选择过的协议 → Contradiction
var wrongTech = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"possible\",\"evidence\":[\"protocol|0:02.00|PlayerTech_Allied_Superiority\"]}]}\n```", index);
Program.Assert(wrongTech.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitCapabilityContradiction), "未选协议 → Contradiction");
// move-only 高置信 → WeakEvidence
var moveOnly = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"move|0:01.00|100,200,300\"]}]}\n```", index);
Program.Assert(moveOnly.Issues.Any(i => i.Kind == AIValidationIssueKind.WeakEvidence
&& i.Message.Contains("move")), "move-only 高置信 → WeakEvidence");
// 首次出兵时间线:轰炸机首次出现早于首次生产 → Contradiction
var earlyBomber = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":7,\"player\":\"PlayerA\",\"claim\":\"AlliedBomberAircraft 轰炸机\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"select|0:01.00|7\"]}]}\n```", index);
Program.Assert(earlyBomber.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitTimelineContradiction
&& i.Severity == AIValidationSeverity.Contradiction), "早于首次生产 → Contradiction");
// 特殊能力验证:单位从未观察到任何能力 → WeakEvidence
var wrongPower = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":1,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_Nonexistent|1\"]}]}\n```", index);
Program.Assert(wrongPower.Issues.Any(i => i.Kind == AIValidationIssueKind.WeakEvidence
&& i.Message.Contains("SpecialPower_Nonexistent")), "未观察到任何能力 → WeakEvidence");
// 特殊能力验证:记录过其他能力但未用过该能力 → Contradiction
var conflictingPower = Validate("[机器可读声明]\n```json\n{\"unitClaims\":[{\"unitId\":7,\"player\":\"PlayerA\",\"claim\":\"AlliedMCV\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_UnpackReplaceSelf|7\"]}]}\n```", index);
Program.Assert(conflictingPower.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitCapabilityContradiction
&& i.Message.Contains("SpecialPower_UnpackReplaceSelf")), "记录过其他能力 → Contradiction");
// 事件声明:引用不存在的 UnitId → Warning
var badEventUnit = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"某事件\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_Nonexistent|999\"]}]}\n```", index);
Program.Assert(badEventUnit.Issues.Any(i => i.Kind == AIValidationIssueKind.InvalidMachineReadableClaims
&& i.Severity == AIValidationSeverity.Warning
&& i.Message.Contains("999")), "事件声明引用不存在 UnitId → Warning");
// 事件声明:技能与事实索引冲突 → Contradiction
var badEventPower = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"基地迁移\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"power|0:01.00|SpecialPower_UnpackReplaceSelf|7\"]}]}\n```", index);
Program.Assert(badEventPower.Issues.Any(i => i.Kind == AIValidationIssueKind.UnitCapabilityContradiction
&& i.Severity == AIValidationSeverity.Contradiction), "事件声明技能冲突 → Contradiction");
// 事件声明:协议未在任何玩家选择中观察到 → Warning
var badEventTech = Validate("[机器可读声明]\n```json\n{\"eventClaims\":[{\"claim\":\"协议事件\",\"evidenceLevel\":\"confirmed\",\"evidence\":[\"protocol|0:02.00|PlayerTech_Unknown\"]}]}\n```", index);
Program.Assert(badEventTech.Issues.Any(i => i.Kind == AIValidationIssueKind.InvalidMachineReadableClaims
&& i.Severity == AIValidationSeverity.Warning
&& i.Message.Contains("PlayerTech_Unknown")), "事件声明未观察协议 → Warning");
}
private static AIValidationResult Validate(string response, ReplayFactIndex index) =>
AIAnalysisValidation.ValidateMachineReadableClaims(response, index, Mapping);
}
internal static class TestData
{
public static ReplayFactIndex BuildIndex(
ImmutableDictionary<uint, TimeSpan>? firstObserved = null,
ImmutableDictionary<uint, ImmutableHashSet<string>>? powers = null,
ImmutableArray<SpecialPowerEvent>? specialPowerEvents = null,
ImmutableHashSet<uint>? builders = null,
ImmutableHashSet<uint>? producers = null,
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>? productions = null,
ImmutableDictionary<int, ImmutableHashSet<uint>>? selected = null,
ImmutableDictionary<int, ImmutableHashSet<uint>>? strong = null,
ImmutableDictionary<int, ImmutableHashSet<uint>>? weak = null,
ImmutableDictionary<int, ImmutableHashSet<string>>? tech = null) =>
new ReplayFactIndex(
firstObserved ?? ImmutableDictionary<uint, TimeSpan>.Empty,
powers ?? ImmutableDictionary<uint, ImmutableHashSet<string>>.Empty,
specialPowerEvents ?? ImmutableArray<SpecialPowerEvent>.Empty,
builders ?? ImmutableHashSet<uint>.Empty,
producers ?? ImmutableHashSet<uint>.Empty,
productions ?? ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>>.Empty,
selected ?? ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
strong ?? ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
weak ?? ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty,
tech ?? ImmutableDictionary<int, ImmutableHashSet<string>>.Empty);
}
internal static class FactIndexBuildTests
{
private static CommandChunk MakeChunk(int commandId, int playerIndex, params CommandArgumentEntry[] data)
{
var chunk = new CommandChunk();
typeof(CommandChunk).GetProperty(nameof(CommandChunk.CommandId))!.SetValue(chunk, commandId);
typeof(CommandChunk).GetProperty(nameof(CommandChunk.PlayerIndex))!.SetValue(chunk, playerIndex);
typeof(CommandChunk).GetProperty(nameof(CommandChunk.Data))!.SetValue(chunk, data.ToImmutableArray());
return chunk;
}
private static CommandArgumentEntry Obj(uint id) =>
new(CommandArgumentType.ObjectId, id, 1);
private static CommandArgumentEntry Int(int value) =>
new(CommandArgumentType.Int32, value, 1);
private static CommandArgumentEntry Str(string value) =>
new(CommandArgumentType.AsciiString, value, 1);
public static void Run()
{
const uint powerHash = 0x1234u;
var timeline = ImmutableArray.Create(
(TimeSpan.FromSeconds(1), ImmutableArray.Create(
MakeChunk(0x1F5, 4,
new CommandArgumentEntry(CommandArgumentType.Bool, new[] { true }, 1),
new CommandArgumentEntry(CommandArgumentType.Bool, new[] { false }, 1),
Obj(239)))), // 选择 → 弱所有权
(TimeSpan.FromSeconds(2), ImmutableArray.Create(
MakeChunk(0x1FA, 4, Int(3), Obj(389)), // 创建编队 → 强所有权
MakeChunk(0x24E, 4, Str("PlayerTech_Allied_AirPower")))), // 协议
(TimeSpan.FromSeconds(3), ImmutableArray.Create(
MakeChunk(0x1FB, 4, Int(3)))), // 选择编队 → 解析成员 389
(TimeSpan.FromSeconds(4), ImmutableArray.Create(
MakeChunk(0x200, 4, Int(unchecked((int)powerHash)),
new CommandArgumentEntry(CommandArgumentType.Vector3, new Vector3(1, 2, 3), 1),
new CommandArgumentEntry(CommandArgumentType.Float32, 1.0f, 1),
Obj(0), Int(0), Int(1), Obj(587)))), // 指定位置和角度:587 是施法者
(TimeSpan.FromSeconds(5), ImmutableArray.Create(
MakeChunk(0x201, 4, Int(unchecked((int)powerHash)),
Obj(323), Int(0), Int(1), Obj(322),
new CommandArgumentEntry(CommandArgumentType.Vector3, new Vector3(4, 5, 6), 1))))); // 指定目标:不记录为施法者
var stringHashes = new Dictionary<uint, string>
{
[powerHash] = "SpecialPower_UnpackReplaceSelf"
};
var index = ReplayFactIndex.Build(timeline, stringHashes);
// 弱所有权:选择
Program.Assert(index.PlayerWeakOwnershipUnitIds.TryGetValue(4, out var weak)
&& weak.Contains(239), "选择 → 弱所有权");
// 强所有权:编队创建 + 编队选择解析
Program.Assert(index.PlayerStrongOwnershipUnitIds.TryGetValue(4, out var strong)
&& strong.Contains(389) && strong.Contains(587), "编队/施法者 → 强所有权");
Program.Assert(!strong.Contains(239), "选择不属于强所有权");
Program.Assert(!strong.Contains(322), "目标不属于强所有权");
// 协议
Program.Assert(index.PlayerTechChoices.TryGetValue(4, out var tech)
&& tech.Contains("PlayerTech_Allied_AirPower"), "协议记录");
// 施法者归属:0x200 记录,0x201 不记录
Program.Assert(index.UnitIdSpecialPowers.TryGetValue(587, out var powers)
&& powers.Contains("SpecialPower_UnpackReplaceSelf"), "0x200 施法者记录");
Program.Assert(!index.UnitIdSpecialPowers.ContainsKey(322)
&& !index.UnitIdSpecialPowers.ContainsKey(323), "0x201 目标不记录为施法者");
// 首次出现
Program.Assert(index.UnitIdFirstObservedTime.ContainsKey(239)
&& index.UnitIdFirstObservedTime.ContainsKey(389)
&& index.UnitIdFirstObservedTime.ContainsKey(587)
&& index.UnitIdFirstObservedTime.ContainsKey(322), "首次出现记录");
// 编队按玩家隔离 + 0x1F6/0x22A 选择类命令
var secondTimeline = ImmutableArray.Create(
(TimeSpan.FromSeconds(1), ImmutableArray.Create(
MakeChunk(0x1FA, 4, Int(3), Obj(401)))),
(TimeSpan.FromSeconds(2), ImmutableArray.Create(
MakeChunk(0x1FB, 5, Int(3)))),
(TimeSpan.FromSeconds(3), ImmutableArray.Create(
MakeChunk(0x1F6, 4, Obj(777)),
MakeChunk(0x22A, 4, Obj(888)))));
var secondIndex = ReplayFactIndex.Build(secondTimeline, new Dictionary<uint, string>());
Program.Assert(secondIndex.PlayerStrongOwnershipUnitIds.TryGetValue(4, out var p4Strong)
&& p4Strong.Contains(401), "P4 创建编队 → 强证据");
Program.Assert(!secondIndex.PlayerStrongOwnershipUnitIds.TryGetValue(5, out _)
|| !secondIndex.PlayerStrongOwnershipUnitIds[5].Contains(401), "P5 选择同号编队不应继承 P4 成员");
Program.Assert(secondIndex.PlayerWeakOwnershipUnitIds.TryGetValue(4, out var p4Weak)
&& p4Weak.Contains(777) && p4Weak.Contains(888), "0x1F6/0x22A → 弱所有权");
// 真实布局兼容:0x205/0x24E 的名称可能以 Int32 hash 编码
var hashTable = new Dictionary<uint, string>
{
[0x1001u] = "AlliedMiner",
[0x2001u] = "PlayerTech_Allied_AirPower"
};
var hashTimeline = ImmutableArray.Create(
(TimeSpan.FromSeconds(10), ImmutableArray.Create(
MakeChunk(0x205, 4,
Obj(423),
Int(unchecked((int)0x1001)),
Int(0),
Int(3)))),
(TimeSpan.FromSeconds(12), ImmutableArray.Create(
MakeChunk(0x24E, 4, Int(unchecked((int)0x2001))))));
var hashIndex = ReplayFactIndex.Build(hashTimeline, hashTable);
Program.Assert(
hashIndex.PlayerFirstProductionTime.TryGetValue(4, out var productions)
&& productions.TryGetValue("AlliedMiner", out var productionTime)
&& productionTime == TimeSpan.FromSeconds(10),
"0x205 Int32 hash → 首次出兵时间表");
Program.Assert(
hashIndex.PlayerTechChoices.TryGetValue(4, out var techs)
&& techs.Contains("PlayerTech_Allied_AirPower"),
"0x24E Int32 hash → 协议选择");
// 特殊能力事件应保留真实发生时间,供摘要输出 Pack@/Unpack@
Program.Assert(
index.SpecialPowerEvents.Any(e =>
e.Time == TimeSpan.FromSeconds(4)
&& e.PlayerIndex == 4
&& e.UnitId == 587
&& e.PowerName == "SpecialPower_UnpackReplaceSelf"),
"SpecialPowerEvents 记录 0x200 事件时间");
}
}
internal static class StructuredKnowledgeTests
{
public static void Run()
{
var knowledge = StructuredKnowledge.GetForMod("default");
Program.Assert(knowledge is not null, "默认 mod 结构化知识可加载");
Program.Assert(knowledge!.AllEntities.Length > 20, "盟军实体数量");
Program.Assert(knowledge.UnknownTags.IsEmpty,
"无未知标签(实际: " + string.Join(",", knowledge.UnknownTags) + "");
Program.Assert(knowledge.GetEntity("AlliedMCV") is { } mcv
&& mcv.HasTag(KnowledgeTag.Builder)
&& mcv.HasTag(KnowledgeTag.Unpack), "AlliedMCV 标签");
Program.Assert(knowledge.GetEntity("AlliedAntiInfantryVehicle_Ground") is { } aliasEntity
&& aliasEntity.AssetName == "AlliedAntiInfantryVehicle", "别名可解析到同一实体");
Program.Assert(knowledge.GetEntity("AlliedAntiInfantryVehicle") is { } acv
&& acv.ProducedBy.Contains("AlliedWarFactory"), "alsoProducedBy 合并进 ProducedBy");
// 不存在的 mod → null(无结构化数据时不剥离 flat 文本)
Program.Assert(StructuredKnowledge.GetForMod("corona") is null, "corona 无结构化文件 → null");
}
}
internal static class KnowledgeSetRenderingTests
{
public static void Run()
{
var knowledge = KnowledgeSet.ForMod("default", AppContext.BaseDirectory);
var prompt = knowledge.RenderAsPrompt(new[] { "盟军" }, "map_mp_2_rao1");
Program.Assert(prompt.Contains("盟军"), "包含盟军知识");
Program.Assert(prompt.Contains("基地车"), "结构化渲染包含盟军单位");
Program.Assert(!prompt.Contains("神州常用建筑"), "未参战阵营(神州)被过滤");
Program.Assert(prompt.Contains("地图参数") || prompt.Contains("出生点"), "地图知识保留");
var corona = KnowledgeSet.ForMod("corona", AppContext.BaseDirectory);
var coronaPrompt = corona.RenderAsPrompt(new[] { "盟军" }, "map_mp_2_rao1");
Program.Assert(coronaPrompt.Contains("盟军"), "Corona 盟军知识保留");
Program.Assert(!coronaPrompt.Contains("神州常用建筑"), "Corona 未参战阵营(神州)被过滤");
}
}
internal static class UserKnowledgeOverlayTests
{
public static void Run()
{
var dir = AppContext.BaseDirectory;
var knowledgePath = Path.Combine(dir, "knowledge_units_testmod.json");
var userPath = Path.Combine(dir, "AnotherReplayReader.user_knowledge.json");
const string knowledgeJson = "{\"factions\":{\"盟军\":{\"units\":[{\"assetName\":\"TestUnit\",\"displayName\":\"内置单位\",\"tier\":\"基础\",\"tags\":[\"vehicle\"],\"specialPowers\":[],\"producedBy\":[],\"text\":\"内置描述\"}]}}}";
const string userJson = "{\"factions\":{\"盟军\":{\"units\":[{\"assetName\":\"TestUnit\",\"displayName\":\"用户覆盖\",\"tier\":\"基础\",\"tags\":[\"vehicle\"],\"specialPowers\":[],\"producedBy\":[],\"text\":\"用户描述\"},{\"assetName\":\"NewUnit\",\"displayName\":\"新增单位\",\"tier\":\"T2\",\"tags\":[\"infantry\"],\"specialPowers\":[],\"producedBy\":[],\"text\":\"新增\"}]}}}";
try
{
File.WriteAllText(knowledgePath, knowledgeJson, Encoding.UTF8);
File.WriteAllText(userPath, userJson, Encoding.UTF8);
var knowledge = StructuredKnowledge.GetForMod("testmod");
Program.Assert(knowledge is not null, "测试 mod 加载");
Program.Assert(knowledge!.GetEntity("TestUnit") is { } overridden
&& overridden.DisplayName == "用户覆盖"
&& overridden.Text == "用户描述", "用户覆盖内置条目");
Program.Assert(knowledge.GetEntity("NewUnit") is { } added
&& added.DisplayName == "新增单位", "用户新增条目");
}
finally
{
if (File.Exists(knowledgePath)) File.Delete(knowledgePath);
if (File.Exists(userPath)) File.Delete(userPath);
}
}
}
internal static class PromptBuildersTests
{
public static void Run()
{
var slice = new ReplaySlice(0, TimeSpan.Zero, TimeSpan.FromMinutes(5), 0, 100, 10, 500);
var overview = AIAnalyze.BuildOverviewUserPrompt(ImmutableArray.Create(slice));
Program.Assert(overview.Contains("[分段概述]"), "总览轮要求 [分段概述] 块");
Program.Assert(overview.Contains("不要修改"), "总览轮不修改边界");
Program.Assert(
overview.Contains("本阶段不会获取原始操作记录"),
"总览轮明确不获取原始记录");
Program.Assert(
overview.Contains("不要输出整局叙述"),
"总览轮不输出深度叙述");
var segment = AIAnalyze.BuildSegmentUserPromptV2(
0, 1, slice, 500, "开局", "前期平稳发育", new[] { "1:20~1:45" });
Program.Assert(segment.Contains("第1/1段"), "段指令编号");
Program.Assert(segment.Contains("开局"), "段标题");
Program.Assert(segment.Contains("前期平稳发育"), "段概述");
Program.Assert(segment.Contains("1:20~1:45"), "总览回查提示");
Program.Assert(segment.Contains("[回查]"), "回查说明");
Program.Assert(segment.Contains("[小结]"), "小结要求");
Program.Assert(segment.Contains("[机器可读声明]"), "机器可读声明要求");
// 焦点窗口指令:数据=整段,重点=时间段(时间段应优先于窗口编号)
var focusWindow = new FocusWindow(0, TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(2), 4, 300);
var focus = AIAnalyze.BuildFocusWindowUserPromptV2(
0, 1, slice, 0, 1, focusWindow, 500, 40, "开局", "前期平稳发育", new[] { "1:20~1:45" });
Program.Assert(focus.Contains("请重点分析 游戏开始 至 游戏结束 时间段的操作数据"), "单窗口=整段时间段作为重点");
Program.Assert(!focus.Contains("第1/1段"), "单窗口不再用段编号表达重点");
Program.Assert(focus.Contains("完整操作记录"), "数据提示包含整段记录");
Program.Assert(focus.Contains("40 条操作信息"), "窗口事件数");
Program.Assert(focus.Contains("跨时间"), "鼓励跨时间关联");
Program.Assert(focus.Contains("[回查]"), "回查说明");
Program.Assert(focus.Contains("[机器可读声明]"), "机器可读声明要求");
var multiFocus = AIAnalyze.BuildFocusWindowUserPromptV2(
0, 1, slice, 1, 3, focusWindow, 500, 40, "开局");
Program.Assert(multiFocus.Contains("请重点分析 0:30.00 至 2:00.00 时间段的操作数据"), "多窗口=时间段作为重点");
Program.Assert(multiFocus.Contains("当前是第 2 个"), "窗口编号降级为次要说明");
Program.Assert(multiFocus.Contains("后续时间段会依次分析"), "后续时间段说明");
var summary = AIAnalyze.BuildSummaryUserPromptV2(1000);
Program.Assert(summary.Contains("总结"), "总结指令");
var backquery = AIAnalyze.BuildBackqueryUserPrompt("切片内容");
Program.Assert(backquery.Contains("切片内容"), "回查指令携带切片");
var revision = AIAnalyze.BuildRevisionUserPrompt("草稿", "问题1", "事实1");
Program.Assert(revision.Contains("问题1") && revision.Contains("事实1") && revision.Contains("草稿"), "修订指令内容");
Program.Assert(revision.Contains("不要提及"), "修订不暴露修订过程");
}
}
internal static class RevisionFactsAndSerializationTests
{
public static void Run()
{
var index = TestData.BuildIndex(
firstObserved: ImmutableDictionary<uint, TimeSpan>.Empty.Add(1, TimeSpan.FromSeconds(60)),
strong: ImmutableDictionary<int, ImmutableHashSet<uint>>.Empty
.Add(4, ImmutableHashSet.Create<uint>(1)),
builders: ImmutableHashSet<uint>.Empty.Add(1));
var claims = new AIMachineReadableClaims(
ImmutableArray.Create(new AIUnitClaim("1", "PlayerA", "AlliedMCV",
AIEvidenceLevel.Confirmed,
ImmutableArray.Create("power|0:01.00|SpecialPower_PackReplaceSelf|1"),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty)),
ImmutableArray<AIEventClaim>.Empty,
ImmutableArray<AITimelineClaim>.Empty);
var facts = RelevantFactsFormatter.Format(claims, index);
Program.Assert(facts.Contains("首次出现 1:00"), "事实包含首次出现时间");
Program.Assert(facts.Contains("建造者"), "事实包含建造者");
Program.Assert(facts.Contains("强所有权"), "事实包含强所有权");
// ChatMessage 序列化为小写 role/content,兼容 OpenAI 兼容端点
var message = new AIAnalyze.ChatMessage("user", "内容");
var json = JsonSerializer.Serialize(message);
Program.Assert(json.Contains("\"role\":\"user\""), "role 小写");
Program.Assert(json.Contains("\"content\":"), "content 字段小写");
var deserialized = JsonSerializer.Deserialize<AIAnalyze.ChatMessage>(json);
Program.Assert(deserialized!.Role == "user" && deserialized.Content == "内容", "往返一致");
}
}
/// <summary>
/// AI 分析主工程实验代码已清理;本类只存在于测试工程中。
/// 用于直接构造 OpenAI 兼容请求,避免把实验字段带回主项目。
/// </summary>
internal class TestAIAnalyze
{
private readonly TestAiClient _client = new();
internal sealed class Result
{
public Result(string response, string? reasoning)
{
Response = response;
Reasoning = reasoning;
}
public string Response { get; }
public string? Reasoning { get; }
public int? PromptTokens { get; }
public int? CompletionTokens { get; }
public int? TotalTokens { get; }
public int? ReasoningTokens { get; }
}
internal sealed record ChatMessage(
[property: JsonPropertyName("role")] string Role,
[property: JsonPropertyName("content"),
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
[property: JsonPropertyName("reasoning_content"),
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null)
{
internal static ChatMessage Assistant(string? content, string? reasoningContent = null) =>
new("assistant",
string.IsNullOrEmpty(content) ? null : content,
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent);
}
public async Task<Result> CompleteAsync(
ImmutableList<ChatMessage> messages,
AiRequestContext context,
object? unused,
CancellationToken cancellationToken)
{
var response = await _client.CompleteAsync(
context.Provider, context.Model, messages, cancellationToken);
if (response.Error is not null)
{
throw new InvalidOperationException(response.Error);
}
return new Result(response.Content ?? string.Empty, response.Reasoning);
}
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices) =>
AnotherReplayReader.Utils.AIAnalyze.BuildOverviewUserPrompt(slices);
public static string BuildSegmentUserPromptV2(
int segmentIndex,
int totalSegments,
ReplaySlice slice,
int eventCount,
string? title,
string? description = null,
IEnumerable<string>? backqueryHints = null) =>
AnotherReplayReader.Utils.AIAnalyze.BuildSegmentUserPromptV2(
segmentIndex, totalSegments, slice, eventCount,
title, description, backqueryHints);
public static string BuildFocusWindowUserPromptV2(
int segmentIndex,
int totalSegments,
ReplaySlice slice,
int windowIndex,
int windowCount,
FocusWindow window,
int sliceEventCount,
int windowEventCount,
string? title,
string? description = null,
IEnumerable<string>? backqueryHints = null) =>
AnotherReplayReader.Utils.AIAnalyze.BuildFocusWindowUserPromptV2(
segmentIndex, totalSegments, slice, windowIndex, windowCount,
window, sliceEventCount, windowEventCount, title, description, backqueryHints);
public static string BuildSummaryUserPromptV2(int totalEventCount) =>
AnotherReplayReader.Utils.AIAnalyze.BuildSummaryUserPromptV2(totalEventCount);
public static string BuildBackqueryUserPrompt(string sliceText) =>
AnotherReplayReader.Utils.AIAnalyze.BuildBackqueryUserPrompt(sliceText);
public static string BuildRevisionUserPrompt(
string draft,
string validationIssues,
string relevantFacts) =>
AnotherReplayReader.Utils.AIAnalyze.BuildRevisionUserPrompt(
draft, validationIssues, relevantFacts);
}
internal sealed record TestAiResponse(
string? Content,
string? Reasoning,
string? Error = null,
int? StatusCode = null);
internal sealed class TestAiClient
{
private readonly HttpClient _http = new()
{
Timeout = TimeSpan.FromMinutes(5)
};
public async Task<TestAiResponse> CompleteAsync(
AiProvider provider,
AiModel model,
IReadOnlyList<TestAIAnalyze.ChatMessage> messages,
CancellationToken cancellationToken)
{
var request = new Dictionary<string, object>
{
["model"] = model.ModelId,
["temperature"] = provider.DefaultTemperature,
["top_p"] = provider.DefaultTopP,
["max_tokens"] = provider.DefaultMaxTokens,
["stream"] = false
};
foreach (var kv in model.ExtraParameters)
{
request[kv.Key] = kv.Value;
}
request["messages"] = messages.ToArray();
var uri = new Uri(new(provider.BaseUrl.TrimEnd('/') + "/"), "chat/completions");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, uri);
httpRequest.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", provider.ApiKey);
httpRequest.Content = new StringContent(
JsonSerializer.Serialize(request), Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(
httpRequest, cancellationToken);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
var errorMessage = body;
try
{
using var errorDocument = JsonDocument.Parse(body);
if (errorDocument.RootElement.TryGetProperty("error", out var error)
&& error.TryGetProperty("message", out var message))
{
errorMessage = message.GetString() ?? body;
}
}
catch
{
// 保留原始响应体。
}
return new TestAiResponse(
null, null, errorMessage, (int)response.StatusCode);
}
string? content = null;
string? reasoning = null;
using var document = JsonDocument.Parse(body);
AppendFromResponse(document.RootElement, ref content, ref reasoning);
return new TestAiResponse(
content, reasoning, null, (int)response.StatusCode);
}
private static void AppendFromResponse(
JsonElement root,
ref string? content,
ref string? reasoning)
{
if (!root.TryGetProperty("choices", out var choices)
|| choices.ValueKind != JsonValueKind.Array
|| choices.GetArrayLength() == 0)
{
return;
}
var message = choices[0].GetProperty("message");
if (message.TryGetProperty("content", out var contentProperty)
&& contentProperty.ValueKind == JsonValueKind.String)
{
content = contentProperty.GetString();
}
if (message.TryGetProperty("reasoning_content", out var reasoningProperty)
&& reasoningProperty.ValueKind == JsonValueKind.String)
{
reasoning = reasoningProperty.GetString();
}
}
}
internal static class UserReplayFactIndexReproTests
{
public static void Run()
{
if (Environment.GetEnvironmentVariable("ARR_E2E_REPLAY") != "1")
{
Console.WriteLine(" [跳过] 未设置 ARR_E2E_REPLAY=1,跳过真实回放诊断");
return;
}
var replayPath = @"C:\Users\lanyi\Documents\Red Alert 3\Replays\安洁莉娜.(C)_VS_机枢舞者(A)[1V1][无限岛][2026_06_16 05_57][ra3battle.net].RA3Replay";
if (!File.Exists(replayPath))
{
Console.WriteLine(" [跳过] 未找到用户回放文件");
return;
}
var replay = new Replay(replayPath, parseBody: true);
Program.Assert(replay.Body is { } body, "回放解析");
var timeline = (from chunk in body
where chunk.Type == 1
select (chunk.Time, CommandChunk.Parse(chunk).ToImmutableArray()))
.ToImmutableArray();
// 诊断:0x1FE/0x200 中 Int32 数组条目
foreach (var (time, commands) in timeline)
{
foreach (var command in commands)
{
if (command.CommandId is not (0x1FE or 0x200))
{
continue;
}
foreach (var entry in command.Data)
{
if (entry.Type == CommandArgumentType.Int32 && entry.Count > 1)
{
Console.WriteLine($" [诊断] 0x{command.CommandId:X3} @{time} 玩家{command.PlayerIndex} Int32 count={entry.Count} valueType={entry.Value?.GetType().Name}");
}
}
}
}
ReplayFactIndex.Build(timeline, new Dictionary<uint, string>());
Program.Assert(true, "事实索引构建无异常");
}
}
}