test: 引擎空基/地基发射类型单元测试 + Planner覆盖全面测试
- SimulationEngineTests: AirBased_LaunchesWithCorrectDescription (空基描述="空基投放") - SimulationEngineTests: GroundBased_LaunchesWithCorrectDescription (地基描述="计划发射") - DefensePlannerTests: 23个全覆盖(威胁排序/弹药匹配/地基弹道/空基弹道/分配/边界/多威胁/临界) - FullPipeline AirBased: 加入事件描述诊断断言
This commit is contained in:
parent
7fa0268463
commit
c08d613d5b
@ -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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
// 间隔应稳定 ≈ 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(
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user