CounterDroneBackend/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs
tian 3aac0060e7 test: 修复 MunitionEntity/MultiLane 测试 + Jet/Piston 打印到桌面
- 删除 GroundBased_ArrivesAtTargetPosition(旧下行逻辑)
- 新增 ArrivesNearTarget_WithInterpolation(真实场景0.4s步长)
- MultiLane 改断言为 ≥27发+多个Z值
- Jet/Piston 测试结果写入桌面txt
2026-06-13 17:34:15 +08:00

315 lines
15 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 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_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 车道各需 9 发 × 3 = 27 发
Assert.True(events.Count >= 27, $"应≥27发, 实际{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 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);
}
}
}