CounterDroneBackend/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs
tian 3b8fb4ee86 refactor: code cleanup - warnings, dead code, naming
Warnings (9 to 0): events declared nullable, _cache nullable, IDamageModel param nullable.

Dead code removed: DefaultFormations class + FormationTemplate; AlgorithmTypes legacy types (ThreatProfile/DefenseRecommendation/DefenseSolution/MultiGroupRecommendation/RecommendedPlatform/RecommendedDetection); DroneEntity CurrentWaypointIndex/FormationOffsetX/Y/ApplyFormationOffset/wind params; Vector3 operators+Length+DistanceTo; CloudExpansionModel Phase2Duration/TimeToDensity; Kinematics EstimatedShellTime/GaussianConcentration; InterceptCandidate write-only fields (CoverageDuration/FlightTime/ShellFlightTime); ParticleParams EmitRate/ColorHex.

Naming: DefaultDefensePlanner -> DefensePlanner (only impl of IDefensePlanner); inCloudTime -> inCloudDistance (returns meters not seconds); DamageAssessment class summary fixed; wind comments clarified (drone wind removed vs cloud wind active).

Tests: 191 pass, 0 warnings.
2026-06-14 15:52:05 +08:00

434 lines
21 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;
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 DefensePlanner(TestAmmo, TestPlannerConfig.Instance).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 DefensePlanner(TestAmmo, TestPlannerConfig.Instance).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 DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new(), new() { MakeThreat() }, new CombatScene()).Best.ThreatsUnengaged);
[Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).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 DefensePlanner(TestAmmo, TestPlannerConfig.Instance).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);
}
// ═══════════════════════════════════════
// P1 风偏补偿:云团生成后会被风吹偏 windVec × expansionTime
// Planner 须逆风预置抛撒点,使云团漂移回无人机穿越点。
// tx,tz = 无人机穿越点(航路中点,不变)
// cloudGenX/Z = 抛撒点FireEvent.TargetX/Z含风偏补偿
// ═══════════════════════════════════════
[Fact]
public void WindOffset_NoWind_NoDirectionalBias()
{
// 无风时,无论 WindDirection 取何值,抛撒点都应相同
// (风偏补偿 = windVec × expansionTimewindVec=0 时补偿为 0
var threat = MakeThreat(startX: 0, endX: 10000);
// 风向取不同值,但风速都是 0
var envN = new CombatScene { WindSpeed = 0, WindDirection = (int)WindDirection.N };
var envE = new CombatScene { WindSpeed = 0, WindDirection = (int)WindDirection.E };
var rN = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, envN);
var rE = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, envE);
float xN = rN.Best.MergedSchedule[0].TargetX;
float xE = rE.Best.MergedSchedule[0].TargetX;
Assert.True(Math.Abs(xN - xE) < 1f,
$"无风时风向不应影响抛撒点N={xN:F1}, E={xE:F1}");
}
[Fact]
public void WindOffset_EastWind_TargetShiftedUpwind()
{
// 东风(WindDirection.E)WindToVector 返回 (speed, 0, 0),云团被吹向 +X。
// 抛撒点应逆风预置 → TargetX < 航路中点 X=5000
var threat = MakeThreat(startX: 0, endX: 10000);
var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.E };
var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 });
var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env);
float calmX = calmResult.Best.MergedSchedule[0].TargetX;
float windyX = windyResult.Best.MergedSchedule[0].TargetX;
Assert.True(windyX < calmX,
$"东风下抛撒点 X={windyX:F1} 应小于无风 X={calmX:F1}(逆风预置补偿)");
// 偏移量级合理性expansionTime≈30s风速 10m/s → 偏移约 300m
Assert.True(calmX - windyX > 100f,
$"偏移量 {calmX - windyX:F1}m 应有显著量级(期望 ~300m");
}
[Fact]
public void WindOffset_WestWind_TargetShiftedOpposite()
{
// 西风:风矢量 (-speed, 0, 0),云团被吹向 -X。
// 抛撒点应在 +X 方向TargetX > 中点),与东风对称
var threat = MakeThreat(startX: 0, endX: 10000);
var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.W };
var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 });
var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env);
float calmX = calmResult.Best.MergedSchedule[0].TargetX;
float windyX = windyResult.Best.MergedSchedule[0].TargetX;
Assert.True(windyX > calmX,
$"西风下抛撒点 X={windyX:F1} 应大于无风 X={calmX:F1}(与东风相反方向)");
}
[Fact]
public void WindOffset_NorthWind_ShiftsZ()
{
// 北风WindToVector 返回 (0, 0, speed),云团被吹向 +Z。
// 抛撒点应在 -Z 方向TargetZ 更小)
var threat = MakeThreat(startX: 0, endX: 10000);
threat.Waypoints[0].PosZ = 100;
threat.Waypoints[1].PosZ = 100;
var env = new CombatScene { WindSpeed = 10f, WindDirection = (int)WindDirection.N };
var calmResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, new CombatScene { WindSpeed = 0 });
var windyResult = Plan(new() { MakeGroundUnit("u0", 5000) }, threat, env);
float calmZ = calmResult.Best.MergedSchedule[0].TargetZ;
float windyZ = windyResult.Best.MergedSchedule[0].TargetZ;
Assert.True(windyZ < calmZ,
$"北风下抛撒点 Z={windyZ:F1} 应小于无风 Z={calmZ:F1}Z 方向逆风补偿)");
}
// ═══════════════════════════════════════
// 航路感知布局:云团沿真实航路方向分布,不写死 X 轴。
// Z 向航路南北飞的多发云团X 坐标应集中在航路 X 附近(非发散)。
// ═══════════════════════════════════════
[Fact]
public void RouteAware_ZDirectionRoute_CloudsOnRoute()
{
// Z 向航路:从 (5000,0) 飞向 (5000,10000),即沿 +Z 方向
// 之前 offset 写死 X 轴时,多发云团会沿 X 发散到 5000±N×spacing偏离航路
// 改用 RouteGeometry 后offset 沿航路切向(+Z云团应集中在 X=5000
var threat = new DroneGroup
{
GroupId = "default",
Target = new TargetConfig
{
TargetType = (int)TargetType.Piston, PowerType = (int)PowerType.Piston,
Quantity = 1, TypicalSpeed = 200, TypicalAltitude = 500,
},
Waypoints = new List<Waypoint>
{
new() { PosX = 5000, PosY = 500, PosZ = 0, Speed = 200 },
new() { PosX = 5000, PosY = 500, PosZ = 10000, Speed = 200 },
},
};
// 火力单元放在航路起点附近X=5000
var unit = MakeGroundUnit("u0", 5000);
var result = Plan(new() { unit }, threat, new CombatScene());
// 所有抛撒点的 X 应集中在 5000 附近(±少量风偏,此处无风)
// 若 offset 仍写死 X 轴,多发会沿 X 散开到 5000±N×40
Assert.All(result.Best.MergedSchedule, fe =>
Assert.True(Math.Abs(fe.TargetX - 5000) < 50f,
$"Z 向航路下抛撒点 X={fe.TargetX:F1} 应在航路 X=5000 附近±50m而非沿 X 发散"));
}
}
}