using System; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using AnotherReplayReader; using AnotherReplayReader.ReplayFile; using AnotherReplayReader.Utils; 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("OverviewParser", OverviewParserTests.Run); Run("BackqueryParser", BackqueryParserTests.Run); Run("BackquerySliceExtractor", BackquerySliceExtractorTests.Run); Run("StructuredEvidence", StructuredEvidenceTests.Run); Run("MachineReadableClaims", MachineReadableClaimsTests.Run); Run("ClaimFindingsFormatter", ClaimFindingsFormatterTests.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 expected, T actual, string message) { if (EqualityComparer.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(); 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.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(); 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 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(); 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.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.Empty)), ImmutableArray.Empty, ImmutableArray.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 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.Empty.Add(4, player); var factIndex = TestData.BuildIndex( ImmutableDictionary.Empty.Add(1, TimeSpan.FromSeconds(80)), ImmutableDictionary>.Empty.Add( 1, ImmutableHashSet.Create("SpecialPower_PackReplaceSelf")), ImmutableHashSet.Empty.Add(1), ImmutableHashSet.Empty, ImmutableDictionary>.Empty.Add( 4, ImmutableDictionary.Empty.Add("AlliedMCV", TimeSpan.FromSeconds(100))), ImmutableDictionary>.Empty.Add( 4, ImmutableHashSet.Create(1)), ImmutableDictionary>.Empty, ImmutableDictionary>.Empty, ImmutableDictionary>.Empty); const string fullText = "[0:00] 玩家 A,开始建造建筑\n [UnitId]1(建造者)\n AlliedBarracks\n\n[4:00] 玩家 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("AlliedMCV@1:40"), "首次出兵时间"); Program.Assert(digest.Contains("SpecialPower_PackReplaceSelf"), "打包/展开段"); Program.Assert(digest.Contains("建造者"), "建造者段"); Program.Assert(digest.Contains("第1段"), "分段元数据"); Program.Assert(digest.Contains("开始建造建筑"), "关键事件采样"); Program.Assert(digest.Contains("# 协议选择"), "摘要包含协议选择"); Program.Assert(digest.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 Mapping = new Dictionary { { "PlayerA", 4 }, { "PlayerB", 5 } }; public static void Run() { var index = TestData.BuildIndex( firstObserved: ImmutableDictionary.Empty .Add(1, TimeSpan.FromSeconds(60)) .Add(2, TimeSpan.FromSeconds(90)) .Add(3, TimeSpan.FromSeconds(120)) .Add(7, TimeSpan.FromSeconds(60)), strong: ImmutableDictionary>.Empty .Add(4, ImmutableHashSet.Create(1)), powers: ImmutableDictionary>.Empty .Add(7, ImmutableHashSet.Create("SpecialPower_PackReplaceSelf")), weak: ImmutableDictionary>.Empty .Add(5, ImmutableHashSet.Create(1, 3)), productions: ImmutableDictionary>.Empty .Add(4, ImmutableDictionary.Empty .Add("AlliedBomberAircraft", TimeSpan.FromSeconds(300))), tech: ImmutableDictionary>.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? firstObserved = null, ImmutableDictionary>? powers = null, ImmutableHashSet? builders = null, ImmutableHashSet? producers = null, ImmutableDictionary>? productions = null, ImmutableDictionary>? selected = null, ImmutableDictionary>? strong = null, ImmutableDictionary>? weak = null, ImmutableDictionary>? tech = null) => new ReplayFactIndex( firstObserved ?? ImmutableDictionary.Empty, powers ?? ImmutableDictionary>.Empty, builders ?? ImmutableHashSet.Empty, producers ?? ImmutableHashSet.Empty, productions ?? ImmutableDictionary>.Empty, selected ?? ImmutableDictionary>.Empty, strong ?? ImmutableDictionary>.Empty, weak ?? ImmutableDictionary>.Empty, tech ?? ImmutableDictionary>.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 { [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()); 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 → 弱所有权"); } } 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("不要修改"), "总览轮不修改边界"); 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 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.Empty.Add(1, TimeSpan.FromSeconds(60)), strong: ImmutableDictionary>.Empty .Add(4, ImmutableHashSet.Create(1)), builders: ImmutableHashSet.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.Empty, ImmutableArray.Empty)), ImmutableArray.Empty, ImmutableArray.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(json); Program.Assert(deserialized!.Role == "user" && deserialized.Content == "内容", "往返一致"); } } 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()); Program.Assert(true, "事实索引构建无异常"); } } }