CounterDroneBackend/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs
tian c08edbd2f9 feat: 火力单元通道模型 + 默认配置 + 引擎通道展开
- FireUnit: 新增 GunCount/ChannelsPerGun/ChannelInterval/检测设备字段
- EquipmentDeployment: 新增 GunCount/ChannelsPerGun/ChannelInterval
- DefaultFireUnits: 3 种默认火力单元(标准地基/重地基/空基)
- Planner: 逐通道齐射分配,ChannelInterval 错开,Cooldown 轮次
- Engine: PlatformEntity 按 GunCount×ChannelsPerGun 展开
- BuildFireUnits: 与引擎同步展开通道
- FullPipelineTests: 使用 DefaultFireUnits 配置
2026-06-13 10:42:08 +08:00

293 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.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}");
// 通道间隔错开,但同轮无 cooldown 级延迟
float first = events.Min(e => e.FireTime);
float last = events.Max(e => e.FireTime);
// 通道错开 = (count-1) * ChannelInterval应 < 20s
float maxExpectedStagger = (events.Count - 1) * 1f + 2f;
Assert.True(last - first < maxExpectedStagger,
$"spread={last-first:F1}s, expected<{maxExpectedStagger:F1}s");
}
[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 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());
}
}
}