CounterDroneBackend/test/unit/CounterDrone.Core.Tests/DefensePlannerTests.cs
tian 0ed714a730 refactor: 抛物线运动学前向计算 — 移除回退/反算,统一用 ParabolicPosition
Kinematics:
- CalculateLaunchAngle/ParabolicTimeOfFlight/ParabolicShellTime: 移除静默钳位和 45° 回退,输入无效直接抛异常
- 新增 ComputeParabolicRange(v₀,θ,Δy) → (射程,飞行时间) — 正问题
- 新增 ParabolicApex(v₀,θ) → (顶点高度,顶点时间)
- ParabolicShellTime 改为委托,不重复实现

MunitionEntity:
- launchAngle 必须由方案提供,不再反算
- _flightDuration 用 ComputeParabolicRange 正算
- 删除死代码 CommandFlyTo / 「到达投放点」路径

DefensePlanner:
- 地基用 InterceptCalculator 的 shellTime,不再用 ParabolicShellTime 反算

SimulationEngine:
- 删除空基「到达投放点」死代码

Tests: 240 通过 (KinematicsTests 38, DefensePlannerTests 36, SimulationEngineTests 12, MunitionEntityTests 3)
2026-06-17 10:45:56 +08:00

563 lines
28 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 = TestData.Ammo;
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 DroneWave MakeThreat(PowerType power = PowerType.Piston, float speed = 120f,
float startX = 0, float endX = 10000, float alt = 500)
{
var t = new DroneWave
{
WaveId = "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, DroneWave threat,
CombatScene? env = null)
=> new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(units, new() { threat }, env ?? new CombatScene(), new List<DetectionSource>());
// ═══════════════════════════════════════
// 威胁排序
// ═══════════════════════════════════════
[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()
{
// 不同位置 → InterceptCalculator 给出不同拦截弧/时间 → 发射时刻不同
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);
// 两个位置的 plan 都应有有效发射事件
Assert.NotEmpty(r0.Best.MergedSchedule);
Assert.NotEmpty(r1.Best.MergedSchedule);
float t0 = r0.Best.MergedSchedule[0].FireTime;
float t1 = r1.Best.MergedSchedule[0].FireTime;
// FireTime 必须 > 0在可发射窗口内
Assert.True(t0 > 0, $"u0 FireTime={t0:F1} 应>0");
Assert.True(t1 > 0, $"u1 FireTime={t1:F1} 应>0");
// 两个位置的 launchAngle 都来自 InterceptCalculator应为正值
Assert.True(r0.Best.MergedSchedule[0].LaunchAngle > 0);
Assert.True(r1.Best.MergedSchedule[0].LaunchAngle > 0);
}
[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_Equals_CruiseSpeed()
=> Assert.All(Plan(new() { MakeAirUnit("u0", 3000) }, MakeThreat()).Best.MergedSchedule,
fe => Assert.Equal(55f, 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(), new List<DetectionSource>());
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(), new List<DetectionSource>()).Best.ThreatsUnengaged);
[Fact] public void Edge_NoThreats_Empty() => Assert.Equal(0, new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(new() { MakeGroundUnit("u0", 5000) }, new(), new CombatScene(), new List<DetectionSource>()).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.WaveId = "g0";
var b = MakeThreat(PowerType.Jet, 300); b.WaveId = "g1";
var result = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(
new() { MakeGroundUnit("u0", 5000), MakeGroundUnit("u1", 5100) },
new() { a, b }, new CombatScene(), new List<DetectionSource>());
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 DroneWave
{
WaveId = "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 发散"));
}
// ═══════════════════════════════════════
// 探测驱动规划有探测设备时planner 基于探测边界算到达时间,发射时机改变
// ═══════════════════════════════════════
private static FireUnit MakeBlindGroundUnit(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 },
// 无探测能力RadarRange/EORange/IRange 默认 0
};
[Fact]
public void DetectionDriven_FireTime_UsesDetectionBoundary()
{
// 航路 X:0→10000无人机 200km/h。无探测时 planner 基于起点,到达中点 5000m = 90s
var threat = MakeThreat(speed: 200, startX: 0, endX: 10000);
var unit = MakeBlindGroundUnit("u0", 5000);
// 场景A无探测DetectArc=0上帝视角从起点算
var resultNoDet = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(
new() { unit }, new() { threat }, new CombatScene { Visibility = 10000 },
new List<DetectionSource>());
float fireNoDet = resultNoDet.Best.MergedSchedule[0].FireTime;
// 场景B探测源雷达 3000m部署 X=8000边界 X=5000 和 X=11000
// 无人机从 X=0 飞,在 X=5000 进入探测DetectArc=5000
// planner 基于 X=5000 算到达时间(拦截点 5000m 中点travelArc = 5000-5000 = 0
// 但中点 X=5000 = 探测边界travelArc=0 意味着来不及拦截
// 所以实际拦截点会不同。简化验证:有探测时 fireTime 不同(推迟或无法拦截)
var detSrc = new DetectionSource
{
Position = new Vector3(8000, 0, 0),
RadarRange = 3000, Accuracy = 50,
};
var resultDet = new DefensePlanner(TestAmmo, TestPlannerConfig.Instance).Plan(
new() { unit }, new() { threat }, new CombatScene { Visibility = 10000 },
new List<DetectionSource> { detSrc });
Assert.True(fireNoDet > 0, $"无探测 fireTime={fireNoDet}");
// 探测边界 X=5000 = 航路中点拦截点travelArc=0来不及拦截 → ThreatsUnengaged
// 这验证了 planner 确实基于探测边界算到达时间(而非上帝视角从起点算)
Assert.True(resultDet.Best.ThreatsUnengaged > 0,
$"探测边界=拦截点时应无法拦截(DetectArc=中点弧长)threatsUnengaged={resultDet.Best.ThreatsUnengaged}");
}
// ═══════════════════════════════════════
// HasInterceptWindow — 拦截窗口可行性(纯函数单元测试)
// ═══════════════════════════════════════
private static DroneWave QuickThreat(float typicalSpeed, float detectArc)
{
var t = MakeThreat(speed: typicalSpeed);
t.DetectArc = detectArc;
return t;
}
[Fact]
public void HasInterceptWindow_DetectArcPastMidpoint_ReturnsFalse()
{
// DetectArc=3000 > midArc=2500 → travelArc<0 → 探测在拦截点之后
var threat = QuickThreat(200f, 3000f);
Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f));
}
[Fact]
public void HasInterceptWindow_DetectArcAtMidpoint_ReturnsFalse()
{
// DetectArc=2500 = midArc → travelArc=0 → 时间窗口为零
var threat = QuickThreat(200f, 2500f);
Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f));
}
[Fact]
public void HasInterceptWindow_WindowTooShort_ReturnsFalse()
{
// travelArc=500, speed=200km/h=55.6m/s → timeAvail≈9s
// expansionTime=30s + deliveryTime=2s = 32s → 9s < 32s
var threat = QuickThreat(200f, 2000f); // DetectArc=2000, midArc=2500 → travel=500
Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 2f));
}
[Fact]
public void HasInterceptWindow_BarelyEnough_ReturnsTrue()
{
// travelArc=2000, speed=200km/h=55.6m/s → timeAvail≈36s
// expansionTime=30s + deliveryTime=5s = 35s → 36s > 35s
var threat = QuickThreat(200f, 500f); // DetectArc=500, midArc=2500 → travel=2000
Assert.True(DefensePlanner.HasInterceptWindow(threat, 2500f, 30f, 5f));
}
[Fact]
public void HasInterceptWindow_SlowDroneHasMoreTime_ReturnsTrue()
{
// travelArc=500, speed=60km/h=16.7m/s → timeAvail≈30s
// expansionTime=25s + deliveryTime=3s = 28s → 30s > 28s
var threat = QuickThreat(60f, 2000f); // slower drone, same geometry
Assert.True(DefensePlanner.HasInterceptWindow(threat, 2500f, 25f, 3f));
}
[Fact]
public void HasInterceptWindow_FastDroneHasLessTime_ReturnsFalse()
{
// travelArc=500, speed=300km/h=83.3m/s → timeAvail≈6s
// expansionTime=20s + deliveryTime=1s = 21s → 6s < 21s
var threat = QuickThreat(300f, 2000f);
Assert.False(DefensePlanner.HasInterceptWindow(threat, 2500f, 20f, 1f));
}
// ═══════════════════════════════════════
// 端到端:不可行方案返回清晰原因
// ═══════════════════════════════════════
}
}