From c08d613d5b771b4043ae0a98cc6a9dcc98fac882 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Sat, 13 Jun 2026 11:27:00 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=BC=95=E6=93=8E=E7=A9=BA=E5=9F=BA/?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA=E5=8F=91=E5=B0=84=E7=B1=BB=E5=9E=8B=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95=20+=20Planner=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SimulationEngineTests: AirBased_LaunchesWithCorrectDescription (空基描述="空基投放") - SimulationEngineTests: GroundBased_LaunchesWithCorrectDescription (地基描述="计划发射") - DefensePlannerTests: 23个全覆盖(威胁排序/弹药匹配/地基弹道/空基弹道/分配/边界/多威胁/临界) - FullPipeline AirBased: 加入事件描述诊断断言 --- .../Algorithms/DefaultDefensePlanner.cs | 25 +- .../DefensePlannerTests.cs | 449 ++++++++---------- .../FullPipelineTests.cs | 13 + .../SimulationEngineTests.cs | 47 ++ 4 files changed, 266 insertions(+), 268 deletions(-) diff --git a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs index 4ca6853..18d6267 100644 --- a/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs +++ b/src/CounterDrone.Core/Algorithms/DefaultDefensePlanner.cs @@ -131,6 +131,15 @@ namespace CounterDrone.Core.Algorithms for (int salvo = 0; salvo < salvosNeeded; salvo++) { float salvoDelay = salvo * candidates[0].Unit.Cooldown; + + // 同平台飞行时间只算一次(平台飞到投放点后所有通道齐投) + var (effectiveR, _) = ComputeEffectiveRadius(threat, ammo, env); + float cloudDiameter = effectiveR * 2f; + float droneSpeed = (float)threat.Target.TypicalSpeed / 3.6f; + float physicsInterval = Math.Max(candidates[0].Unit.ChannelInterval, + cloudDiameter / Math.Max(0.1f, droneSpeed)); + float spacing = effectiveR * 1.5f; + foreach (var c in candidates.OrderBy(c => c.EarliestInterceptTime) .ThenByDescending(c => c.KillProbability)) { @@ -142,20 +151,20 @@ namespace CounterDrone.Core.Algorithms int toTake = Math.Min(maxSalvo, totalRoundsNeeded - roundsCollected); if (toTake <= 0) continue; - var (effectiveR, _) = ComputeEffectiveRadius(threat, ammo, env); - float cloudDiameter = effectiveR * 2f; - float droneSpeed = (float)threat.Target.TypicalSpeed / 3.6f; - // 物理间隔:云团直径 / 无人机速度,不小于硬件最小 - float physicsInterval = Math.Max(c.Unit.ChannelInterval, cloudDiameter / Math.Max(0.1f, droneSpeed)); - float spacing = effectiveR * 1.5f; + // 同单元所有弹药以中心点计算飞行时间,仅通过 eventIdx 错开间隔 + float centerOffset = (eventIdx + (toTake - 1) / 2f - (totalRoundsNeeded - 1) / 2f) * spacing; + var feTemplate = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, centerOffset); + float baseFireTime = feTemplate[0].FireTime; var fevents = new List(); for (int ch = 0; ch < toTake; ch++) { float offset = (eventIdx - (totalRoundsNeeded - 1) / 2f) * spacing; var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset); - // 跨单元按物理间隔错开 - foreach (var e in fe) e.FireTime += salvoDelay + eventIdx * physicsInterval; + foreach (var e in fe) + { + e.FireTime = baseFireTime + salvoDelay + eventIdx * physicsInterval; + } fevents.AddRange(fe); eventIdx++; } diff --git a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs index 26ee9c2..bbd3d6d 100644 --- a/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs +++ b/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using CounterDrone.Core.Algorithms; @@ -10,338 +11,266 @@ namespace CounterDrone.Core.Tests { private static readonly List TestAmmo = DefaultAmmunition.GetAll(); - private static List MakeGroundUnits(int count = 2) - { - var units = new List(); - for (int i = 0; i < count; i++) - units.Add(new FireUnit - { - Id = $"u{i}", - Type = PlatformType.GroundBased, - Position = new Vector3(5000 + i * 100, 0, 50), - GunCount = 4, - ChannelsPerGun = 4, - ChannelInterval = 1f, - MuzzleVelocity = 800f, - TotalMunitions = 16, - Cooldown = 5f, - AmmoTypes = new List { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel }, - }); - return units; - } + 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 DroneGroup MakeThreat(PowerType powerType = PowerType.Piston, float speed = 120f) + 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) { - return new DroneGroup + var t = new DroneGroup { GroupId = "default", Target = new TargetConfig { - TargetType = (int)TargetType.Piston, - PowerType = (int)powerType, - Quantity = 1, - TypicalSpeed = speed, - TypicalAltitude = 500, + TargetType = (int)TargetType.Piston, PowerType = (int)power, + Quantity = 1, TypicalSpeed = speed, TypicalAltitude = alt, }, Waypoints = new List { - new Waypoint { PosX = 0, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed }, - new Waypoint { PosX = 10000, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed }, + 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 ThreatPriority_SpeedMatters() + [Fact] public void Priority_FasterThreat_RanksHigher() { - var slow = MakeThreat(PowerType.Piston, 60); - slow.Target.TargetType = (int)TargetType.Piston; - slow.ArrivalTime = 100; - slow.ThreatIndex = CalcIndex(slow.Target); - - var fast = MakeThreat(PowerType.Piston, 500); - fast.Target.TargetType = (int)TargetType.Piston; - fast.ArrivalTime = 100; - fast.ThreatIndex = CalcIndex(fast.Target); - - Assert.True(fast.Priority > slow.Priority, $"fast={fast.Priority} slow={slow.Priority}"); + 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 ThreatPriority_TypeMatters() + [Fact] public void Priority_EarlierThreat_RanksHigher() { - var rotor = MakeThreat(PowerType.Electric, 200); - rotor.Target.TargetType = (int)TargetType.Rotor; - rotor.ArrivalTime = 50; - rotor.ThreatIndex = CalcIndex(rotor.Target); - - var jet = MakeThreat(PowerType.Piston, 200); - jet.Target.TargetType = (int)TargetType.HighSpeed; - jet.ArrivalTime = 50; - jet.ThreatIndex = CalcIndex(jet.Target); - - Assert.True(jet.ThreatIndex > rotor.ThreatIndex, $"jet={jet.ThreatIndex} rotor={rotor.ThreatIndex}"); - } - - private static float CalcIndex(TargetConfig t) - { - var coef = new Dictionary - { - { TargetType.HighSpeed, 4f }, { TargetType.FixedWing, 2f }, - { TargetType.Piston, 2f }, { TargetType.Rotor, 1f }, { TargetType.Electric, 1f }, - }.GetValueOrDefault((TargetType)t.TargetType, 1f); - return coef * (float)t.TypicalSpeed / 60f; + 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 Piston_InertGas() { Assert.Equal(AerosolType.InertGas, Plan(PowerType.Piston).Best.Assignments[0].AmmoType); } - [Fact] public void Jet_ActiveMaterial() { Assert.Equal(AerosolType.ActiveMaterial, Plan(PowerType.Jet, 500).Best.Assignments[0].AmmoType); } - [Fact] public void Electric_InertGas() { Assert.Equal(AerosolType.InertGas, Plan(PowerType.Electric).Best.Assignments[0].AmmoType); } + [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 SingleUnit_AllChannelsUsed_IfNeeded() + public void Ground_FireTime_VariesPerRound() { - var result = Plan(PowerType.Piston, 60); - var events = result.Best.MergedSchedule; - Assert.True(events.Count >= 3, $"count={events.Count}"); - } - - [Fact] - public void NoThreats_Empty() { Assert.Equal(0, new DefaultDefensePlanner(TestAmmo).Plan(new(), new(), new CombatScene()).Best.ThreatsEngaged); } - - [Fact] - public void ChannelInterval_StaggersWithinSalvo() - { - var result = Plan(PowerType.Piston, 60); + var result = Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(speed: 120)); var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList(); - // 同一单元通道有 ChannelInterval=1s 错开 - Assert.True(events.Count >= 2); + Assert.True(events.Count >= 3); + // 地基弹道:目标更近的弹飞行时间更短,FireTime 更晚(更接近 recommendedTiming) + float first = events[0].FireTime; + float last = events[^1].FireTime; + Assert.True(last > first, "远目标 FireTime 应 > 近目标"); } [Fact] - public void MultiUnit_PoolsChannels() + 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); + + // 间隔应稳定 ≈ physicsInterval(1.2s),不应因目标位置变化而波动 + // physicsInterval = max(1, 40.38/33.33) = 1.21s + for (int i = 1; i < events.Count; i++) + { + float gap = events[i].FireTime - events[i - 1].FireTime; + Assert.True(Math.Abs(gap - 1.21f) < 0.2f, + $"gap[{i}]={gap:F3}, expected 1.21±0.2"); + } + } + + [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() { - // 2 个单元 = 32 通道 var result = new DefaultDefensePlanner(TestAmmo).Plan( - MakeGroundUnits(2), new() { MakeThreat(PowerType.Piston, 200) }, new CombatScene()); - Assert.True(result.Best.ThreatsEngaged > 0); + 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 OneSalvo_NoCooldownNeeded() + public void Allocation_IncompatibleAmmo_NotEngaged() { - // 16 通道足够覆盖慢速目标 - var result = Plan(PowerType.Piston, 60); - var events = result.Best.MergedSchedule; - float first = events.Min(e => e.FireTime); - float last = events.Max(e => e.FireTime); - // 最后一个事件 - 第一个事件 < Cooldown → 同一轮 - Assert.True(last - first < 5f, $"spread={last - first:F1}s, should be < 5s cooldown"); - } - - [Fact] - public void TotalMunitions_CapsFirepower() - { - var units = new List + var unit = new FireUnit { - new FireUnit { Id = "u0", Type = PlatformType.GroundBased, - Position = new Vector3(5000, 0, 50), GunCount = 4, ChannelsPerGun = 4, - MuzzleVelocity = 800, TotalMunitions = 2, // 只够打 2 发 - AmmoTypes = new() { AerosolType.InertGas } }, + 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 = new DefaultDefensePlanner(TestAmmo).Plan( - units, new() { MakeThreat(PowerType.Piston, 200) }, new CombatScene()); - Assert.True(result.Best.ThreatsEngaged > 0); - Assert.True(result.Best.Assignments.Sum(a => a.RoundsFired) <= 2); + 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 IncompatibleAmmo_NotEngaged() + public void Edge_OutOfRange_Ground() { - var units = new List - { - new FireUnit { Id = "u0", Type = PlatformType.GroundBased, - Position = new Vector3(5000, 0, 50), GunCount = 4, ChannelsPerGun = 4, - MuzzleVelocity = 800, TotalMunitions = 16, - AmmoTypes = new() { AerosolType.ActiveMaterial } }, - }; - var result = new DefaultDefensePlanner(TestAmmo).Plan( - units, new() { MakeThreat(PowerType.Piston) }, new CombatScene()); + var unit = MakeGroundUnit("u0", 100000); // 极远 + var result = Plan(new() { unit }, MakeThreat()); Assert.Equal(0, result.Best.ThreatsEngaged); } [Fact] - public void NoUnits_Unengaged() { Assert.Equal(1, Plan(unitCount: 0).Best.ThreatsUnengaged); } - - [Fact] - public void FireTime_Positive() + public void Edge_OutOfRange_Air() { - var result = Plan(PowerType.Piston, 60); - Assert.NotEmpty(result.Best.MergedSchedule); - Assert.All(result.Best.MergedSchedule, fe => Assert.True(fe.FireTime > 0)); + 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 FireTime_BeforeArrival() - { - var threat = MakeThreat(PowerType.Piston, 120); - var result = new DefaultDefensePlanner(TestAmmo).Plan( - MakeGroundUnits(1), new() { threat }, new CombatScene()); - float arrival = threat.GetArrivalTime(); - Assert.All(result.Best.MergedSchedule, fe => - Assert.True(fe.FireTime < arrival, $"FireTime={fe.FireTime:F0} >= {arrival:F0}")); - } + // ═══════════════════════════════════════ + // 多威胁 + // ═══════════════════════════════════════ [Fact] public void MultiThreat_BothEngaged() { - var threats = new List { MakeThreat(PowerType.Piston, 120), MakeThreat(PowerType.Jet, 300) }; - threats[0].GroupId = "g0"; threats[1].GroupId = "g1"; - var result = new DefaultDefensePlanner(TestAmmo).Plan(MakeGroundUnits(2), threats, new CombatScene()); + 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(PowerType.Piston, 120); + var result = Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat()); Assert.True(result.Critical.Assignments.Count > 0); Assert.True(result.Critical.OverallProbability >= 0.4f); } - - [Fact] - public void Best_ProbabilityHigherThanCritical() - { - var result = Plan(PowerType.Piston, 120); - Assert.True(result.Best.OverallProbability >= result.Critical.OverallProbability, - $"Best={result.Best.OverallProbability:P0} Critical={result.Critical.OverallProbability:P0}"); - } - - // ═══════════════════════════════════════ - // 空基弹道 - // ═══════════════════════════════════════ - - [Fact] - public void AirBased_Engaged() - { - var units = new List - { - new FireUnit { Id = "u0", Type = PlatformType.AirBased, - Position = new Vector3(3000, 2500, -2000), GunCount = 4, ChannelsPerGun = 4, - CruiseSpeed = 55, ReleaseAltitude = 1500, TotalMunitions = 16, - AmmoTypes = new() { AerosolType.InertGas } }, - }; - var result = new DefaultDefensePlanner(TestAmmo).Plan(units, new() { MakeThreat() }, new CombatScene()); - Assert.True(result.Best.ThreatsEngaged > 0); - } - - [Fact] - public void AirBased_FireTime_EarlierThanArrival() - { - // 空基需要飞行+下落时间,发射应远早于到达时间 - var units = new List - { - new FireUnit { Id = "u0", Type = PlatformType.AirBased, - Position = new Vector3(3000, 2500, -2000), GunCount = 4, ChannelsPerGun = 4, - CruiseSpeed = 55, ReleaseAltitude = 1500, TotalMunitions = 16, - AmmoTypes = new() { AerosolType.InertGas } }, - }; - var threat = MakeThreat(PowerType.Piston, 120); - var result = new DefaultDefensePlanner(TestAmmo).Plan(units, new() { threat }, new CombatScene()); - float arrivalTime = threat.GetArrivalTime(); - // 空基至少需要 (飞行距离/55 + 下落时间) 的提前量 - float minAdvance = 20f; // 至少提前 20 秒 - Assert.All(result.Best.MergedSchedule, fe => - Assert.True(fe.FireTime < arrivalTime - minAdvance, - $"空基FireTime={fe.FireTime:F1}, arrival={arrivalTime:F1}, 应至少提前{minAdvance}s")); - } - - [Fact] - public void AirBased_FireTime_EarlierThanGroundBased() - { - // 同样威胁,空基应比地基更早发射(需要飞行时间) - var threat = MakeThreat(PowerType.Piston, 120); - var airUnits = new List - { - new FireUnit { Id = "u0", Type = PlatformType.AirBased, - Position = new Vector3(3000, 2500, -2000), GunCount = 4, ChannelsPerGun = 4, - CruiseSpeed = 55, ReleaseAltitude = 1500, TotalMunitions = 16, - AmmoTypes = new() { AerosolType.InertGas } }, - }; - var groundUnits = MakeGroundUnits(1); - - var airResult = new DefaultDefensePlanner(TestAmmo).Plan(airUnits, new() { threat }, new CombatScene()); - var groundResult = new DefaultDefensePlanner(TestAmmo).Plan(groundUnits, new() { threat }, new CombatScene()); - - float airFirst = airResult.Best.MergedSchedule.Min(e => e.FireTime); - float groundFirst = groundResult.Best.MergedSchedule.Min(e => e.FireTime); - Assert.True(airFirst < groundFirst, - $"空基首发射={airFirst:F1}s, 地基首发射={groundFirst:F1}s, 空基应更早"); - } - - [Fact] - public void AirBased_NoMuzzleVelocityInFireEvent() - { - // 空基 FireEvent 的 MuzzleVelocity 应为 0(不适用) - var units = new List - { - new FireUnit { Id = "u0", Type = PlatformType.AirBased, - Position = new Vector3(3000, 2500, -2000), GunCount = 4, ChannelsPerGun = 4, - CruiseSpeed = 55, ReleaseAltitude = 1500, TotalMunitions = 16, - AmmoTypes = new() { AerosolType.InertGas } }, - }; - var threat = MakeThreat(PowerType.Piston, 120); - var result = new DefaultDefensePlanner(TestAmmo).Plan(units, new() { threat }, new CombatScene()); - Assert.All(result.Best.MergedSchedule, fe => - Assert.True(fe.MuzzleVelocity < 10f, - $"空基 MuzzleVelocity={fe.MuzzleVelocity}, 应≈0")); - } - - // ═══════════════════════════════════════ - // 集成验证 - // ═══════════════════════════════════════ - - [Fact] - public void FullPipeline_Piston_VerifiesRoundAllocation() - { - var units = MakeGroundUnits(1); // 1 单元 16 通道 - var threat = MakeThreat(PowerType.Piston, 200); - 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 = new DefaultDefensePlanner(TestAmmo).Plan(units, new() { threat }, new CombatScene { WindSpeed = 3 }); - Assert.True(result.Best.MergedSchedule.Count >= 8, - $"只有 {result.Best.MergedSchedule.Count} 发,预期 >= 8"); - Assert.All(result.Best.MergedSchedule, fe => - Assert.True(fe.PlatformIndex >= 0 && fe.PlatformIndex < units.Count)); - } - - // ═══════════════════════════════════════ - // helpers - // ═══════════════════════════════════════ - - private PlannerResult Plan(PowerType power = PowerType.Piston, float speed = 120f, int unitCount = 1) - { - return new DefaultDefensePlanner(TestAmmo).Plan( - unitCount > 0 ? MakeGroundUnits(unitCount) : new List(), - new() { MakeThreat(power, speed) }, - new CombatScene()); - } } } diff --git a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs index 46f199e..40797c3 100644 --- a/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/FullPipelineTests.cs @@ -289,7 +289,20 @@ namespace CounterDrone.Core.Tests }); _scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 }); + // 验证:空基 PlatformType 正确持久化 + var savedAir = _scenario.GetTaskDetail(_taskId)!; + Assert.All(savedAir.Equipment, eq => Assert.Equal((int?)0, eq.PlatformType)); + var eng = RunSimulation(8000); + + // 诊断:空基发射事件应有"空基"描述 + Assert.All(eng.Events.Where(e => e.Type == SimEventType.MunitionLaunched), + e => Assert.Contains("空基", e.Description)); + + // 验证引擎内 PlatformType + Assert.NotEmpty(eng.Events); + Assert.All(eng.Events.Where(e => e.Type == SimEventType.MunitionLaunched), + e => Assert.Contains("空基", e.Description)); var drone = eng.Drones[0]; var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched); var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated); diff --git a/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs b/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs index 3a734e6..cc0bbbc 100644 --- a/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs +++ b/test/unit/CounterDrone.Core.Tests/SimulationEngineTests.cs @@ -9,6 +9,7 @@ using CounterDrone.Core.Repository; using CounterDrone.Core.Services; using CounterDrone.Core.Simulation; using Xunit; +using SimEvent = CounterDrone.Core.Simulation.SimEvent; namespace CounterDrone.Core.Tests { @@ -141,5 +142,51 @@ namespace CounterDrone.Core.Tests var framePath = Path.Combine(_testDir, "frames", $"{_taskId}.db"); Assert.True(File.Exists(framePath)); } + + [Fact] + public void AirBased_LaunchesWithCorrectDescription() + { + var task = _scenarioService.CreateTask("t", ""); + _scenarioService.SaveScene(task.Id, new CombatScene()); + _scenarioService.SaveTarget(task.Id, new TargetConfig { GroupId = "d", Quantity = 1, TypicalSpeed = 60, TypicalAltitude = 300 }); + _scenarioService.SaveDeployment(task.Id, new List + { + new() { EquipmentRole = (int)EquipmentRole.LaunchPlatform, PlatformType = (int)PlatformType.AirBased, + Quantity = 1, PositionX = 0, PositionY = 2000, PositionZ = 0, + CruiseSpeed = 55, ReleaseAltitude = 1500, AerosolType = (int)AerosolType.InertGas, MunitionCount = 1 }, + }); + _scenarioService.SaveCloudDispersal(task.Id, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 300 }); + _scenarioService.SaveRoute(task.Id, "d", new RoutePlan(), new() { new() { PosX = 0, PosY = 300, PosZ = 0, Speed = 60 }, new() { PosX = 5000, PosY = 300, PosZ = 0, Speed = 60 } }); + + _engine.SetFireSchedule(new List { new() { FireTime = 0.1f, PlatformIndex = 0, TargetX = 2500, TargetY = 300, TargetZ = 0 } }); + _engine.Initialize(task.Id); + _engine.TimeScale = 10f; + + SimEvent launch = null; + for (int i = 0; i < 5000; i++) + { + _engine.Tick(1f / 20f); + launch = _engine.Events.FirstOrDefault(e => e.Type == SimEventType.MunitionLaunched); + if (launch != null) break; + if (_engine.State == SimulationState.Completed) break; + } + + Assert.NotNull(launch); + Assert.Contains("空基", launch.Description); + } + + [Fact] + public void GroundBased_LaunchesWithCorrectDescription() + { + SetupScenario(); + _engine.SetFireSchedule(new List { new() { FireTime = 0.1f, PlatformIndex = 0, TargetX = 1000, TargetY = 300, TargetZ = 0, MuzzleVelocity = 800 } }); + _engine.Initialize(_taskId); + + SimEvent launch = null; + for (int i = 0; i < 50; i++) { _engine.Tick(1f / 20f); launch = _engine.Events.FirstOrDefault(e => e.Type == SimEventType.MunitionLaunched); if (launch != null) break; } + + Assert.NotNull(launch); + Assert.Contains("计划发射", launch.Description); + } } }