798 lines
47 KiB
C#
798 lines
47 KiB
C#
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, "小预算仍应有切片");
|
||
|
||
// 超过 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 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 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")),
|
||
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("开始建造建筑"), "关键事件采样");
|
||
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<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,
|
||
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), "首次出现记录");
|
||
|
||
// 编队按玩家隔离 + 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 → 弱所有权");
|
||
}
|
||
}
|
||
|
||
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<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()
|
||
{
|
||
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, "事实索引构建无异常");
|
||
}
|
||
}
|
||
}
|