CounterDroneBackend/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs
tian c08d613d5b test: 引擎空基/地基发射类型单元测试 + Planner覆盖全面测试
- SimulationEngineTests: AirBased_LaunchesWithCorrectDescription (空基描述="空基投放")
- SimulationEngineTests: GroundBased_LaunchesWithCorrectDescription (地基描述="计划发射")
- DefensePlannerTests: 23个全覆盖(威胁排序/弹药匹配/地基弹道/空基弹道/分配/边界/多威胁/临界)
- FullPipeline AirBased: 加入事件描述诊断断言
2026-06-13 11:27:00 +08:00

277 lines
13 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;
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 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 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_VariesPerRound()
{
var result = Plan(new() { MakeGroundUnit("u0", 5000) }, MakeThreat(speed: 120));
var events = result.Best.MergedSchedule.OrderBy(e => e.FireTime).ToList();
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 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()
{
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);
}
}
}