CounterDroneBackend/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs
tian 7fa0268463 fix: 空基 FireEvent.MuzzleVelocity=0 + 空基弹道单元测试
- GenerateFireEventsAt: 空基时 MuzzleVelocity=0(而非800)
- 新增 AirBased_FireTime_EarlierThanArrival 验证提前量
- 新增 AirBased_FireTime_EarlierThanGroundBased 验证空基更早
- 新增 AirBased_NoMuzzleVelocityInFireEvent 验证 MuzzleVelocity=0
- 空基弹道计算本身正确(recommendedTiming - flightTime - fallTime)
2026-06-13 11:05:34 +08:00

348 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll();
private static List<FireUnit> MakeGroundUnits(int count = 2)
{
var units = new List<FireUnit>();
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> { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
});
return units;
}
private DroneGroup MakeThreat(PowerType powerType = PowerType.Piston, float speed = 120f)
{
return new DroneGroup
{
GroupId = "default",
Target = new TargetConfig
{
TargetType = (int)TargetType.Piston,
PowerType = (int)powerType,
Quantity = 1,
TypicalSpeed = speed,
TypicalAltitude = 500,
},
Waypoints = new List<Waypoint>
{
new Waypoint { PosX = 0, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed },
new Waypoint { PosX = 10000, PosY = 500, PosZ = 100, Altitude = 500, Speed = speed },
},
};
}
// ═══════════════════════════════════════
// 威胁排序
// ═══════════════════════════════════════
[Fact]
public void ThreatPriority_SpeedMatters()
{
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}");
}
[Fact]
public void ThreatPriority_TypeMatters()
{
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, 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 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 SingleUnit_AllChannelsUsed_IfNeeded()
{
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 events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
// 同一单元通道有 ChannelInterval=1s 错开
Assert.True(events.Count >= 2);
}
[Fact]
public void 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);
Assert.True(result.Best.MergedSchedule.Count > 1);
}
[Fact]
public void OneSalvo_NoCooldownNeeded()
{
// 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<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 } },
};
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);
}
// ═══════════════════════════════════════
// 过滤与边界
// ═══════════════════════════════════════
[Fact]
public void IncompatibleAmmo_NotEngaged()
{
var units = new List<FireUnit>
{
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);
}
[Fact]
public void NoUnits_Unengaged() { Assert.Equal(1, Plan(unitCount: 0).Best.ThreatsUnengaged); }
[Fact]
public void FireTime_Positive()
{
var result = Plan(PowerType.Piston, 60);
Assert.NotEmpty(result.Best.MergedSchedule);
Assert.All(result.Best.MergedSchedule, fe => Assert.True(fe.FireTime > 0));
}
[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<DroneGroup> { 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());
Assert.Equal(2, result.Best.ThreatsEngaged);
}
[Fact]
public void Critical_HasAssignments()
{
var result = Plan(PowerType.Piston, 120);
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<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());
}
}
}