ai plan v2

This commit is contained in:
2026-08-21 15:34:24 +02:00
parent 9555423f51
commit d06a93f759
20 changed files with 3093 additions and 523 deletions
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<AssemblyName>AiV2.Tests</AssemblyName>
<RootNamespace>AiV2.Tests</RootNamespace>
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AnotherReplayReader.csproj" AdditionalProperties="AiV2TestsBuilding=true" />
</ItemGroup>
<ItemGroup>
<Content Include="..\knowledge_units_default.json" Link="knowledge_units_default.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\knowledge_default.md" Link="knowledge_default.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+697
View File
@@ -0,0 +1,697 @@
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>(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, "小预算仍应有切片");
}
}
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");
// 高置信无证据 → 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), "高置信无证据");
// 低置信无备选/待确认 → 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), "低置信无备选");
}
}
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 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, "预算内 → 通过");
}
}
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")),
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] 玩家 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("开始建造建筑"), "关键事件采样");
}
}
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");
}
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,
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,
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), "首次出现记录");
}
}
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 标签");
// 不存在的 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("出生点"), "地图知识保留");
}
}
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, "开局");
Program.Assert(segment.Contains("第1/1段"), "段指令编号");
Program.Assert(segment.Contains("开局"), "段标题");
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<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 == "内容", "往返一致");
}
}
internal static class UserReplayFactIndexReproTests
{
public static void Run()
{
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, "事实索引构建无异常");
}
}
}