using System; using System.Collections.Generic; using System.Linq; using CounterDrone.Core.Algorithms; using CounterDrone.Core.Models; using Xunit; using Xunit.Abstractions; namespace CounterDrone.Core.Tests { public class DefensePlannerTests { private readonly ITestOutputHelper _output; public DefensePlannerTests(ITestOutputHelper output) { _output = output; } private static readonly List TestAmmo = TestData.Ammo; private static FireUnit MakeGroundUnit(string id, float posX, int munitions = 16) => new() { Id = id, Type = PlatformType.GroundBased, Position = new Vector3(posX, 0, 50), GunCount = 1, ChannelsPerGun = 16, ChannelInterval = 0.1f, MuzzleVelocity = 800, TotalMunitions = munitions, Cooldown = 5f, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, }; private static FireUnit MakeAirUnit(string id, float posX) => new() { Id = id, Type = PlatformType.AirBased, Position = new Vector3(posX, 2500, -2000), GunCount = 1, ChannelsPerGun = 16, MuzzleVelocity = 55, CruiseSpeed = 55, ReleaseAltitude = 1500, TotalMunitions = 16, Cooldown = 5f, AmmoTypes = new() { AerosolType.InertGas }, }; private static DroneWave MakeThreat(PowerType power = PowerType.Piston, float speed = 120f, float startX = 0, float endX = 10000, float alt = 500) { var t = new DroneWave { WaveId = "default", Target = new TargetConfig { TargetType = (int)TargetType.Piston, PowerType = (int)power, Quantity = 1, TypicalSpeed = speed, TypicalAltitude = alt, }, Waypoints = new List { new() { PosX = startX, PosY = alt, PosZ = 100, Speed = speed }, new() { PosX = endX, PosY = alt, PosZ = 100, Speed = speed }, }, }; return t; } private PlannerResult Plan(List units, DroneWave threat, CombatScene? env = null) => new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(units, new() { threat }, env ?? new CombatScene(), new List()); // ═══════════════════════════════════════ // 威胁排序 // ═══════════════════════════════════════ [Fact] public void Priority_FasterThreat_RanksHigher() { var a = MakeThreat(speed: 60); a.ArrivalTime = 50; a.ThreatIndex = 2f; var b = MakeThreat(speed: 500); b.ArrivalTime = 50; b.ThreatIndex = 16f; Assert.True(b.Priority > a.Priority); } [Fact] public void Priority_EarlierThreat_RanksHigher() { var a = MakeThreat(); a.ArrivalTime = 20; a.ThreatIndex = 2f; var b = MakeThreat(); b.ArrivalTime = 100; b.ThreatIndex = 2f; Assert.True(a.Priority > b.Priority); } // ═══════════════════════════════════════ // 弹药匹配 // ═══════════════════════════════════════ [Fact] public void Ammo_Piston_MatchesInertGas() => Assert.Equal(AerosolType.InertGas, Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(PowerType.Piston)).Best.Assignments[0].AmmoType); [Fact] public void Ammo_Jet_MatchesActiveMaterial() => Assert.Equal(AerosolType.ActiveMaterial, Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(PowerType.Jet, 500)).Best.Assignments[0].AmmoType); [Fact] public void Ammo_Electric_MatchesInertGas() => Assert.Equal(AerosolType.InertGas, Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(PowerType.Electric)).Best.Assignments[0].AmmoType); // ═══════════════════════════════════════ // 地基弹道:每发 shellTime 因目标位置不同而不同 // ═══════════════════════════════════════ [Fact] public void Ground_FireTime_VariesPerUnitPosition() { // 不同位置 → InterceptCalculator 给出不同拦截弧/时间 → 发射时刻不同 var u0 = MakeGroundUnit("u0", 5000); var u1 = MakeGroundUnit("u1", 8000); var threat = MakeThreat(speed: 120); var r0 = Plan(new() { u0 }, threat); var r1 = Plan(new() { u1 }, threat); // 两个位置的 plan 都应有有效发射事件 Assert.NotEmpty(r0.Best.MergedSchedule); Assert.NotEmpty(r1.Best.MergedSchedule); float t0 = r0.Best.MergedSchedule[0].FireTime; float t1 = r1.Best.MergedSchedule[0].FireTime; // FireTime 必须 > 0(在可发射窗口内) Assert.True(t0 > 0, $"u0 FireTime={t0:F1} 应>0"); Assert.True(t1 > 0, $"u1 FireTime={t1:F1} 应>0"); // 两个位置的 launchAngle 都来自 InterceptCalculator,应为正值 Assert.True(r0.Best.MergedSchedule[0].LaunchAngle > 0); Assert.True(r1.Best.MergedSchedule[0].LaunchAngle > 0); } [Fact] public void MultiLane_3Drones_3Lanes_ExactOutput() { var threat = MakeThreat(speed: 200); threat.Target.Quantity = 3; threat.Route = new RoutePlan { FormationMode = (int)Models.FormationMode.Formation, LateralSpacing = 50 }; threat.Waypoints.Clear(); threat.Waypoints.Add(new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 }); threat.Waypoints.Add(new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 }); var result = Plan(new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5000), MakeGroundUnit("u2", 5000) }, threat); var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList(); float first = events[0].FireTime, last = events[^1].FireTime; // 输出到控制台 _output.WriteLine($"=== 3无人机 3车道 Planner输出 ==="); _output.WriteLine($"总弹药: {events.Count}发, 跨度: {last-first:F2}s"); foreach (var e in events) _output.WriteLine($" FireTime={e.FireTime:F2}s Y={e.TargetY:F0} PlatIdx={e.PlatformIndex}"); var dump = string.Join("\n", events.Select(e => $" FireTime={e.FireTime:F2}s Y={e.TargetY:F0} PlatIdx={e.PlatformIndex}")); // 3 车道,弹药数取决于损伤模型 Assert.True(events.Count >= 21, $"应≥21发, 实际{events.Count}\n{dump}"); Assert.True(last - first > 0, $"跨度>0\n{dump}"); // 验证每个车道有独立的 TargetZ var zs = events.Select(e => e.TargetZ).Distinct().ToList(); Assert.True(zs.Count >= 2, $"应有至少2个不同Z值(distinct lanes)\n{dump}"); } [Fact] public void Ground_AllFireTimesPositive() => Assert.All( Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat()).Best.MergedSchedule, fe => Assert.True(fe.FireTime > 0)); [Fact] public void Ground_AllBeforeArrival() { var threat = MakeThreat(); float arrival = threat.GetArrivalTime(); Assert.All(Plan(new() { MakeGroundUnit("u0", 5000) }, threat).Best.MergedSchedule, fe => Assert.True(fe.FireTime < arrival, $"FireTime={fe.FireTime:F1} >= arrival={arrival:F1}")); } [Fact] public void Ground_MuzzleVelocity_InFireEvent() => Assert.All(Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat()).Best.MergedSchedule, fe => Assert.Equal(800f, fe.MuzzleVelocity)); [Fact] public void Ground_TargetX_OnRoute() { var threat = MakeThreat(startX: 0, endX: 10000); Assert.All(Plan(new() { MakeGroundUnit("u0", 5000) }, threat).Best.MergedSchedule, fe => Assert.True(fe.TargetX >= 0 && fe.TargetX <= 10000, $"TargetX={fe.TargetX:F0}")); } // ═══════════════════════════════════════ // 空基弹道:平台飞一次,所有弹药同飞行时间 // ═══════════════════════════════════════ // ═══════════════════════════════════════ // 分配逻辑 // ═══════════════════════════════════════ [Fact] public void Allocation_RespectsTotalMunitions() { var unit = MakeGroundUnit("u0", 5000, munitions: 2); // 只能打 2 发 var result = Plan(new() { unit }, MakeThreat(speed: 200)); int totalFired = result.Best.Assignments.Sum(a => a.RoundsFired); Assert.True(totalFired <= 2, $"fired={totalFired} > 2 munitions"); } [Fact] public void Allocation_MultiUnit_PoolsChannels() { var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, new() { MakeThreat(speed: 200) }, new CombatScene(), new List()); Assert.True(result.Best.ThreatsEngaged == 1); Assert.True(result.Best.MergedSchedule.Count > 1); } [Fact] public void Allocation_IncompatibleAmmo_NotEngaged() { var unit = new FireUnit { Id = "u0", Type = PlatformType.GroundBased, Position = new Vector3(5000, 0, 50), GunCount = 1, ChannelsPerGun = 16, MuzzleVelocity = 800, TotalMunitions = 16, AmmoTypes = new() { AerosolType.ActiveMaterial }, // 无 InertGas }; var result = Plan(new() { unit }, MakeThreat(PowerType.Piston)); Assert.Equal(0, result.Best.ThreatsEngaged); } // ═══════════════════════════════════════ // 边界 // ═══════════════════════════════════════ [Fact] public void Edge_NoUnits_Unengaged() => Assert.Equal(1, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new(), new() { MakeThreat() }, new CombatScene(), new List()).Best.ThreatsUnengaged); [Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene(), new List()).Best.ThreatsEngaged); [Fact] public void Edge_OutOfRange_Ground() { var unit = MakeGroundUnit("u0", 100000); // 极远 var result = Plan(new() { unit }, MakeThreat()); Assert.Equal(0, result.Best.ThreatsEngaged); } [Fact] public void Edge_OutOfRange_Air() { var unit = new FireUnit { Id = "u0", Type = PlatformType.AirBased, Position = new Vector3(100000, 2500, 100000), // 极远 GunCount = 1, ChannelsPerGun = 4, MuzzleVelocity = 10, CruiseSpeed = 10, ReleaseAltitude = 1500, TotalMunitions = 4, AmmoTypes = new() { AerosolType.InertGas }, }; var result = Plan(new() { unit }, MakeThreat()); Assert.Equal(0, result.Best.ThreatsEngaged); } // ═══════════════════════════════════════ // 多威胁 // ═══════════════════════════════════════ [Fact] public void MultiThreat_BothEngaged() { var a = MakeThreat(PowerType.Piston, 120); a.WaveId = "g0"; var b = MakeThreat(PowerType.Jet, 300); b.WaveId = "g1"; var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, new() { a, b }, new CombatScene(), new List()); Assert.Equal(2, result.Best.ThreatsEngaged); } // ═══════════════════════════════════════ // 临界方案 // ═══════════════════════════════════════ [Fact] public void Critical_HasAssignments() { var result = Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat()); Assert.True(result.Critical.Assignments.Count > 0); Assert.True(result.Critical.OverallProbability >= 0.4f); } // ═══════════════════════════════════════ // P1 风偏补偿:云团生成后会被风吹偏 windVec × expansionTime, // Planner 须逆风预置抛撒点,使云团漂移回无人机穿越点。 // tx,tz = 无人机穿越点(航路中点,不变) // cloudGenX/Z = 抛撒点(FireEvent.TargetX/Z,含风偏补偿) // ═══════════════════════════════════════ [Fact] public void WindOffset_NoWind_NoDirectionalBias() { // 无风时,无论 WindDirection 取何值,抛撒点都应相同 // (风偏补偿 = windVec × expansionTime,windVec=0 时补偿为 0) var threat = MakeThreat(startX: 0, endX: 10000); // 风向取不同值,但风速都是 0 var envN = new CombatScene { WindSpeed = 0, WindDirection = (int)WindDirection.N }; var envE = new CombatScene { WindSpeed = 0, WindDirection = (int)WindDirection.E }; var rN = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, envN); var rE = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, envE); float xN = rN.Best.MergedSchedule[0].TargetX; float xE = rE.Best.MergedSchedule[0].TargetX; Assert.True(Math.Abs(xN - xE) < 1f, $"无风时风向不应影响抛撒点:N={xN:F1}, E={xE:F1}"); } [Fact] public void WindOffset_EastWind_TargetShiftedUpwind() { // 东风(WindDirection.E):WindToVector 返回 (speed, 0, 0),云团被吹向 +X。 // 抛撒点应逆风预置 → TargetX < 航路中点 X=5000 var threat = MakeThreat(startX: 0, endX: 10000); var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.E }; var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 }); var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env); float calmX = calmResult.Best.MergedSchedule[0].TargetX; float windyX = windyResult.Best.MergedSchedule[0].TargetX; Assert.True(windyX < calmX, $"东风下抛撒点 X={windyX:F1} 应小于无风 X={calmX:F1}(逆风预置补偿)"); // 偏移量级合理性:expansionTime≈30s,风速 10m/s → 偏移约 300m Assert.True(calmX - windyX > 100f, $"偏移量 {calmX - windyX:F1}m 应有显著量级(期望 ~300m)"); } [Fact] public void WindOffset_WestWind_TargetShiftedOpposite() { // 西风:风矢量 (-speed, 0, 0),云团被吹向 -X。 // 抛撒点应在 +X 方向(TargetX > 中点),与东风对称 var threat = MakeThreat(startX: 0, endX: 10000); var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.W }; var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 }); var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env); float calmX = calmResult.Best.MergedSchedule[0].TargetX; float windyX = windyResult.Best.MergedSchedule[0].TargetX; Assert.True(windyX > calmX, $"西风下抛撒点 X={windyX:F1} 应大于无风 X={calmX:F1}(与东风相反方向)"); } [Fact] public void WindOffset_NorthWind_ShiftsZ() { // 北风:WindToVector 返回 (0, 0, speed),云团被吹向 +Z。 // 抛撒点应在 -Z 方向(TargetZ 更小) var threat = MakeThreat(startX: 0, endX: 10000); threat.Waypoints[0].PosZ = 100; threat.Waypoints[1].PosZ = 100; var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.N }; var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 }); var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env); float calmZ = calmResult.Best.MergedSchedule[0].TargetZ; float windyZ = windyResult.Best.MergedSchedule[0].TargetZ; Assert.True(windyZ < calmZ, $"北风下抛撒点 Z={windyZ:F1} 应小于无风 Z={calmZ:F1}(Z 方向逆风补偿)"); } // ═══════════════════════════════════════ // 航路感知布局:云团沿真实航路方向分布,不写死 X 轴。 // Z 向航路(南北飞)的多发云团,X 坐标应集中在航路 X 附近(非发散)。 // ═══════════════════════════════════════ [Fact] public void RouteAware_ZDirectionRoute_CloudsOnRoute() { // Z 向航路:从 (5000,0) 飞向 (5000,10000),即沿 +Z 方向 // 之前 offset 写死 X 轴时,多发云团会沿 X 发散到 5000±N×spacing,偏离航路 // 改用 RouteGeometry 后,offset 沿航路切向(+Z),云团应集中在 X=5000 var threat = new DroneWave { WaveId = "default", Target = new TargetConfig { TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston, Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500, }, Waypoints = new List { new() { PosX = 5000, PosY = 500, PosZ = 0, Speed = 200 }, new() { PosX = 5000, PosY = 500, PosZ = 10000, Speed = 200 }, }, }; // 火力单元放在航路起点附近(X=5000) var unit = MakeGroundUnit("u0", 5000); var result = Plan(new() { unit }, threat, new CombatScene()); // 所有抛撒点的 X 应集中在 5000 附近(±少量风偏,此处无风) // 若 offset 仍写死 X 轴,多发会沿 X 散开到 5000±N×40 Assert.All(result.Best.MergedSchedule, fe => Assert.True(Math.Abs(fe.TargetX - 5000) < 50f, $"Z 向航路下抛撒点 X={fe.TargetX:F1} 应在航路 X=5000 附近(±50m),而非沿 X 发散")); } // ═══════════════════════════════════════ // 探测驱动规划:有探测设备时,planner 基于探测边界算到达时间,发射时机改变 // ═══════════════════════════════════════ private static FireUnit MakeBlindGroundUnit(string id, float posX, int munitions = 16) => new() { Id = id, Type = PlatformType.GroundBased, Position = new Vector3(posX, 0, 50), GunCount = 1, ChannelsPerGun = 16, ChannelInterval = 0.1f, MuzzleVelocity = 800, TotalMunitions = munitions, Cooldown = 5f, AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, // 无探测能力(RadarRange/EORange/IRange 默认 0) }; [Fact] public void DetectionDriven_FireTime_UsesDetectionBoundary() { // 航路 X:0→10000,无人机 200km/h。无探测时 planner 基于起点,到达中点 5000m = 90s var threat = MakeThreat(speed: 200, startX: 0, endX: 10000); var unit = MakeBlindGroundUnit("u0", 5000); // 场景A:无探测(DetectArc=0,上帝视角从起点算) var resultNoDet = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( new() { unit }, new() { threat }, new CombatScene { Visibility = 10000 }, new List()); float fireNoDet = resultNoDet.Best.MergedSchedule[0].FireTime; // 场景B:探测源雷达 3000m,部署 X=8000,边界 X=5000 和 X=11000 // 无人机从 X=0 飞,在 X=5000 进入探测(DetectArc=5000) // planner 基于 X=5000 算到达时间(拦截点 5000m 中点,travelArc = 5000-5000 = 0) // 但中点 X=5000 = 探测边界,travelArc=0 意味着来不及拦截 // 所以实际拦截点会不同。简化验证:有探测时 fireTime 不同(推迟或无法拦截) var detSrc = new DetectionSource { Position = new Vector3(8000, 0, 0), RadarRange = 3000, Accuracy = 50, }; var resultDet = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan( new() { unit }, new() { threat }, new CombatScene { Visibility = 10000 }, new List { detSrc }); Assert.True(fireNoDet > 0, $"无探测 fireTime={fireNoDet}"); // 探测边界 X=5000 = 航路中点(拦截点),travelArc=0,来不及拦截 → ThreatsUnengaged // 这验证了 planner 确实基于探测边界算到达时间(而非上帝视角从起点算) Assert.True(resultDet.Best.ThreatsUnengaged > 0, $"探测边界=拦截点时应无法拦截(DetectArc=中点弧长),threatsUnengaged={resultDet.Best.ThreatsUnengaged}"); } // ═══════════════════════════════════════ // HasInterceptWindow — 拦截窗口可行性(纯函数单元测试) // ═══════════════════════════════════════ private static DroneWave QuickThreat(float typicalSpeed, float detectArc) { var t = MakeThreat(speed: typicalSpeed); t.DetectArc = detectArc; return t; } [Fact] public void HasInterceptWindow_DetectArcPastMidpoint_ReturnsFalse() { // DetectArc=3000 > midArc=2500 → travelArc<0 → 探测在拦截点之后 var threat = QuickThreat(200f, 3000f); Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f)); } [Fact] public void HasInterceptWindow_DetectArcAtMidpoint_ReturnsFalse() { // DetectArc=2500 = midArc → travelArc=0 → 时间窗口为零 var threat = QuickThreat(200f, 2500f); Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f)); } [Fact] public void HasInterceptWindow_WindowTooShort_ReturnsFalse() { // travelArc=500, speed=200km/h=55.6m/s → timeAvail≈9s // expansionTime=30s + deliveryTime=2s = 32s → 9s < 32s var threat = QuickThreat(200f, 2000f); // DetectArc=2000, midArc=2500 → travel=500 Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f)); } [Fact] public void HasInterceptWindow_BarelyEnough_ReturnsTrue() { // travelArc=2000, speed=200km/h=55.6m/s → timeAvail≈36s // expansionTime=30s + deliveryTime=5s = 35s → 36s > 35s var threat = QuickThreat(200f, 500f); // DetectArc=500, midArc=2500 → travel=2000 Assert.True(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 5f)); } [Fact] public void HasInterceptWindow_SlowDroneHasMoreTime_ReturnsTrue() { // travelArc=500, speed=60km/h=16.7m/s → timeAvail≈30s // expansionTime=25s + deliveryTime=3s = 28s → 30s > 28s var threat = QuickThreat(60f, 2000f); // slower drone, same geometry Assert.True(DefensePlanner.HasInterceptWindow(threat, 2500f, 25f, 3f)); } [Fact] public void HasInterceptWindow_FastDroneHasLessTime_ReturnsFalse() { // travelArc=500, speed=300km/h=83.3m/s → timeAvail≈6s // expansionTime=20s + deliveryTime=1s = 21s → 6s < 21s var threat = QuickThreat(300f, 2000f); Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 20f, 1f)); } // ═══════════════════════════════════════ // 端到端:不可行方案返回清晰原因 // ═══════════════════════════════════════ } }