using System; using System.Collections.Generic; using System.Linq; using CounterDrone.Core.Algorithms; using CounterDrone.Core.Models; using Xunit; namespace CounterDrone.Core.Tests { public class DefensePlannerTests { private static readonly List TestAmmo = DefaultAmmunition.GetAll(); 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, 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, CruiseSpeed = 55, ReleaseAltitude = 1500, TotalMunitions = 16, Cooldown = 5f, AmmoTypes = new() { AerosolType.InertGas }, }; private static DroneGroup MakeThreat(PowerType power = PowerType.Piston, float speed = 120f, float startX = 0, float endX = 10000, float alt = 500) { var t = new DroneGroup { GroupId = "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, DroneGroup threat, CombatScene? env = null) => new DefaultDefensePlanner(TestAmmo).Plan(units, new() { threat }, env ?? new CombatScene()); // ═══════════════════════════════════════ // 威胁排序 // ═══════════════════════════════════════ [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() { // 两个单元不同位置 → 飞行时间不同 → 发射时间不同 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); float t0 = r0.Best.MergedSchedule[0].FireTime; float t1 = r1.Best.MergedSchedule[0].FireTime; Assert.NotEqual(t0, t1); // 位置不同 → 弹道不同 → 发射时刻不同 } [Fact] public void MultiLane_3Drones_3Lanes_ExactOutput() { // 3 架无人机横向编队,应生成 3 车道各 11 发 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(); // 打印所有事件 var lines = events.Select(e => $"{e.FireTime:F2}s Y={e.TargetY:F0} u={e.PlatformIndex}").ToList(); var dump = string.Join("\n", lines.Take(15)) + $"\n... ({events.Count} total)"; Assert.True(events.Count >= 30, $"count={events.Count}\n{dump}"); // 验证时间不等(有错开) float first = events[0].FireTime, last = events[^1].FireTime; Assert.True(last - first > 2f, $"spread={last-first:F1}s, 应有错开\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 AirBased_PlatformFliesOnce_SameFlightTime() { // 同一平台多通道:所有弹药飞行时间相同(平台停在投放点) var unit = MakeAirUnit("u0", 3000); var result = Plan(new() { unit }, MakeThreat(speed: 120)); var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList(); Assert.True(events.Count >= 3); float expectedGap = Kinematics.CloudCoverInterval(40f, 120f, 1f); for (int i = 1; i < events.Count; i++) { float gap = events[i].FireTime - events[i - 1].FireTime; Assert.True(Math.Abs(gap - expectedGap) < 0.3f, $"gap[{i}]={gap:F3}, expected {expectedGap:F2}±0.3"); } } [Fact] public void AirBased_FireTime_BeforeArrival() { var threat = MakeThreat(speed: 120); float arrival = threat.GetArrivalTime(); Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, threat).Best.MergedSchedule, fe => Assert.True(fe.FireTime < arrival - 40f, $"空基FireTime={fe.FireTime:F1} 应远早于 arrival={arrival:F1}")); } [Fact] public void AirBased_MuzzleVelocity_Zero() => Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, MakeThreat()).Best.MergedSchedule, fe => Assert.Equal(0f, fe.MuzzleVelocity)); [Fact] public void AirBased_TargetX_OnRoute() { var threat = MakeThreat(startX: 0, endX: 10000); Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, 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 DefaultDefensePlanner(TestAmmo).Plan( new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, new() { MakeThreat(speed: 200) }, new CombatScene()); 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 DefaultDefensePlanner(TestAmmo).Plan(new(), new() { MakeThreat() }, new CombatScene()).Best.ThreatsUnengaged); [Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefaultDefensePlanner(TestAmmo).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene()).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, 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.GroupId = "g0"; var b = MakeThreat(PowerType.Jet, 300); b.GroupId = "g1"; var result = new DefaultDefensePlanner(TestAmmo).Plan( new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) }, new() { a, b }, new CombatScene()); 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); } } }