- ComputeEffectiveRadius: 基于 AmmunitionSpec 三阶段扩散模型 - CalcRoundsNeeded: 真实有效半径和弹药参数 - GenerateFireEvents: recommendedTiming = arrivalTime - expansionTime - 候选概率基于真实覆盖计算 - 24 个单元测试覆盖威胁排序/弹药匹配/候选过滤/弹药数/时机/多威胁/空基 - AGENTS.md 规则7: 只跑相关测试,不跑全量
432 lines
18 KiB
C#
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}"));
|
|
}
|
|
}
|
|
}
|