param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll')) $ErrorActionPreference = 'Stop' $assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath)) function Assert-True($Actual, [string]$Message) { if (-not $Actual) { throw $Message } } function Assert-Null($Actual, [string]$Message) { if ($null -ne $Actual) { throw $Message } } function Assert-False($Actual, [string]$Message) { if ($Actual) { throw $Message } } function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) { if ([Math]::Abs($Expected - $Actual) -gt 0.000001) { throw "$Message Expected=$Expected Actual=$Actual" } } function Assert-Equal($Expected, $Actual, [string]$Message) { if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" } } function Assert-Throws([scriptblock]$Action, [string]$Message) { try { & $Action } catch { return } throw $Message } function Find-Method($Type, [string]$Name, [Type[]]$ParameterTypes) { foreach ($candidate in $Type.GetMethods()) { if ($candidate.Name -ne $Name) { continue } $parameters = $candidate.GetParameters() if ($parameters.Length -ne $ParameterTypes.Length) { continue } $matches = $true for ($index = 0; $index -lt $parameters.Length; $index++) { if ($parameters[$index].ParameterType -ne $ParameterTypes[$index]) { $matches = $false break } } if ($matches) { return $candidate } } return $null } function Find-NonPublicStaticMethod($Type, [string]$Name, [Type[]]$ParameterTypes) { foreach ($candidate in $Type.GetMethods([Reflection.BindingFlags]'Static,NonPublic')) { if ($candidate.Name -ne $Name) { continue } $parameters = $candidate.GetParameters() if ($parameters.Length -ne $ParameterTypes.Length) { continue } $matches = $true for ($index = 0; $index -lt $parameters.Length; $index++) { if ($parameters[$index].ParameterType -ne $ParameterTypes[$index]) { $matches = $false; break } } if ($matches) { return $candidate } } return $null } function New-TestMap { $mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.' $boundsType = $assembly.GetType($mapping + 'MapBoundsMm', $true) $requestType = $assembly.GetType($mapping + 'PlanningMapRequest', $true) $factoryType = $assembly.GetType($mapping + 'PlanningMapFactory', $true) $bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000)) $request = [Activator]::CreateInstance($requestType) $request.Bounds = $bounds $request.ResolutionMm = [single]50 $request.AllowExplicitEmptyMap = $true $result = [Activator]::CreateInstance($factoryType).Create($request) Assert-True $result.Succeeded 'Test map must be created.' Assert-True $result.Map.PlanningReady 'Test map must be ready.' return $result.Map } function New-CornerBlockedMap { $mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.' $boundsType = $assembly.GetType($mapping + 'MapBoundsMm', $true) $requestType = $assembly.GetType($mapping + 'PlanningMapRequest', $true) $factoryType = $assembly.GetType($mapping + 'PlanningMapFactory', $true) $obstacleType = $assembly.GetType($mapping + 'IMapObstacle', $true) $circleType = $assembly.GetType($mapping + 'CircleObstacle', $true) $manualType = $assembly.GetType($mapping + 'ManualObstacleSource', $true) $sourceType = $assembly.GetType($mapping + 'IMapObstacleSource', $true) $bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]150, [single]0, [single]150)) $obstacles = [Array]::CreateInstance($obstacleType, 2) $obstacles.SetValue([Activator]::CreateInstance($circleType, @([single]75, [single]25, [single]0)), 0) $obstacles.SetValue([Activator]::CreateInstance($circleType, @([single]25, [single]75, [single]0)), 1) $sources = [Array]::CreateInstance($sourceType, 1) $sources.SetValue([Activator]::CreateInstance($manualType, @('corner-blocks', [long]1, $true, $obstacles)), 0) $request = [Activator]::CreateInstance($requestType) $request.Bounds = $bounds $request.ResolutionMm = [single]50 $request.ObstacleSources = $sources $request.AllowExplicitEmptyMap = $false $result = [Activator]::CreateInstance($factoryType).Create($request) Assert-True $result.Succeeded 'Corner-blocked map must be created.' Assert-True $result.Map.PlanningReady 'Corner-blocked map must be ready.' return $result.Map } $coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.' $search = $coarsePath + 'Search.' $primitiveType = $assembly.GetType($search + 'MotionPrimitive', $true) $generatorType = $assembly.GetType($search + 'MotionPrimitiveGenerator', $true) $goalCheckerType = $assembly.GetType($search + 'GoalToleranceChecker', $true) Assert-True ($primitiveType.GetProperty('ActualLengthMeters') -ne $null) 'MotionPrimitive must expose actual length.' Assert-True ($primitiveType.GetProperty('Points') -ne $null) 'MotionPrimitive must expose integration points.' Assert-True ($primitiveType.GetProperty('IsGoalTruncation') -ne $null) 'MotionPrimitive must expose goal truncation state.' $poseType = $assembly.GetType($coarsePath + 'Pose2D', $true) $requestType = $assembly.GetType($coarsePath + 'PlanningRequest', $true) $configurationType = $assembly.GetType($coarsePath + 'HybridAStarConfiguration', $true) $vehicleType = $assembly.GetType($coarsePath + 'VehicleParameters', $true) $directionType = $assembly.GetType($coarsePath + 'TravelDirection', $true) $goalDirectionType = $assembly.GetType($coarsePath + 'GoalDirectionConstraint', $true) $generate = Find-Method $generatorType 'Generate' @($poseType, [double], $directionType, $requestType) Assert-True ($generate -ne $null) 'MotionPrimitiveGenerator must expose Generate(start, curvature, direction, request).' $isSatisfied = Find-Method $goalCheckerType 'IsSatisfied' @($poseType, $poseType, $configurationType, $directionType, $goalDirectionType) Assert-True ($isSatisfied -ne $null) 'GoalToleranceChecker must expose the planned goal check.' $map = New-TestMap $vehicle = [Activator]::CreateInstance($vehicleType) $vehicle.LengthMeters = 0.20 $vehicle.WidthMeters = 0.20 $vehicle.SafetyMarginMeters = 0.0 $vehicle.MaximumCurvaturePerMeter = 1.0 $config = [Activator]::CreateInstance($configurationType) $config.PrimitiveLengthMeters = 0.50 $config.IntegrationStepMeters = 0.05 $config.MaximumCollisionCheckStepMeters = 0.025 $config.GoalPositionToleranceMeters = 0.001 $config.GoalHeadingToleranceRadians = 0.001 $config.CurvatureLevelCount = 5 $forward = [Enum]::Parse($directionType, 'Forward') $reverse = [Enum]::Parse($directionType, 'Reverse') $anyDirection = [Enum]::Parse($goalDirectionType, 'Any') $forwardOnly = [Enum]::Parse($goalDirectionType, 'Forward') $start = [Activator]::CreateInstance($poseType, @(1.0, 1.0, 0.0)) $generator = [Activator]::CreateInstance($generatorType) $request = [Activator]::CreateInstance($requestType) $request.Map = $map $request.Vehicle = $vehicle $request.Configuration = $config $request.Goal = [Activator]::CreateInstance($poseType, @(3.0, 1.0, 0.0)) $request.GoalDirection = $anyDirection $straight = $generate.Invoke($generator, @($start, 0.0, $forward, $request)) Assert-True ($straight -ne $null) 'Straight primitive must be generated.' Assert-Near 0.50 $straight.ActualLengthMeters 'Straight primitive must use the configured cap.' Assert-Near 1.50 $straight.End.X 'Straight primitive must use analytic integration.' Assert-Near 1.00 $straight.End.Y 'Straight primitive must not drift laterally.' Assert-Near 0.00 $straight.End.Heading 'Straight primitive must keep heading.' Assert-True ($straight.Points.Count -ge 20) 'Point spacing must honor the collision check step.' $previous = $start foreach ($point in $straight.Points) { $distance = [Math]::Sqrt(($point.X - $previous.X) * ($point.X - $previous.X) + ($point.Y - $previous.Y) * ($point.Y - $previous.Y)) Assert-True ($distance -le 0.025001) 'Point spacing must not exceed the map-safe collision step.' $previous = $point } $curve = $generate.Invoke($generator, @($start, 1.0, $forward, $request)) Assert-True ($curve -ne $null) 'Arc primitive must be generated.' Assert-Near ([Math]::Sin(0.50) + 1.0) $curve.End.X 'Arc X must use analytic integration.' Assert-Near (1.0 - [Math]::Cos(0.50) + 1.0) $curve.End.Y 'Arc Y must use analytic integration.' Assert-Near 0.50 $curve.End.Heading 'Arc heading must use signed distance.' $reversePrimitive = $generate.Invoke($generator, @($start, 0.0, $reverse, $request)) Assert-True ($reversePrimitive -ne $null) 'Reverse primitive must be generated.' Assert-Near 0.50 $reversePrimitive.End.X 'Reverse straight primitive must move behind the vehicle.' $request.Goal = [Activator]::CreateInstance($poseType, @(1.30, 1.0, 0.0)) $truncated = $generate.Invoke($generator, @($start, 0.0, $forward, $request)) Assert-True ($truncated -ne $null) 'Goal-truncated primitive must be generated.' Assert-Near 0.30 $truncated.ActualLengthMeters 'A 0.30m goal must truncate at its first internal point.' Assert-True $truncated.IsGoalTruncation 'Truncated primitive must record its goal source.' Assert-Near 1.30 $truncated.End.X 'Truncated primitive must end at the goal point.' $request.Goal = $start $zeroLength = $generate.Invoke($generator, @($start, 0.0, $forward, $request)) Assert-True ($zeroLength -ne $null) 'Start-at-goal must produce a zero-length candidate.' Assert-Near 0.0 $zeroLength.ActualLengthMeters 'Start-at-goal candidate must have zero length.' Assert-True $zeroLength.IsGoalTruncation 'Start-at-goal candidate must have goal truncation source.' $wrapPose = [Activator]::CreateInstance($poseType, @(1.0, 1.0, (-[Math]::PI + 0.0005))) $wrapGoal = [Activator]::CreateInstance($poseType, @(1.0, 1.0, ([Math]::PI - 0.0005))) $config.GoalHeadingToleranceRadians = 0.002 Assert-True $isSatisfied.Invoke($null, @($wrapPose, $wrapGoal, $config, $forward, $forwardOnly)) 'Goal heading tolerance must wrap across pi.' Assert-False $isSatisfied.Invoke($null, @($wrapPose, $wrapGoal, $config, $reverse, $forwardOnly)) 'Goal direction constraint must reject reverse entry.' $levelsMethod = Find-Method $generatorType 'GetCurvatureLevels' @($vehicleType, $configurationType) Assert-True ($levelsMethod -ne $null) 'MotionPrimitiveGenerator must expose curvature levels.' $levels = $levelsMethod.Invoke($generator, @($vehicle, $config)) Assert-True ($levels.Count -eq 5) 'Five configured curvature levels must be generated.' Assert-Near -1.0 $levels[0] 'First curvature level must be negative maximum.' Assert-Near 0.0 $levels[2] 'Middle curvature level must be straight.' Assert-Near 1.0 $levels[4] 'Last curvature level must be positive maximum.' $adjacent = Find-Method $generatorType 'AreCurvatureLevelsAdjacent' @([int], [int]) Assert-True ($adjacent -ne $null) 'MotionPrimitiveGenerator must expose curvature adjacency.' Assert-True $adjacent.Invoke($null, @(2, 3)) 'Neighboring curvature levels must be allowed.' Assert-False $adjacent.Invoke($null, @(2, 4)) 'Non-neighboring curvature levels must be rejected.' $heapGenericType = $assembly.GetType($search + 'BinaryMinHeap`1', $true) $heapType = $heapGenericType.MakeGenericType([string]) $heap = [Activator]::CreateInstance($heapType) $push = Find-Method $heapType 'Push' @([string], [double], [double], [double]) $pop = Find-Method $heapType 'Pop' @() Assert-True ($push -ne $null) 'BinaryMinHeap must expose Push(item, f, h, g).' Assert-True ($pop -ne $null) 'BinaryMinHeap must expose Pop().' $push.Invoke($heap, @('node-a', 8.0, 2.0, 1.0)) $push.Invoke($heap, @('node-b', 8.0, 1.0, 1.0)) $push.Invoke($heap, @('node-c', 8.0, 1.0, 3.0)) $push.Invoke($heap, @('node-d', 7.0, 9.0, 0.0)) $push.Invoke($heap, @('node-e', 8.0, 1.0, 3.0)) Assert-Equal 'node-d' $pop.Invoke($heap, @()) 'Lower F must win.' Assert-Equal 'node-c' $pop.Invoke($heap, @()) 'Equal F and H must prefer larger G.' Assert-Equal 'node-e' $pop.Invoke($heap, @()) 'Equal F, H and G must preserve insertion order.' Assert-Equal 'node-b' $pop.Invoke($heap, @()) 'Equal F must prefer lower H.' Assert-Equal 'node-a' $pop.Invoke($heap, @()) 'Remaining item must be returned last.' $firstDeterministicHeap = [Activator]::CreateInstance($heapType) $secondDeterministicHeap = [Activator]::CreateInstance($heapType) foreach ($deterministicHeap in @($firstDeterministicHeap, $secondDeterministicHeap)) { $push.Invoke($deterministicHeap, @('repeat-a', 5.0, 2.0, 1.0)) $push.Invoke($deterministicHeap, @('repeat-b', 5.0, 1.0, 1.0)) $push.Invoke($deterministicHeap, @('repeat-c', 5.0, 1.0, 3.0)) } $firstDeterministicOrder = @($pop.Invoke($firstDeterministicHeap, @()), $pop.Invoke($firstDeterministicHeap, @()), $pop.Invoke($firstDeterministicHeap, @())) -join ',' $secondDeterministicOrder = @($pop.Invoke($secondDeterministicHeap, @()), $pop.Invoke($secondDeterministicHeap, @()), $pop.Invoke($secondDeterministicHeap, @())) -join ',' Assert-Equal $firstDeterministicOrder $secondDeterministicOrder 'Equivalent heap inputs must have deterministic pop order.' $calculatorType = $assembly.GetType($search + 'SearchCostCalculator', $true) $calculator = [Activator]::CreateInstance($calculatorType) $calculate = Find-Method $calculatorType 'Calculate' @([double], $directionType, [bool], [double], [double], [int], [double], $configurationType) Assert-True ($calculate -ne $null) 'SearchCostCalculator must expose the planned equivalent-meter calculation.' $costConfig = [Activator]::CreateInstance($configurationType) $cost = $calculate.Invoke($calculator, @(2.0, $reverse, $true, 0.5, 1.0, 2, 0.25, $costConfig)) Assert-Near 4.55 $cost 'Reverse, gear, curvature and clearance costs must be accumulated.' $negativeWeightConfig = [Activator]::CreateInstance($configurationType) $negativeWeightConfig.CurvatureMagnitudeWeight = -0.01 Assert-Throws { $calculate.Invoke($calculator, @(1.0, $forward, $false, 0.0, 1.0, 0, 1.0, $negativeWeightConfig)) } 'Negative cost weights must be rejected.' $nonFiniteWeightConfig = [Activator]::CreateInstance($configurationType) $nonFiniteWeightConfig.ClearanceCostWeight = [double]::NaN Assert-Throws { $calculate.Invoke($calculator, @(1.0, $forward, $false, 0.0, 1.0, 0, 1.0, $nonFiniteWeightConfig)) } 'Non-finite cost weights must be rejected.' $dijkstraType = $assembly.GetType($search + 'GridDijkstraHeuristic', $true) $dijkstraConstructor = $dijkstraType.GetConstructor(@($map.GetType(), [int], [int])) Assert-True ($dijkstraConstructor -ne $null) 'GridDijkstraHeuristic must expose the planned grid constructor.' $getCost = Find-Method $dijkstraType 'GetCost' @([int], [int]) Assert-True ($getCost -ne $null) 'GridDijkstraHeuristic must expose GetCost(row, col).' $openHeuristic = $dijkstraConstructor.Invoke(@($map, 2, 2)) $expectedDiagonalCost = 2.0 * [Math]::Sqrt(2.0) * $map.ResolutionMeters Assert-Near $expectedDiagonalCost $getCost.Invoke($openHeuristic, @(0, 0)) 'Open-grid diagonal movement must cost sqrt(2) per cell.' $cornerBlockedMap = New-CornerBlockedMap $blockedHeuristic = $dijkstraConstructor.Invoke(@($cornerBlockedMap, 1, 1)) Assert-True ([double]::IsPositiveInfinity($getCost.Invoke($blockedHeuristic, @(0, 0)))) 'Diagonal corner cutting through two occupied orthogonal cells must be forbidden.' $operationBudgetType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Utils.PlanningOperationBudget', $true) $operationStopReasonType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Utils.PlanningOperationStopReason', $true) $budgetConstructor = $operationBudgetType.GetConstructor([Reflection.BindingFlags]'Instance,NonPublic', $null, @([Threading.CancellationToken], [TimeSpan]), $null) Assert-True ($budgetConstructor -ne $null) 'PlanningOperationBudget must expose its internal cancellation and timeout constructor.' $tryCreateDijkstra = Find-NonPublicStaticMethod $dijkstraType 'TryCreate' @($map.GetType(), [int], [int], $operationBudgetType, $dijkstraType.MakeByRefType(), $operationStopReasonType.MakeByRefType()) Assert-True ($tryCreateDijkstra -ne $null) 'GridDijkstraHeuristic must expose an internal budget-aware TryCreate method.' $expiredBudget = $budgetConstructor.Invoke(@([Threading.CancellationToken]::None, [TimeSpan]::Zero)) $dijkstraArguments = [object[]]@($map, 2, 2, $expiredBudget, $null, $null) $dijkstraCreated = $tryCreateDijkstra.Invoke($null, $dijkstraArguments) Assert-False $dijkstraCreated 'An expired total budget must stop Dijkstra construction.' Assert-Null $dijkstraArguments[4] 'Stopped Dijkstra construction must not publish a partial heuristic.' Assert-Equal 'TimedOut' $dijkstraArguments[5].ToString() 'Expired Dijkstra construction must report timeout.' $largeBounds = [Activator]::CreateInstance($assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.MapBoundsMm', $true), @([single]0, [single]40000, [single]0, [single]20000)) $largeMapRequest = [Activator]::CreateInstance($assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapRequest', $true)) $largeMapRequest.Bounds = $largeBounds $largeMapRequest.ResolutionMm = [single]20 $largeMapRequest.AllowExplicitEmptyMap = $true $largeMap = [Activator]::CreateInstance($assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapFactory', $true)).Create($largeMapRequest) Assert-True $largeMap.Succeeded 'Large empty map for Dijkstra cancellation must be created before cancellation starts.' $midDijkstraCancellation = New-Object Threading.CancellationTokenSource $midDijkstraBudget = $budgetConstructor.Invoke(@($midDijkstraCancellation.Token, [TimeSpan]::FromSeconds(2))) $midDijkstraCancellation.CancelAfter(1) $midDijkstraTimer = [Diagnostics.Stopwatch]::StartNew() $midDijkstraArguments = [object[]]@($largeMap.Map, 500, 1000, $midDijkstraBudget, $null, $null) $midDijkstraCreated = $tryCreateDijkstra.Invoke($null, $midDijkstraArguments) $midDijkstraTimer.Stop() Assert-False $midDijkstraCreated 'Cancellation during Dijkstra initialization must stop construction.' Assert-Null $midDijkstraArguments[4] 'Mid-Dijkstra cancellation must not publish a partial heuristic.' Assert-Equal 'Cancelled' $midDijkstraArguments[5].ToString() 'Mid-Dijkstra cancellation must retain cancellation status.' Assert-True ($midDijkstraTimer.Elapsed -lt [TimeSpan]::FromSeconds(2)) 'Dijkstra cancellation must return within the configured response bound.' Write-Output 'Coarse path search primitive checks passed.' $nodeType = $assembly.GetType($search + 'HybridAStarNode', $true) $nodeKeyType = $assembly.GetType($search + 'HybridAStarNodeKey', $true) $searchType = $assembly.GetType($search + 'HybridAStarSearch', $true) $searchResultType = $assembly.GetType($search + 'HybridAStarSearchResult', $true) $searchMethod = Find-Method $searchType 'Search' @($requestType, [Threading.CancellationToken]) Assert-True ($searchMethod -ne $null) 'HybridAStarSearch must expose Search(request, cancellationToken).' Assert-True ($searchResultType.GetProperty('Status') -ne $null) 'Search result must expose planning status.' Assert-True ($searchResultType.GetProperty('TerminationReason') -ne $null) 'Search result must expose its original termination reason.' Assert-True ($searchResultType.GetProperty('SuccessNodeIndex') -ne $null) 'Search result must expose the successful node index.' Assert-True ($searchResultType.GetProperty('ReopenedNodeCount') -ne $null) 'Search result must expose reopened node count.' Assert-True ($searchResultType.GetProperty('StaleOpenListEntryCount') -ne $null) 'Search result must expose stale Open List entry count.' Assert-True ($searchResultType.GetProperty('PeakOpenListCount') -ne $null) 'Search result must expose Open List peak count.' Assert-True ($nodeType.GetProperty('ParentNodeIndex') -ne $null) 'Search node must expose its parent node index.' Assert-True ($nodeKeyType.GetProperty('HeadingIndex') -ne $null) 'Search node key must contain a heading index.' Assert-True ($nodeKeyType.GetProperty('Direction') -ne $null) 'Search node key must contain a travel direction.' Assert-True ($nodeKeyType.GetProperty('CurvatureLevelIndex') -ne $null) 'Search node key must contain a curvature level index.' $goalCandidateProperty = $nodeType.GetProperty('IsGoalCandidate') Assert-True ($goalCandidateProperty -ne $null) 'Search node must mark a goal-truncated candidate explicitly.' $nodeConstructor = $nodeType.GetConstructor(@([int], $nodeKeyType, $poseType, [int], $primitiveType, [double], [double], [double], [bool])) Assert-True ($nodeConstructor -ne $null) 'Search node must accept the explicit goal-candidate marker.' $admissionMethod = $searchType.GetMethod('ShouldEnqueueSuccessor', [Reflection.BindingFlags]'NonPublic,Static') Assert-True ($admissionMethod -ne $null) 'Search must expose its private successor admission policy for reflection regression coverage.' $candidateStart = [Activator]::CreateInstance($poseType, @(1.000, 1.000, 0.0)) $candidateGoal = [Activator]::CreateInstance($poseType, @(1.024, 1.000, 0.0)) $candidatePoints = [Array]::CreateInstance($poseType, 1) $candidatePoints.SetValue($candidateGoal, 0) $candidatePrimitive = [Activator]::CreateInstance($primitiveType, @($candidateStart, $forward, 0.0, 0.024, $candidatePoints, [double[]]@(1.0), $true)) $sharedKey = [Activator]::CreateInstance($nodeKeyType, @(20, 20, 0, $forward, 2)) $normalNode = $nodeConstructor.Invoke(@(10, $sharedKey, $candidateStart, 0, $null, 0.0, 0.250, 0.0, $false)) $goalCandidateNode = $nodeConstructor.Invoke(@(11, $sharedKey, $candidateGoal, 10, $candidatePrimitive, 0.0, 0.300, 0.0, $true)) $dominanceConfiguration = [Activator]::CreateInstance($configurationType) $dominanceConfiguration.GoalPositionToleranceMeters = 0.001 $dominanceConfiguration.GoalHeadingToleranceRadians = 0.001 Assert-True $candidatePrimitive.IsGoalTruncation 'Regression setup must use a goal-truncated motion primitive.' Assert-True $normalNode.Key.Equals($goalCandidateNode.Key) 'Regression setup must share one discrete state key.' Assert-False $isSatisfied.Invoke($null, @($normalNode.Pose, $candidateGoal, $dominanceConfiguration, $forward, $forwardOnly)) 'Lower-G normal node must remain outside the continuous goal tolerance.' Assert-True $isSatisfied.Invoke($null, @($goalCandidateNode.Pose, $candidateGoal, $dominanceConfiguration, $forward, $forwardOnly)) 'Goal candidate must satisfy the continuous goal tolerance.' Assert-False $admissionMethod.Invoke($null, @($normalNode, 0.250)) 'Equal-cost normal state must be dominated by the best same-key label.' Assert-True $admissionMethod.Invoke($null, @($goalCandidateNode, 0.250)) 'Goal candidate must enter Open List even when a lower-G normal state has the same key.' function New-SearchRequest([object]$Goal, [object]$GoalDirection, [bool]$AllowReverse) { $searchRequest = [Activator]::CreateInstance($requestType) $searchRequest.Map = $map $searchRequest.Start = [Activator]::CreateInstance($poseType, @(1.0, 1.0, 0.0)) $searchRequest.Goal = $Goal $searchRequest.Vehicle = $vehicle $searchConfiguration = [Activator]::CreateInstance($configurationType) $searchConfiguration.PrimitiveLengthMeters = 0.50 $searchConfiguration.IntegrationStepMeters = 0.05 $searchConfiguration.MaximumCollisionCheckStepMeters = 0.025 $searchConfiguration.GoalPositionToleranceMeters = 0.001 $searchConfiguration.GoalHeadingToleranceRadians = 0.001 $searchConfiguration.CurvatureLevelCount = 5 $searchConfiguration.MaximumExpandedNodes = 10000 $searchConfiguration.SearchTimeout = [TimeSpan]::FromSeconds(2.0) $searchConfiguration.AllowReverse = $AllowReverse $searchRequest.Configuration = $searchConfiguration $searchRequest.GoalDirection = $GoalDirection return $searchRequest } $searcher = [Activator]::CreateInstance($searchType) $searchGoal = [Activator]::CreateInstance($poseType, @(1.20, 1.0, 0.0)) $searchRequest = New-SearchRequest $searchGoal $forwardOnly $false $searchResult = $searchMethod.Invoke($searcher, @($searchRequest, [Threading.CancellationToken]::None)) Assert-Equal 'Success' $searchResult.Status.ToString() 'Empty map forward search must succeed.' Assert-True ($null -ne $searchResult.SuccessNodeIndex) 'Success must be reported only with a popped goal candidate node.' Assert-True ($searchResult.ExpandedNodeCount -gt 0) 'A successful search must expand the selected goal candidate.' $atGoal = New-SearchRequest $searchRequest.Start $forwardOnly $false $atGoalResult = $searchMethod.Invoke($searcher, @($atGoal, [Threading.CancellationToken]::None)) Assert-Equal 'Success' $atGoalResult.Status.ToString() 'A zero-length goal candidate must enter and leave the open list successfully.' Assert-True ($null -ne $atGoalResult.SuccessNodeIndex) 'A zero-length goal candidate must have a node index.' $cancelledRequest = New-SearchRequest $searchGoal $forwardOnly $false $cancellationSource = New-Object Threading.CancellationTokenSource $cancellationSource.Cancel() $cancelledResult = $searchMethod.Invoke($searcher, @($cancelledRequest, $cancellationSource.Token)) Assert-Equal 'Cancelled' $cancelledResult.Status.ToString() 'Cancellation must be observed before node expansion.' Assert-True (-not [string]::IsNullOrWhiteSpace($cancelledResult.TerminationReason)) 'Cancelled search must retain a non-empty reason.' $limitedRequest = New-SearchRequest $searchGoal $forwardOnly $false $limitedRequest.Configuration.MaximumExpandedNodes = 0 $limitedResult = $searchMethod.Invoke($searcher, @($limitedRequest, [Threading.CancellationToken]::None)) Assert-Equal 'SearchNodeLimitExceeded' $limitedResult.Status.ToString() 'Node limit must be checked before expansion.' Assert-True (-not [string]::IsNullOrWhiteSpace($limitedResult.TerminationReason)) 'Node-limited search must retain a non-empty reason.' $timedOutRequest = New-SearchRequest $searchGoal $forwardOnly $false $timedOutRequest.Configuration.SearchTimeout = [TimeSpan]::Zero $timedOutResult = $searchMethod.Invoke($searcher, @($timedOutRequest, [Threading.CancellationToken]::None)) Assert-Equal 'SearchTimeout' $timedOutResult.Status.ToString() 'Timeout must be checked before expansion.' Assert-True (-not [string]::IsNullOrWhiteSpace($timedOutResult.TerminationReason)) 'Timed-out search must retain a non-empty reason.' Assert-False ($cancelledResult.TerminationReason -eq $limitedResult.TerminationReason) 'Cancelled and node-limited searches must retain different reasons.' Assert-False ($limitedResult.TerminationReason -eq $timedOutResult.TerminationReason) 'Node-limited and timed-out searches must retain different reasons.' $internalSearchMethod = $searchType.GetMethods([Reflection.BindingFlags]'Instance,NonPublic') | Where-Object { $_.Name -eq 'Search' -and $_.GetParameters().Length -eq 2 -and $_.GetParameters()[1].ParameterType -eq $operationBudgetType } | Select-Object -First 1 Assert-True ($internalSearchMethod -ne $null) 'Search must retain its internal shared-budget overload.' $internalErrorResult = $internalSearchMethod.Invoke($searcher, @($searchRequest, $null)) Assert-Equal 'InternalError' $internalErrorResult.Status.ToString() 'A missing internal budget must be mapped to InternalError.' Assert-True ($internalErrorResult.TerminationReason.Contains('ArgumentNullException')) 'Internal search errors must retain the exception type.' Write-Output 'Coarse path Hybrid A star search checks passed.'