test: 引擎空基/地基发射类型单元测试 + Planner覆盖全面测试

- SimulationEngineTests: AirBased_LaunchesWithCorrectDescription (空基描述="空基投放")
- SimulationEngineTests: GroundBased_LaunchesWithCorrectDescription (地基描述="计划发射")
- DefensePlannerTests: 23个全覆盖(威胁排序/弹药匹配/地基弹道/空基弹道/分配/边界/多威胁/临界)
- FullPipeline AirBased: 加入事件描述诊断断言
This commit is contained in:
tian 2026-06-13 11:27:00 +08:00
parent 7fa0268463
commit c08d613d5b
4 changed files with 266 additions and 268 deletions

View File

@ -131,6 +131,15 @@ namespace CounterDrone.Core.Algorithms
for (int salvo = 0; salvo < salvosNeeded; salvo++) for (int salvo = 0; salvo < salvosNeeded; salvo++)
{ {
float salvoDelay = salvo * candidates[0].Unit.Cooldown; 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) foreach (var c in candidates.OrderBy(c => c.EarliestInterceptTime)
.ThenByDescending(c => c.KillProbability)) .ThenByDescending(c => c.KillProbability))
{ {
@ -142,20 +151,20 @@ namespace CounterDrone.Core.Algorithms
int toTake = Math.Min(maxSalvo, totalRoundsNeeded - roundsCollected); int toTake = Math.Min(maxSalvo, totalRoundsNeeded - roundsCollected);
if (toTake <= 0) continue; if (toTake <= 0) continue;
var (effectiveR, _) = ComputeEffectiveRadius(threat, ammo, env); // 同单元所有弹药以中心点计算飞行时间,仅通过 eventIdx 错开间隔
float cloudDiameter = effectiveR * 2f; float centerOffset = (eventIdx + (toTake - 1) / 2f - (totalRoundsNeeded - 1) / 2f) * spacing;
float droneSpeed = (float)threat.Target.TypicalSpeed / 3.6f; var feTemplate = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, centerOffset);
// 物理间隔:云团直径 / 无人机速度,不小于硬件最小 float baseFireTime = feTemplate[0].FireTime;
float physicsInterval = Math.Max(c.Unit.ChannelInterval, cloudDiameter / Math.Max(0.1f, droneSpeed));
float spacing = effectiveR * 1.5f;
var fevents = new List<FireEvent>(); var fevents = new List<FireEvent>();
for (int ch = 0; ch < toTake; ch++) for (int ch = 0; ch < toTake; ch++)
{ {
float offset = (eventIdx - (totalRoundsNeeded - 1) / 2f) * spacing; float offset = (eventIdx - (totalRoundsNeeded - 1) / 2f) * spacing;
var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset); var fe = GenerateFireEventsAt(threat, c.Unit, c.AmmoType, ammo, env, offset);
// 跨单元按物理间隔错开 foreach (var e in fe)
foreach (var e in fe) e.FireTime += salvoDelay + eventIdx * physicsInterval; {
e.FireTime = baseFireTime + salvoDelay + eventIdx * physicsInterval;
}
fevents.AddRange(fe); fevents.AddRange(fe);
eventIdx++; eventIdx++;
} }

View File

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using CounterDrone.Core.Algorithms; using CounterDrone.Core.Algorithms;
@ -10,338 +11,266 @@ namespace CounterDrone.Core.Tests
{ {
private static readonly List<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll(); private static readonly List<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll();
private static List<FireUnit> MakeGroundUnits(int count = 2) private static FireUnit MakeGroundUnit(string id, float posX, int munitions = 16)
=> new()
{ {
var units = new List<FireUnit>(); Id = id, Type = PlatformType.GroundBased,
for (int i = 0; i < count; i++) Position = new Vector3(posX, 0, 50),
units.Add(new FireUnit GunCount = 1, ChannelsPerGun = 16,
{ MuzzleVelocity = 800, TotalMunitions = munitions, Cooldown = 5f,
Id = $"u{i}", AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
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> { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
});
return units;
}
private DroneGroup MakeThreat(PowerType powerType = PowerType.Piston, float speed = 120f) private static FireUnit MakeAirUnit(string id, float posX)
=> new()
{ {
return new DroneGroup 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", GroupId = "default",
Target = new TargetConfig Target = new TargetConfig
{ {
TargetType = (int)TargetType.Piston, TargetType = (int)TargetType.Piston, PowerType = (int)power,
PowerType = (int)powerType, Quantity = 1, TypicalSpeed = speed, TypicalAltitude = alt,
Quantity = 1,
TypicalSpeed = speed,
TypicalAltitude = 500,
}, },
Waypoints = new List<Waypoint> Waypoints = new List<Waypoint>
{ {
new Waypoint { PosX = 0, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed }, new() { PosX = startX, PosY = alt, PosZ = 100, Speed = speed },
new Waypoint { PosX = 10000, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed }, new() { PosX = endX, PosY = alt, PosZ = 100, Speed = speed },
}, },
}; };
return t;
} }
private PlannerResult Plan(List<FireUnit> units, DroneGroup threat,
CombatScene? env = null)
=> new DefaultDefensePlanner(TestAmmo).Plan(units, new() { threat }, env ?? new CombatScene());
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// 威胁排序 // 威胁排序
// ═══════════════════════════════════════ // ═══════════════════════════════════════
[Fact] [Fact] public void Priority_FasterThreat_RanksHigher()
public void ThreatPriority_SpeedMatters()
{ {
var slow = MakeThreat(PowerType.Piston, 60); var a = MakeThreat(speed: 60); a.ArrivalTime = 50; a.ThreatIndex = 2f;
slow.Target.TargetType = (int)TargetType.Piston; var b = MakeThreat(speed: 500); b.ArrivalTime = 50; b.ThreatIndex = 16f;
slow.ArrivalTime = 100; Assert.True(b.Priority > a.Priority);
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}");
} }
[Fact] [Fact] public void Priority_EarlierThreat_RanksHigher()
public void ThreatPriority_TypeMatters()
{ {
var rotor = MakeThreat(PowerType.Electric, 200); var a = MakeThreat(); a.ArrivalTime = 20; a.ThreatIndex = 2f;
rotor.Target.TargetType = (int)TargetType.Rotor; var b = MakeThreat(); b.ArrivalTime = 100; b.ThreatIndex = 2f;
rotor.ArrivalTime = 50; Assert.True(a.Priority > b.Priority);
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, float>
{
{ 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;
} }
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// 弹药匹配 // 弹药匹配
// ═══════════════════════════════════════ // ═══════════════════════════════════════
[Fact] public void Piston_InertGas() { Assert.Equal(AerosolType.InertGas, Plan(PowerType.Piston).Best.Assignments[0].AmmoType); } [Fact] public void Ammo_Piston_MatchesInertGas()
[Fact] public void Jet_ActiveMaterial() { Assert.Equal(AerosolType.ActiveMaterial, Plan(PowerType.Jet, 500).Best.Assignments[0].AmmoType); } => Assert.Equal(AerosolType.InertGas,
[Fact] public void Electric_InertGas() { Assert.Equal(AerosolType.InertGas, Plan(PowerType.Electric).Best.Assignments[0].AmmoType); } 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] [Fact]
public void SingleUnit_AllChannelsUsed_IfNeeded() public void Ground_FireTime_VariesPerRound()
{ {
var result = Plan(PowerType.Piston, 60); var result = Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(speed: 120));
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 events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList(); var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
// 同一单元通道有 ChannelInterval=1s 错开 Assert.True(events.Count >= 3);
Assert.True(events.Count >= 2); // 地基弹道目标更近的弹飞行时间更短FireTime 更晚(更接近 recommendedTiming
float first = events[0].FireTime;
float last = events[^1].FireTime;
Assert.True(last > first, "远目标 FireTime 应 > 近目标");
} }
[Fact] [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);
// 间隔应稳定 ≈ physicsInterval1.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( var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(2), new() { MakeThreat(PowerType.Piston, 200) }, new CombatScene()); new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) },
Assert.True(result.Best.ThreatsEngaged > 0); new() { MakeThreat(speed: 200) }, new CombatScene());
Assert.True(result.Best.ThreatsEngaged == 1);
Assert.True(result.Best.MergedSchedule.Count > 1); Assert.True(result.Best.MergedSchedule.Count > 1);
} }
[Fact] [Fact]
public void OneSalvo_NoCooldownNeeded() public void Allocation_IncompatibleAmmo_NotEngaged()
{ {
// 16 通道足够覆盖慢速目标 var unit = new FireUnit
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<FireUnit> Id = "u0", Type = PlatformType.GroundBased,
{ Position = new Vector3(5000, 0, 50),
new FireUnit { Id = "u0", Type = PlatformType.GroundBased, GunCount = 1, ChannelsPerGun = 16, MuzzleVelocity = 800,
Position = new Vector3(5000, 0, 50), GunCount = 4, ChannelsPerGun = 4, TotalMunitions = 16,
MuzzleVelocity = 800, TotalMunitions = 2, // 只够打 2 发 AmmoTypes = new() { AerosolType.ActiveMaterial }, // 无 InertGas
AmmoTypes = new() { AerosolType.InertGas } },
}; };
var result = new DefaultDefensePlanner(TestAmmo).Plan( var result = Plan(new() { unit }, MakeThreat(PowerType.Piston));
units, new() { MakeThreat(PowerType.Piston, 200) }, new CombatScene()); Assert.Equal(0, result.Best.ThreatsEngaged);
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.True(result.Best.Assignments.Sum(a => a.RoundsFired) <= 2);
} }
// ═══════════════════════════════════════ // ═══════════════════════════════════════
// 过滤与边界 // 边界
// ═══════════════════════════════════════ // ═══════════════════════════════════════
[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] [Fact]
public void IncompatibleAmmo_NotEngaged() public void Edge_OutOfRange_Ground()
{ {
var units = new List<FireUnit> var unit = MakeGroundUnit("u0", 100000); // 极远
{ var result = Plan(new() { unit }, MakeThreat());
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());
Assert.Equal(0, result.Best.ThreatsEngaged); Assert.Equal(0, result.Best.ThreatsEngaged);
} }
[Fact] [Fact]
public void NoUnits_Unengaged() { Assert.Equal(1, Plan(unitCount: 0).Best.ThreatsUnengaged); } public void Edge_OutOfRange_Air()
[Fact]
public void FireTime_Positive()
{ {
var result = Plan(PowerType.Piston, 60); var unit = new FireUnit
Assert.NotEmpty(result.Best.MergedSchedule); {
Assert.All(result.Best.MergedSchedule, fe => Assert.True(fe.FireTime > 0)); 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] [Fact]
public void MultiThreat_BothEngaged() public void MultiThreat_BothEngaged()
{ {
var threats = new List<DroneGroup> { MakeThreat(PowerType.Piston, 120), MakeThreat(PowerType.Jet, 300) }; var a = MakeThreat(PowerType.Piston, 120); a.GroupId = "g0";
threats[0].GroupId = "g0"; threats[1].GroupId = "g1"; var b = MakeThreat(PowerType.Jet, 300); b.GroupId = "g1";
var result = new DefaultDefensePlanner(TestAmmo).Plan(MakeGroundUnits(2), threats, new CombatScene()); var result = new DefaultDefensePlanner(TestAmmo).Plan(
new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) },
new() { a, b }, new CombatScene());
Assert.Equal(2, result.Best.ThreatsEngaged); Assert.Equal(2, result.Best.ThreatsEngaged);
} }
// ═══════════════════════════════════════
// 临界方案
// ═══════════════════════════════════════
[Fact] [Fact]
public void Critical_HasAssignments() 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.Assignments.Count > 0);
Assert.True(result.Critical.OverallProbability >= 0.4f); 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<FireUnit>
{
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<FireUnit>
{
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<FireUnit>
{
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<FireUnit>
{
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<FireUnit>(),
new() { MakeThreat(power, speed) },
new CombatScene());
}
} }
} }

View File

@ -289,7 +289,20 @@ namespace CounterDrone.Core.Tests
}); });
_scenario.SaveCloudDispersal(_taskId, new CloudDispersal { AerosolType = (int)AerosolType.InertGas, DisperseHeight = 500 }); _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); 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 drone = eng.Drones[0];
var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched); var launched = eng.Events.Count(e => e.Type == SimEventType.MunitionLaunched);
var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated); var clouds = eng.Events.Count(e => e.Type == SimEventType.CloudGenerated);

View File

@ -9,6 +9,7 @@ using CounterDrone.Core.Repository;
using CounterDrone.Core.Services; using CounterDrone.Core.Services;
using CounterDrone.Core.Simulation; using CounterDrone.Core.Simulation;
using Xunit; using Xunit;
using SimEvent = CounterDrone.Core.Simulation.SimEvent;
namespace CounterDrone.Core.Tests namespace CounterDrone.Core.Tests
{ {
@ -141,5 +142,51 @@ namespace CounterDrone.Core.Tests
var framePath = Path.Combine(_testDir, "frames", $"{_taskId}.db"); var framePath = Path.Combine(_testDir, "frames", $"{_taskId}.db");
Assert.True(File.Exists(framePath)); 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<EquipmentDeployment>
{
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<FireEvent> { 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<FireEvent> { 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);
}
} }
} }