CounterDroneBackend/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs
tian bf3452013d feat: 全部160测试通过(22s). 损伤模型RequiredExposure替代硬编码
- IDamageModel.RequiredExposureSeconds(TargetType,PowerType,AerosolType)
- InertGas: 1/(0.15*sensitivity), Piston 1.5→4.44s
- ActiveMaterial: (1-burst)/residual + burst threshold
- ActiveFuel: ∫BaseRate*e^(kT)解析解
- CalcRoundsNeeded/ComputeInterceptProbability/BuildCandidates 全部调用
- 场景缩短: Piston 5km/150km/h, Jet 5km/200km/h, AirBased 10km/150km/h
- 空基 test: Unit(1500,1000), hp=0 Destroyed
2026-06-13 19:37:27 +08:00

315 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using CounterDrone.Core.Algorithms;
using CounterDrone.Core.Models;
using Xunit;
using Xunit.Abstractions;
namespace CounterDrone.Core.Tests
{
public class DefensePlannerTests
{
private readonly ITestOutputHelper _output;
public DefensePlannerTests(ITestOutputHelper output) { _output = output; }
private static readonly List<AmmunitionSpec> TestAmmo = DefaultAmmunition.GetAll();
private static FireUnit MakeGroundUnit(string id, float posX, int munitions = 16)
=> new()
{
Id = id, Type = PlatformType.GroundBased,
Position = new Vector3(posX, 0, 50),
GunCount = 1, ChannelsPerGun = 16,
ChannelInterval = 0.1f,
MuzzleVelocity = 800, TotalMunitions = munitions, Cooldown = 5f,
AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel },
};
private static FireUnit MakeAirUnit(string id, float posX)
=> new()
{
Id = id, Type = PlatformType.AirBased,
Position = new Vector3(posX, 2500, -2000),
GunCount = 1, ChannelsPerGun = 16,
CruiseSpeed = 55, ReleaseAltitude = 1500,
TotalMunitions = 16, Cooldown = 5f,
AmmoTypes = new() { AerosolType.InertGas },
};
private static DroneGroup MakeThreat(PowerType power = PowerType.Piston, float speed = 120f,
float startX = 0, float endX = 10000, float alt = 500)
{
var t = new DroneGroup
{
GroupId = "default",
Target = new TargetConfig
{
TargetType = (int)TargetType.Piston, PowerType = (int)power,
Quantity = 1, TypicalSpeed = speed, TypicalAltitude = alt,
},
Waypoints = new List<Waypoint>
{
new() { PosX = startX, PosY = alt, PosZ = 100, 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] public void Priority_FasterThreat_RanksHigher()
{
var a = MakeThreat(speed: 60); a.ArrivalTime = 50; a.ThreatIndex = 2f;
var b = MakeThreat(speed: 500); b.ArrivalTime = 50; b.ThreatIndex = 16f;
Assert.True(b.Priority > a.Priority);
}
[Fact] public void Priority_EarlierThreat_RanksHigher()
{
var a = MakeThreat(); a.ArrivalTime = 20; a.ThreatIndex = 2f;
var b = MakeThreat(); b.ArrivalTime = 100; b.ThreatIndex = 2f;
Assert.True(a.Priority > b.Priority);
}
// ═══════════════════════════════════════
// 弹药匹配
// ═══════════════════════════════════════
[Fact] public void Ammo_Piston_MatchesInertGas()
=> Assert.Equal(AerosolType.InertGas,
Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(PowerType.Piston)).Best.Assignments[0].AmmoType);
[Fact] public void Ammo_Jet_MatchesActiveMaterial()
=> Assert.Equal(AerosolType.ActiveMaterial,
Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(PowerType.Jet, 500)).Best.Assignments[0].AmmoType);
[Fact] public void Ammo_Electric_MatchesInertGas()
=> Assert.Equal(AerosolType.InertGas,
Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(PowerType.Electric)).Best.Assignments[0].AmmoType);
// ═══════════════════════════════════════
// 地基弹道:每发 shellTime 因目标位置不同而不同
// ═══════════════════════════════════════
[Fact]
public void Ground_FireTime_VariesPerUnitPosition()
{
// 两个单元不同位置 → 飞行时间不同 → 发射时间不同
var u0 = MakeGroundUnit("u0", 5000);
var u1 = MakeGroundUnit("u1", 8000);
var threat = MakeThreat(speed: 120);
var r0 = Plan(new() { u0 }, threat);
var r1 = Plan(new() { u1 }, threat);
float t0 = r0.Best.MergedSchedule[0].FireTime;
float t1 = r1.Best.MergedSchedule[0].FireTime;
Assert.NotEqual(t0, t1); // 位置不同 → 弹道不同 → 发射时刻不同
}
[Fact]
public void MultiLane_3Drones_3Lanes_ExactOutput()
{
var threat = MakeThreat(speed: 200);
threat.Target.Quantity = 3;
threat.Route = new RoutePlan { FormationMode = (int)Models.FormationMode.Formation, LateralSpacing = 50 };
threat.Waypoints.Clear();
threat.Waypoints.Add(new Waypoint { PosX = 0, PosY = 500, PosZ = 0, Speed = 200 });
threat.Waypoints.Add(new Waypoint { PosX = 20000, PosY = 500, PosZ = 0, Speed = 200 });
var result = Plan(new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5000), MakeGroundUnit("u2", 5000) }, threat);
var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
float first = events[0].FireTime, last = events[^1].FireTime;
// 输出到控制台
_output.WriteLine($"=== 3无人机 3车道 Planner输出 ===");
_output.WriteLine($"总弹药: {events.Count}发, 跨度: {last-first:F2}s");
foreach (var e in events)
_output.WriteLine($" FireTime={e.FireTime:F2}s Y={e.TargetY:F0} PlatIdx={e.PlatformIndex}");
var dump = string.Join("\n", events.Select(e => $" FireTime={e.FireTime:F2}s Y={e.TargetY:F0} PlatIdx={e.PlatformIndex}"));
// 3 车道,弹药数取决于损伤模型
Assert.True(events.Count >= 21, $"应≥21发, 实际{events.Count}\n{dump}");
Assert.True(last - first > 0, $"跨度>0\n{dump}");
// 验证每个车道有独立的 TargetZ
var zs = events.Select(e => e.TargetZ).Distinct().ToList();
Assert.True(zs.Count >= 2, $"应有至少2个不同Z值(distinct lanes)\n{dump}");
}
[Fact]
public void Ground_AllFireTimesPositive() => Assert.All(
Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat()).Best.MergedSchedule,
fe => Assert.True(fe.FireTime > 0));
[Fact]
public void Ground_AllBeforeArrival()
{
var threat = MakeThreat();
float arrival = threat.GetArrivalTime();
Assert.All(Plan(new() { MakeGroundUnit("u0", 5000) }, threat).Best.MergedSchedule,
fe => Assert.True(fe.FireTime < arrival, $"FireTime={fe.FireTime:F1} >= arrival={arrival:F1}"));
}
[Fact]
public void Ground_MuzzleVelocity_InFireEvent()
=> Assert.All(Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat()).Best.MergedSchedule,
fe => Assert.Equal(800f, fe.MuzzleVelocity));
[Fact]
public void Ground_TargetX_OnRoute()
{
var threat = MakeThreat(startX: 0, endX: 10000);
Assert.All(Plan(new() { MakeGroundUnit("u0", 5000) }, threat).Best.MergedSchedule,
fe => Assert.True(fe.TargetX >= 0 && fe.TargetX <= 10000, $"TargetX={fe.TargetX:F0}"));
}
// ═══════════════════════════════════════
// 空基弹道:平台飞一次,所有弹药同飞行时间
// ═══════════════════════════════════════
[Fact]
public void AirBased_PlatformFliesOnce_SameFlightTime()
{
// 同一平台多通道:所有弹药飞行时间相同(平台停在投放点)
var unit = MakeAirUnit("u0", 3000);
var result = Plan(new() { unit }, MakeThreat(speed: 120));
var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
Assert.True(events.Count >= 3);
float expectedGap = Kinematics.CloudCoverInterval(40f, 120f, 1f);
for (int i = 1; i < events.Count; i++)
{
float gap = events[i].FireTime - events[i - 1].FireTime;
Assert.True(Math.Abs(gap - expectedGap) < 0.3f,
$"gap[{i}]={gap:F3}, expected {expectedGap:F2}±0.3");
}
}
[Fact]
public void AirBased_FireTime_BeforeArrival()
{
var threat = MakeThreat(speed: 120);
float arrival = threat.GetArrivalTime();
Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, threat).Best.MergedSchedule,
fe => Assert.True(fe.FireTime < arrival - 40f,
$"空基FireTime={fe.FireTime:F1} 应远早于 arrival={arrival:F1}"));
}
[Fact]
public void AirBased_MuzzleVelocity_Zero()
=> Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, MakeThreat()).Best.MergedSchedule,
fe => Assert.Equal(0f, fe.MuzzleVelocity));
[Fact]
public void AirBased_TargetX_OnRoute()
{
var threat = MakeThreat(startX: 0, endX: 10000);
Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, threat).Best.MergedSchedule,
fe => Assert.True(fe.TargetX >= 0 && fe.TargetX <= 10000, $"TargetX={fe.TargetX:F0}"));
}
// ═══════════════════════════════════════
// 分配逻辑
// ═══════════════════════════════════════
[Fact]
public void Allocation_RespectsTotalMunitions()
{
var unit = MakeGroundUnit("u0", 5000, munitions: 2); // 只能打 2 发
var result = Plan(new() { unit }, MakeThreat(speed: 200));
int totalFired = result.Best.Assignments.Sum(a => a.RoundsFired);
Assert.True(totalFired <= 2, $"fired={totalFired} > 2 munitions");
}
[Fact]
public void Allocation_MultiUnit_PoolsChannels()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) },
new() { MakeThreat(speed: 200) }, new CombatScene());
Assert.True(result.Best.ThreatsEngaged == 1);
Assert.True(result.Best.MergedSchedule.Count > 1);
}
[Fact]
public void Allocation_IncompatibleAmmo_NotEngaged()
{
var unit = new FireUnit
{
Id = "u0", Type = PlatformType.GroundBased,
Position = new Vector3(5000, 0, 50),
GunCount = 1, ChannelsPerGun = 16, MuzzleVelocity = 800,
TotalMunitions = 16,
AmmoTypes = new() { AerosolType.ActiveMaterial }, // 无 InertGas
};
var result = Plan(new() { unit }, MakeThreat(PowerType.Piston));
Assert.Equal(0, result.Best.ThreatsEngaged);
}
// ═══════════════════════════════════════
// 边界
// ═══════════════════════════════════════
[Fact] public void Edge_NoUnits_Unengaged() => Assert.Equal(1, new DefaultDefensePlanner(TestAmmo).Plan(new(), new() { MakeThreat() }, new CombatScene()).Best.ThreatsUnengaged);
[Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefaultDefensePlanner(TestAmmo).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene()).Best.ThreatsEngaged);
[Fact]
public void Edge_OutOfRange_Ground()
{
var unit = MakeGroundUnit("u0", 100000); // 极远
var result = Plan(new() { unit }, MakeThreat());
Assert.Equal(0, result.Best.ThreatsEngaged);
}
[Fact]
public void Edge_OutOfRange_Air()
{
var unit = new FireUnit
{
Id = "u0", Type = PlatformType.AirBased,
Position = new Vector3(100000, 2500, 100000), // 极远
GunCount = 1, ChannelsPerGun = 4, CruiseSpeed = 10, ReleaseAltitude = 1500,
TotalMunitions = 4, AmmoTypes = new() { AerosolType.InertGas },
};
var result = Plan(new() { unit }, MakeThreat());
Assert.Equal(0, result.Best.ThreatsEngaged);
}
// ═══════════════════════════════════════
// 多威胁
// ═══════════════════════════════════════
[Fact]
public void MultiThreat_BothEngaged()
{
var a = MakeThreat(PowerType.Piston, 120); a.GroupId = "g0";
var b = MakeThreat(PowerType.Jet, 300); b.GroupId = "g1";
var result = new DefaultDefensePlanner(TestAmmo).Plan(
new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) },
new() { a, b }, new CombatScene());
Assert.Equal(2, result.Best.ThreatsEngaged);
}
// ═══════════════════════════════════════
// 临界方案
// ═══════════════════════════════════════
[Fact]
public void Critical_HasAssignments()
{
var result = Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat());
Assert.True(result.Critical.Assignments.Count > 0);
Assert.True(result.Critical.OverallProbability >= 0.4f);
}
}
}