CounterDroneBackend/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs
tian b957a5e2b4 feat: Planner 使用真实物理计算 + 完善单元测试 + 规则7
- ComputeEffectiveRadius: 基于 AmmunitionSpec 三阶段扩散模型
- CalcRoundsNeeded: 真实有效半径和弹药参数
- GenerateFireEvents: recommendedTiming = arrivalTime - expansionTime
- 候选概率基于真实覆盖计算
- 24 个单元测试覆盖威胁排序/弹药匹配/候选过滤/弹药数/时机/多威胁/空基
- AGENTS.md 规则7: 只跑相关测试,不跑全量
2026-06-13 09:30:04 +08:00

432 lines
18 KiB
C#

using System.Collections.Generic;
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 = 5)
{
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 * 50, 0, 50),
MuzzleVelocity = 800f,
TotalMunitions = 3,
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 Piston_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene { WindSpeed = 5 });
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.Contains("分配", result.Best.Summary);
}
[Fact]
public void Jet_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Jet, 500) },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
}
[Fact]
public void Electric_Engaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Electric) },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
}
[Fact]
public void Best_HigherThanCritical()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.True(result.Best.OverallProbability >= result.Critical.OverallProbability,
$"Best={result.Best.OverallProbability:P0} Critical={result.Critical.OverallProbability:P0}");
}
[Fact]
public void Has_FireSchedule()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.NotEmpty(result.Best.MergedSchedule);
}
[Fact]
public void Has_Assignments()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.NotEmpty(result.Best.Assignments);
}
[Fact]
public void Critical_Within50Percent()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
// Critical 方案概率标记为 0.5(阈值下限)
Assert.True(result.Critical.OverallProbability >= 0.45f);
Assert.True(result.Critical.Assignments.Count > 0);
}
[Fact]
public void NoUnits_ThreatsUnengaged()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
new List<FireUnit>(),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.Equal(1, result.Best.ThreatsUnengaged);
}
[Fact]
public void NoThreats_EmptyPlan()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup>(),
new CombatScene());
Assert.Equal(0, result.Best.ThreatsEngaged);
}
// ═══════════════════════════════════════════════
// 威胁排序
// ═══════════════════════════════════════════════
[Fact]
public void ThreatPriority_SpeedMatters()
{
var slow = MakeThreat(PowerType.Piston, 60);
slow.Target.TargetType = (int)TargetType.Piston;
slow.ArrivalTime = 100;
slow.ThreatIndex = CalcThreatIndexStatic(slow.Target);
var fast = MakeThreat(PowerType.Piston, 500);
fast.Target.TargetType = (int)TargetType.Piston;
fast.ArrivalTime = 100;
fast.ThreatIndex = CalcThreatIndexStatic(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 = CalcThreatIndexStatic(rotor.Target);
var jet = MakeThreat(PowerType.Piston, 200);
jet.Target.TargetType = (int)TargetType.HighSpeed;
jet.ArrivalTime = 50;
jet.ThreatIndex = CalcThreatIndexStatic(jet.Target);
Assert.True(jet.ThreatIndex > rotor.ThreatIndex,
$"jet={jet.ThreatIndex} rotor={rotor.ThreatIndex}");
}
private static float CalcThreatIndexStatic(TargetConfig target)
{
var typeCoef = new Dictionary<TargetType, float>
{
{ TargetType.HighSpeed, 4f }, { TargetType.FixedWing, 2f },
{ TargetType.Piston, 2f }, { TargetType.Rotor, 1f }, { TargetType.Electric, 1f },
}.GetValueOrDefault((TargetType)target.TargetType, 1f);
return typeCoef * (float)target.TypicalSpeed / 60f;
}
// ═══════════════════════════════════════════════
// 弹药匹配
// ═══════════════════════════════════════════════
[Fact]
public void AmmoMatch_Piston_GetsInertGas()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene());
Assert.Equal(AerosolType.InertGas, result.Best.Assignments[0].AmmoType);
}
[Fact]
public void AmmoMatch_Jet_GetsActiveMaterial()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(),
new List<DroneGroup> { MakeThreat(PowerType.Jet, 500) },
new CombatScene());
Assert.Equal(AerosolType.ActiveMaterial, result.Best.Assignments[0].AmmoType);
}
// ═══════════════════════════════════════════════
// 候选过滤
// ═══════════════════════════════════════════════
[Fact]
public void IncompatibleAmmo_NotEngaged()
{
var units = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.GroundBased,
Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800,
TotalMunitions = 3,
AmmoTypes = new() { AerosolType.ActiveMaterial } }, // 只有活性材料
};
// 活塞需要惰性气体 → 不兼容
var result = new DefaultDefensePlanner(TestAmmo).Plan(
units,
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene());
Assert.Equal(0, result.Best.ThreatsEngaged);
Assert.Equal(1, result.Best.ThreatsUnengaged);
}
[Fact]
public void OutOfRange_NotEngaged()
{
var units = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.GroundBased,
Position = new Vector3(100000, 0, 100000), MuzzleVelocity = 100, // 极远 + 极慢
TotalMunitions = 3,
AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial } },
};
var result = new DefaultDefensePlanner(TestAmmo).Plan(
units,
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene());
Assert.Equal(0, result.Best.ThreatsEngaged);
}
// ═══════════════════════════════════════════════
// 弹药数计算
// ═══════════════════════════════════════════════
[Fact]
public void RoundsNeeded_IncreasesWithSpeed()
{
var slow = MakeThreat(PowerType.Piston, 60);
var fast = MakeThreat(PowerType.Piston, 300);
var planner = new DefaultDefensePlanner(TestAmmo);
var r1 = planner.Plan(MakeGroundUnits(10), new() { slow }, new CombatScene());
var r2 = planner.Plan(MakeGroundUnits(10), new() { fast }, new CombatScene());
// 高速威胁需要更多弹药覆盖
Assert.True(r2.Best.Assignments[0].RoundsFired >= r1.Best.Assignments[0].RoundsFired,
$"slow={r1.Best.Assignments[0].RoundsFired} fast={r2.Best.Assignments[0].RoundsFired}");
}
[Fact]
public void RoundsNeeded_CappedByMunitionCount()
{
var units = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.GroundBased,
Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800,
TotalMunitions = 1, // 只能打 1 发
AmmoTypes = new() { AerosolType.InertGas } },
};
var result = new DefaultDefensePlanner(TestAmmo).Plan(
units,
new List<DroneGroup> { MakeThreat(PowerType.Piston, 300) },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.Equal(1, result.Best.Assignments[0].RoundsFired);
}
// ═══════════════════════════════════════════════
// 发射时机
// ═══════════════════════════════════════════════
[Fact]
public void FireTime_Positive()
{
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { MakeThreat() },
new CombatScene());
Assert.NotEmpty(result.Best.MergedSchedule);
Assert.All(result.Best.MergedSchedule, fe => Assert.True(fe.FireTime > 0));
}
[Fact]
public void FireTime_BeforeArrivalTime()
{
var threat = MakeThreat(PowerType.Piston, 120);
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { threat },
new CombatScene());
// 所有发射时机应在威胁到达之前
float arrivalTime = threat.GetArrivalTime();
Assert.All(result.Best.MergedSchedule, fe =>
Assert.True(fe.FireTime < arrivalTime,
$"FireTime={fe.FireTime:F0} >= ArrivalTime={arrivalTime:F0}"));
}
[Fact]
public void FireEvents_TargetsOnRoute()
{
var threat = MakeThreat(PowerType.Piston, 120);
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
new List<DroneGroup> { threat },
new CombatScene());
// 目标点应在航路起点和终点之间
Assert.All(result.Best.MergedSchedule, fe =>
{
Assert.True(fe.TargetX >= 0 && fe.TargetX <= 10000,
$"TargetX={fe.TargetX:F0} out of [0,10000]");
});
}
// ═══════════════════════════════════════════════
// 多威胁分配
// ═══════════════════════════════════════════════
[Fact]
public void MultiThreat_BothEngaged()
{
var threats = new List<DroneGroup>
{
MakeThreat(PowerType.Piston, 120),
MakeThreat(PowerType.Jet, 500),
};
threats[0].GroupId = "g0";
threats[1].GroupId = "g1";
var result = new DefaultDefensePlanner(TestAmmo).Plan(
MakeGroundUnits(10),
threats,
new CombatScene());
Assert.Equal(2, result.Best.ThreatsEngaged);
Assert.Equal(0, result.Best.ThreatsUnengaged);
}
[Fact]
public void MultiThreat_LimitedUnits_SomeUnengaged()
{
var threats = new List<DroneGroup>
{
MakeThreat(PowerType.Piston, 120),
MakeThreat(PowerType.Jet, 500),
MakeThreat(PowerType.Electric, 100),
};
threats[0].GroupId = "g0";
threats[1].GroupId = "g1";
threats[2].GroupId = "g2";
var units = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.GroundBased,
Position = new Vector3(5000, 0, 50), MuzzleVelocity = 800,
TotalMunitions = 1,
AmmoTypes = new() { AerosolType.InertGas, AerosolType.ActiveMaterial, AerosolType.ActiveFuel } },
};
var result = new DefaultDefensePlanner(TestAmmo).Plan(units, threats, new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.True(result.Best.ThreatsUnengaged > 0);
}
// ═══════════════════════════════════════════════
// 空基平台
// ═══════════════════════════════════════════════
[Fact]
public void AirBased_Engaged()
{
var units = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.AirBased,
Position = new Vector3(3000, 2500, -2000), CruiseSpeed = 55,
ReleaseAltitude = 1500, TotalMunitions = 3,
AmmoTypes = new() { AerosolType.InertGas } },
};
var result = new DefaultDefensePlanner(TestAmmo).Plan(
units,
new List<DroneGroup> { MakeThreat(PowerType.Piston) },
new CombatScene());
Assert.True(result.Best.ThreatsEngaged > 0);
Assert.NotEmpty(result.Best.MergedSchedule);
}
[Fact]
public void AirBased_FireTime_EarlierThanArrival()
{
var units = new List<FireUnit>
{
new FireUnit { Id = "u0", Type = PlatformType.AirBased,
Position = new Vector3(3000, 2500, -2000), CruiseSpeed = 55,
ReleaseAltitude = 1500, TotalMunitions = 3,
AmmoTypes = new() { AerosolType.InertGas } },
};
var threat = MakeThreat(PowerType.Piston, 120);
var result = new DefaultDefensePlanner(TestAmmo).Plan(
units,
new List<DroneGroup> { threat },
new CombatScene());
float arrivalTime = threat.GetArrivalTime();
Assert.All(result.Best.MergedSchedule, fe =>
Assert.True(fe.FireTime < arrivalTime,
$"FireTime={fe.FireTime:F0} >= ArrivalTime={arrivalTime:F0}"));
}
}
}