2026-07-29 14:40:06 +08:00
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-Equal($Expected , $Actual , [ string ] $Message ) {
if ( $Expected -ne $Actual ) { throw " $Message Expected= $Expected Actual= $Actual " }
}
function Assert-Near([double]$Expected , [ double ] $Actual , [ double ] $Tolerance , [ string ] $Message ) {
if ([ Math ]:: Abs ( $Expected - $Actual ) -gt $Tolerance ) {
throw " $Message Expected= $Expected Actual= $Actual Tolerance= $Tolerance "
}
}
function Get-RequiredType([string]$Name ) {
return $assembly . GetType ( $Name , $true )
}
2026-07-29 14:50:26 +08:00
function New-Map([bool]$WithObstacle ) {
2026-07-29 14:40:06 +08:00
$mapRequest = [ Activator ]:: CreateInstance ( $mapRequestType )
$mapRequest . Bounds = [ Activator ]:: CreateInstance ( $boundsType , @ ([ single]0, [single]5000, [single]0, [single ] 5000 ))
$mapRequest . ResolutionMm = [ single ] 50
2026-07-29 14:50:26 +08:00
if ( $WithObstacle ) {
$obstacle = [ Activator ]:: CreateInstance ( $rectangleType , @ ([ single]900, [single]1100, [single]400, [single ] 600 ))
$obstacles = [ Array ]:: CreateInstance ( $obstacleType , 1 )
$obstacles . SetValue ( $obstacle , 0 )
$source = [ Activator ]:: CreateInstance ( $manualSourceType , @ ( 'service-safety-obstacle' , [ long ] 1 , $true , $obstacles ))
$sources = [ Array ]:: CreateInstance ( $obstacleSourceType , 1 )
$sources . SetValue ( $source , 0 )
$mapRequest . ObstacleSources = $sources
}
else {
$mapRequest . AllowExplicitEmptyMap = $true
}
2026-07-29 14:40:06 +08:00
$map = [ Activator ]:: CreateInstance ( $mapFactoryType ). Create ( $mapRequest ). Map
2026-07-29 14:50:26 +08:00
Assert-True ( $null -ne $map ) 'Service test must create a planning map.'
2026-07-29 14:40:06 +08:00
return $map
}
2026-07-29 14:50:26 +08:00
function New-EmptyMap { return New-Map $false }
function New-CollidingMap { return New-Map $true }
2026-07-29 14:40:06 +08:00
function New-Vehicle {
$vehicle = [ Activator ]:: CreateInstance ( $vehicleType )
$vehicle . LengthMeters = [ double ] 0.20
$vehicle . WidthMeters = [ double ] 0.20
$vehicle . SafetyMarginMeters = [ double ] 0.0
$vehicle . MaximumCurvaturePerMeter = [ double ] 100.0
return $vehicle
}
function New-CoarsePoint (
[ double ] $X ,
[ double ] $Y ,
[ double ] $ArcLength ,
$Direction ,
[ double ] $BodyClearance = 1.0 ,
[ bool ] $IsGearSwitch = $false ) {
return [ Activator ]:: CreateInstance ( $coarsePointType , @ (
$X , $Y , [ double]0.0, [double ] 0.0 , $ArcLength , $Direction ,
[ double ] 0.0 , $BodyClearance , $IsGearSwitch , $coarseAnchor ))
}
function New-Configuration($Method = $cubicBSpline ) {
$configuration = [ Activator ]:: CreateInstance ( $configurationType )
$configuration . Method = $Method
return $configuration
}
2026-07-29 14:50:26 +08:00
function New-Request([object[]]$Points , $Configuration , $Map = $null ) {
if ( $null -eq $Map ) { $Map = New-EmptyMap }
2026-07-29 14:40:06 +08:00
$typedPoints = [ Array ]:: CreateInstance ( $coarsePointType , $Points . Count )
for ( $index = 0 ; $index -lt $Points . Count ; $index ++) {
$typedPoints . SetValue ( $Points [ $index ], $index )
}
$segments = [ Array ]:: CreateInstance ( $coarseSegmentType , 1 )
$segments . SetValue ([ Activator ]:: CreateInstance ( $coarseSegmentType , @ (
0 , $forward , 0 , ( $Points . Count - 1 ), $false , $false )), 0 )
2026-07-29 14:50:26 +08:00
return [ Activator ]:: CreateInstance ( $requestType , @ ( $typedPoints , $segments , $Map , ( New-Vehicle ), $Configuration ))
2026-07-29 14:40:06 +08:00
}
function New-StraightRequest($Configuration ) {
return New-Request @ (
( New-CoarsePoint 0.5 0.5 0.0 $forward ),
( New-CoarsePoint 1.5 0.5 1.0 $forward )) $Configuration
}
function New-InfeasibleRequest($Configuration ) {
# Zero declared movement clearance makes every non-linear B-spline displacement retryably infeasible,
# while the empty map still permits the independently revalidated coarse-path fallback.
return New-Request @ (
( New-CoarsePoint 0.5 0.5 0.0 $forward 0.0 ),
( New-CoarsePoint 1.0 0.5 0.5 $forward 0.0 ),
( New-CoarsePoint 1.0 1.0 1.0 $forward 0.0 ),
( New-CoarsePoint 1.5 1.0 1.5 $forward 0.0 )) $Configuration
}
function Invoke-Smooth($Request , [ Threading.CancellationToken ] $CancellationToken = [ Threading.CancellationToken ]:: None ) {
return $smoothMethod . Invoke ( $service , @ ( $Request , $CancellationToken ))
}
function Assert-NoGeometry($Result , [ string ] $Message ) {
Assert-Equal 0 $Result . Path . Count " $Message A non-published result must not expose a path."
Assert-Equal 0 $Result . Segments . Count " $Message A non-published result must not expose segments."
}
function Assert-InvalidInputBeforeRetry($Configuration , [ string ] $CaseName ) {
$result = Invoke-Smooth ( New-StraightRequest $Configuration )
Assert-Equal 'InvalidInput' $result . Status . ToString () " $CaseName must be rejected as invalid input."
Assert-NoGeometry $result $CaseName
Assert-Equal 0 $result . Diagnostics . RetryCount " $CaseName must be rejected before any smoothing retry."
Assert-Near 0.0 $result . Diagnostics . AcceptedStrength 0.0 " $CaseName must not accept a smoothing strength."
}
$root = 'MultiWheelC.TrajectoryPlanning.PathSmoothing.'
$facade = $root + 'Facade.'
$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
$serviceType = Get-RequiredType ( $facade + 'PathSmoothingService' )
$requestType = Get-RequiredType ( $root + 'PathSmoothingRequest' )
$resultType = Get-RequiredType ( $root + 'PathSmoothingResult' )
$configurationType = Get-RequiredType ( $root + 'PathSmoothingConfiguration' )
$methodType = Get-RequiredType ( $root + 'SmoothingMethod' )
$coarsePointType = Get-RequiredType ( $coarsePath + 'CoarsePathPoint' )
$coarseSegmentType = Get-RequiredType ( $coarsePath + 'PathSegment' )
$directionType = Get-RequiredType ( $coarsePath + 'TravelDirection' )
$coarsePointSourceType = Get-RequiredType ( $coarsePath + 'CoarsePathPointSource' )
$vehicleType = Get-RequiredType ( $coarsePath + 'VehicleParameters' )
$boundsType = Get-RequiredType ( $mapping + 'MapBoundsMm' )
2026-07-29 14:50:26 +08:00
$obstacleType = Get-RequiredType ( $mapping + 'IMapObstacle' )
$rectangleType = Get-RequiredType ( $mapping + 'AxisAlignedRectangleObstacle' )
$obstacleSourceType = Get-RequiredType ( $mapping + 'IMapObstacleSource' )
$manualSourceType = Get-RequiredType ( $mapping + 'ManualObstacleSource' )
2026-07-29 14:40:06 +08:00
$mapRequestType = Get-RequiredType ( $mapping + 'PlanningMapRequest' )
$mapFactoryType = Get-RequiredType ( $mapping + 'PlanningMapFactory' )
Assert-True $serviceType . IsPublic 'PathSmoothingService must be public.'
$service = [ Activator ]:: CreateInstance ( $serviceType )
$smoothMethod = $serviceType . GetMethod ( 'Smooth' , [ Type[] ] @ ( $requestType , [ Threading.CancellationToken ]))
Assert-True ( $null -ne $smoothMethod ) 'PathSmoothingService must expose Smooth(PathSmoothingRequest, CancellationToken).'
Assert-Equal $resultType $smoothMethod . ReturnType 'PathSmoothingService Smooth must return PathSmoothingResult.'
$forward = [ Enum ]:: Parse ( $directionType , 'Forward' )
$cubicBSpline = [ Enum ]:: Parse ( $methodType , 'CubicBSpline' )
$localCubicBezier = [ Enum ]:: Parse ( $methodType , 'LocalCubicBezier' )
$piecewiseQuintic = [ Enum ]:: Parse ( $methodType , 'PiecewiseQuintic' )
2026-08-09 22:13:18 +08:00
$localG2Quintic = [ Enum ]:: Parse ( $methodType , 'LocalG2Quintic' )
2026-07-29 14:40:06 +08:00
$coarseAnchor = [ Enum ]:: Parse ( $coarsePointSourceType , 'Start' )
# The public method registry must retain every stable enum-to-algorithm mapping.
foreach ( $method in @ ( $cubicBSpline , $localCubicBezier , $piecewiseQuintic )) {
$result = Invoke-Smooth ( New-StraightRequest ( New-Configuration $method ))
Assert-Equal 'Success' $result . Status . ToString () "A valid straight path must succeed for $method ."
Assert-Equal $method . ToString () $result . Method . ToString () "The result must retain the selected $method method."
Assert-True ( $result . Path . Count -gt 0 ) "A successful $method result must publish geometry."
Assert-True $result . Diagnostics . Metrics . IsFeasible "A successful $method result must publish feasible diagnostics."
}
2026-08-09 22:13:18 +08:00
# Local G2 is a dedicated service pipeline, but must remain available through the same public entrypoint.
$localG2Result = Invoke-Smooth ( New-StraightRequest ( New-Configuration $localG2Quintic ))
Assert-Equal 'NotNeeded' $localG2Result . Status . ToString () 'A straight Local G2 request must publish its verified baseline as NotNeeded.'
Assert-Equal 'LocalG2Quintic' $localG2Result . Method . ToString () 'Local G2 must retain the selected method.'
Assert-True ( $localG2Result . Path . Count -gt 0 ) 'NotNeeded Local G2 must publish verified geometry.'
2026-07-29 14:40:06 +08:00
# Every configuration scalar is checked before the options snapshot or retry runner starts.
$invalidConfigurationCases = @ (
[ PSCustomObject ] @ { Name = 'NaN output spacing' ; Mutate = { param ( $c ) $c . OutputSpacingMeters = [ double ]:: NaN } },
[ PSCustomObject ] @ { Name = 'zero output spacing' ; Mutate = { param ( $c ) $c . OutputSpacingMeters = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'infinite collision step' ; Mutate = { param ( $c ) $c . MaximumCollisionCheckStepMeters = [ double ]:: PositiveInfinity } },
[ PSCustomObject ] @ { Name = 'zero collision step' ; Mutate = { param ( $c ) $c . MaximumCollisionCheckStepMeters = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'NaN clearance reserve' ; Mutate = { param ( $c ) $c . MinimumClearanceReserveMeters = [ double ]:: NaN } },
[ PSCustomObject ] @ { Name = 'negative clearance reserve' ; Mutate = { param ( $c ) $c . MinimumClearanceReserveMeters = [ double ] -0.01 } },
[ PSCustomObject ] @ { Name = 'NaN smoothing strength' ; Mutate = { param ( $c ) $c . SmoothingStrength = [ double ]:: NaN } },
[ PSCustomObject ] @ { Name = 'zero smoothing strength' ; Mutate = { param ( $c ) $c . SmoothingStrength = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'NaN B-spline scale' ; Mutate = { param ( $c ) $c . CubicBSpline . EndpointTangentScale = [ double ]:: NaN } },
[ PSCustomObject ] @ { Name = 'zero B-spline scale' ; Mutate = { param ( $c ) $c . CubicBSpline . EndpointTangentScale = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'zero Bezier threshold' ; Mutate = { param ( $c ) $c . LocalCubicBezier . CornerHeadingThresholdRadians = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'over-pi Bezier threshold' ; Mutate = { param ( $c ) $c . LocalCubicBezier . CornerHeadingThresholdRadians = [ Math ]:: PI + 0.01 } },
[ PSCustomObject ] @ { Name = 'NaN Bezier window' ; Mutate = { param ( $c ) $c . LocalCubicBezier . MaximumWindowLengthMeters = [ double ]:: NaN } },
[ PSCustomObject ] @ { Name = 'zero Bezier window' ; Mutate = { param ( $c ) $c . LocalCubicBezier . MaximumWindowLengthMeters = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'infinite Bezier handle scale' ; Mutate = { param ( $c ) $c . LocalCubicBezier . HandleLengthRatio = [ double ]:: PositiveInfinity } },
[ PSCustomObject ] @ { Name = 'zero Bezier handle scale' ; Mutate = { param ( $c ) $c . LocalCubicBezier . HandleLengthRatio = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'NaN quintic knot spacing' ; Mutate = { param ( $c ) $c . PiecewiseQuintic . KnotSpacingMeters = [ double ]:: NaN } },
[ PSCustomObject ] @ { Name = 'zero quintic knot spacing' ; Mutate = { param ( $c ) $c . PiecewiseQuintic . KnotSpacingMeters = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'infinite minimum quintic knot spacing' ; Mutate = { param ( $c ) $c . PiecewiseQuintic . MinimumKnotSpacingMeters = [ double ]:: PositiveInfinity } },
[ PSCustomObject ] @ { Name = 'zero minimum quintic knot spacing' ; Mutate = { param ( $c ) $c . PiecewiseQuintic . MinimumKnotSpacingMeters = [ double ] 0.0 } },
[ PSCustomObject ] @ { Name = 'quintic knot spacing below minimum' ; Mutate = { param ( $c ) $c . PiecewiseQuintic . KnotSpacingMeters = [ double ] 0.05 ; $c . PiecewiseQuintic . MinimumKnotSpacingMeters = [ double ] 0.10 } }
)
foreach ( $case in $invalidConfigurationCases ) {
$configuration = New-Configuration
& $case . Mutate $configuration
Assert-InvalidInputBeforeRetry $configuration $case . Name
}
$unknownMethodConfiguration = New-Configuration ([ Enum ]:: ToObject ( $methodType , 99 ))
Assert-InvalidInputBeforeRetry $unknownMethodConfiguration 'unknown smoothing method'
$invalidCoarseConfiguration = New-Configuration
$invalidCoarseRequest = New-Request @ (
( New-CoarsePoint ([ double ]:: NaN ) 0.5 0.0 $forward ),
( New-CoarsePoint 1.5 0.5 1.0 $forward )) $invalidCoarseConfiguration
$invalidCoarseResult = Invoke-Smooth $invalidCoarseRequest
Assert-Equal 'InvalidInput' $invalidCoarseResult . Status . ToString () 'A non-finite coarse path coordinate must be invalid input.'
Assert-NoGeometry $invalidCoarseResult 'Invalid coarse path'
Assert-Equal 0 $invalidCoarseResult . Diagnostics . RetryCount 'Invalid coarse input must be rejected before retries.'
2026-07-29 14:50:26 +08:00
# A finite, structurally valid coarse path may still be unsafe for the requested map and vehicle.
# It must be rejected before method selection/retries and may never use fallback to publish the unsafe geometry.
$unsafeCoarseResult = Invoke-Smooth ( New-Request @ (
( New-CoarsePoint 0.5 0.5 0.0 $forward ),
( New-CoarsePoint 1.5 0.5 1.0 $forward )) ( New-Configuration ) ( New-CollidingMap ))
Assert-Equal 'InvalidInput' $unsafeCoarseResult . Status . ToString () 'A colliding coarse path must be invalid before smoothing starts.'
Assert-NoGeometry $unsafeCoarseResult 'Unsafe coarse path'
Assert-Equal 0 $unsafeCoarseResult . Diagnostics . RetryCount 'Unsafe coarse geometry must be rejected before retry execution.'
2026-07-29 14:40:06 +08:00
$cancelledConfiguration = New-Configuration
$cancellationSource = [ Threading.CancellationTokenSource ]:: new ()
$cancellationSource . Cancel ()
try {
$cancelledResult = Invoke-Smooth ( New-StraightRequest $cancelledConfiguration ) $cancellationSource . Token
Assert-Equal 'Cancelled' $cancelledResult . Status . ToString () 'Pre-cancelled smoothing must return the explicit cancellation result.'
Assert-NoGeometry $cancelledResult 'Cancelled smoothing'
Assert-Equal 0 $cancelledResult . Diagnostics . RetryCount 'Cancellation before execution must not start retries.'
}
finally {
$cancellationSource . Dispose ()
}
$withoutFallbackConfiguration = New-Configuration
$withoutFallbackConfiguration . AllowFallbackToCoarsePath = $false
$withoutFallbackResult = Invoke-Smooth ( New-InfeasibleRequest $withoutFallbackConfiguration )
Assert-Equal 'Infeasible' $withoutFallbackResult . Status . ToString () 'A retryably infeasible candidate without fallback must remain infeasible.'
Assert-NoGeometry $withoutFallbackResult 'Infeasible smoothing without fallback'
Assert-Equal 3 $withoutFallbackResult . Diagnostics . RetryCount 'Infeasible smoothing must exhaust the four configured strengths.'
Assert-Near 0.0 $withoutFallbackResult . Diagnostics . AcceptedStrength 0.0 'Infeasible smoothing must not accept a strength.'
$withFallbackConfiguration = New-Configuration
$withFallbackConfiguration . AllowFallbackToCoarsePath = $true
$withFallbackResult = Invoke-Smooth ( New-InfeasibleRequest $withFallbackConfiguration )
Assert-Equal 'FallbackToCoarsePath' $withFallbackResult . Status . ToString () 'A verified coarse path must return explicit fallback status.'
Assert-Equal 'CubicBSpline' $withFallbackResult . Method . ToString () 'Fallback must retain the originally selected method.'
Assert-True ( $withFallbackResult . Path . Count -gt 0 ) 'A verified fallback must publish the revalidated coarse geometry.'
Assert-True $withFallbackResult . Diagnostics . Metrics . IsFeasible 'Fallback must publish feasible shared-geometry diagnostics.'
foreach ( $point in $withFallbackResult . Path ) {
Assert-Equal 'CoarsePathFallback' $point . Source . ToString () 'Every fallback point must be explicitly labeled as coarse-path fallback.'
}
Assert-Equal $withoutFallbackResult . Diagnostics . RetryCount $withFallbackResult . Diagnostics . RetryCount 'Fallback must preserve retry diagnostics from the failed method.'
Assert-Near $withoutFallbackResult . Diagnostics . AcceptedStrength $withFallbackResult . Diagnostics . AcceptedStrength 0.0 'Fallback must preserve the failed method accepted-strength diagnostic.'
Assert-Equal $withoutFallbackResult . Diagnostics . TerminationReason $withFallbackResult . Diagnostics . TerminationReason 'Fallback must preserve the failed method termination reason.'
Write-Output 'Path smoothing service checks passed.'