# Coarse Path Manual Test Diagnostics Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 让 `[MovementTest(name = "粗路径规划")]` 支持每次输入正数秒总预算，并在失败时显示真实终止原因、搜索统计、总耗时和路径搜索耗时。

**Architecture:** `HybridAStarSearchResult` 在搜索边界保留原始终止原因，`HybridAStarPlanner` 负责把原因、资源限制和节点统计装配成公开诊断。`PlanningDiagnostics` 增加兼容的 `PathSearchElapsed`，MovementTest 只覆盖本次任务的超时配置并在图层和 Toast 中显示诊断；搜索、安全和路径发布规则保持不变。

**Tech Stack:** C# / .NET Standard 2.0、`System.Diagnostics.Stopwatch`、PowerShell 反射回归脚本、Clumsy `MovementTest`/Painter。

## Global Constraints

- 普通规划失败继续通过 `PlanningResult` 返回，不把超时、无解、碰撞或资源上限改成异常。
- `PlanningDiagnostics.Elapsed` 继续表示从 `CoarsePathPlanningService.Plan` 开始、包含建图的总耗时。
- `PlanningDiagnostics.PathSearchElapsed` 从搜索前预检通过后开始，包含二维启发式、Hybrid A*、回溯、装配和最终复核，不包含建图。
- 搜索前失败的 `PathSearchElapsed` 为 `TimeSpan.Zero`；搜索后的所有出口满足 `TimeSpan.Zero <= PathSearchElapsed <= Elapsed`。
- 手动超时只接受 `TimeSpan` 可表示范围内的有限正数秒；`0` 不表示不限时。
- 不修改碰撞步长、终点容差、Open List 排序、地图边界策略、倒车开关或成功路径发布条件。
- 不实现 Reeds-Shepp/Dubins 精确连接、横移、蟹行、原地旋转、平滑、速度规划或控制。
- 手动入口继续使用长 `0.80 m`、宽 `0.60 m`、安全余量 `0.05 m`、最小转弯半径 `1.20 m` 的固定演示车辆参数。
- 工作区已有未提交内容；每次提交只能暂存任务中明确列出的文件，不得暂存其他路径。

## File Structure

- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Search/HybridAStarSearch.cs` — 搜索结果保留发生位置一致的原始终止原因。
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Contracts/PlanningDiagnostics.cs` — 公开地图就绪后的独立路径搜索耗时。
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/HybridAStarPlanner.cs` — 计时搜索/发布阶段并组合原因、资源上限和节点统计。
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/MovementTest.CoarsePathTest.cs` — 读取单次超时并在图层/Toast 显示诊断。
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/README.md` — 记录手动预算、演示车辆、两类耗时和第一版能力边界。
- Modify: `ClumsyPilot/tests/verify_coarse_path_search.ps1` — 验证搜索原始原因和内部异常不再静默。
- Modify: `ClumsyPilot/tests/verify_coarse_path_integration.ps1` — 验证规划器诊断、路径搜索耗时和慢可行场景。
- Modify: `ClumsyPilot/tests/verify_coarse_path_ui.ps1` — 验证手动超时、图层/Toast 和 README 文本。

---

### Task 1: Preserve Search Termination Reasons

**Files:**
- Modify: `ClumsyPilot/tests/verify_coarse_path_search.ps1:302-391`
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Search/HybridAStarSearch.cs:15-318`

**Interfaces:**
- Consumes: `HybridAStarSearch.Search(PlanningRequest, CancellationToken)` 和内部 `Search(PlanningRequest, PlanningOperationBudget)`。
- Produces: `HybridAStarSearchResult.TerminationReason : string`，成功时为空，所有失败状态非空。
- Produces: `CreateResult(..., int? successNodeIndex, string terminationReason = null)`，未显式提供原因时按状态生成稳定中文原因。

- [ ] **Step 1: Add failing search-result reason assertions**

在搜索结果属性断言后加入：

```powershell
Assert-True ($searchResultType.GetProperty('TerminationReason') -ne $null) `
    'Search result must expose its original termination reason.'
```

在取消、节点上限和超时状态断言后分别加入：

```powershell
Assert-True (-not [string]::IsNullOrWhiteSpace($cancelledResult.TerminationReason)) `
    'Cancelled search must retain a non-empty reason.'
Assert-True (-not [string]::IsNullOrWhiteSpace($limitedResult.TerminationReason)) `
    'Node-limited search must retain a non-empty reason.'
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.'
```

在脚本末尾通过内部预算重载制造一个可重复的未预期错误，并断言异常类型没有被吞掉：

```powershell
$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.'
```

- [ ] **Step 2: Run the search script and verify RED**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_search.ps1
```

Expected: build succeeds; the script fails first with `Search result must expose its original termination reason.`

- [ ] **Step 3: Extend `HybridAStarSearchResult`**

Add the final constructor parameter, assignment, and public property:

```csharp
internal HybridAStarSearchResult(
    PlanningStatus status,
    IEnumerable<HybridAStarNode> nodes,
    int expandedNodeCount,
    int generatedNodeCount,
    int reopenedNodeCount,
    int staleOpenListEntryCount,
    int peakOpenListCount,
    int? successNodeIndex,
    string terminationReason)
{
    Status = status;
    Nodes = new ReadOnlyCollection<HybridAStarNode>(
        new List<HybridAStarNode>(nodes ?? Array.Empty<HybridAStarNode>()));
    ExpandedNodeCount = expandedNodeCount;
    GeneratedNodeCount = generatedNodeCount;
    ReopenedNodeCount = reopenedNodeCount;
    StaleOpenListEntryCount = staleOpenListEntryCount;
    PeakOpenListCount = peakOpenListCount;
    SuccessNodeIndex = status == PlanningStatus.Success ? successNodeIndex : null;
    TerminationReason = status == PlanningStatus.Success
        ? string.Empty
        : terminationReason ?? string.Empty;
}

/// <summary>搜索边界记录的原始终止原因；成功时为空字符串，失败时非空。</summary>
public string TerminationReason { get; }
```

- [ ] **Step 4: Centralize default reasons and retain exception details**

Replace `CreateResult` with:

```csharp
private static HybridAStarSearchResult CreateResult(
    PlanningStatus status,
    IEnumerable<HybridAStarNode> nodes,
    int expandedNodeCount,
    int generatedNodeCount,
    int reopenedNodeCount,
    int staleOpenListEntryCount,
    int peakOpenListCount,
    int? successNodeIndex,
    string terminationReason = null)
{
    string reason = status == PlanningStatus.Success
        ? string.Empty
        : terminationReason ?? GetDefaultTerminationReason(status);
    return new HybridAStarSearchResult(status, nodes, expandedNodeCount, generatedNodeCount, reopenedNodeCount,
        staleOpenListEntryCount, peakOpenListCount, successNodeIndex, reason);
}

private static string GetDefaultTerminationReason(PlanningStatus status)
{
    switch (status)
    {
        case PlanningStatus.Cancelled:
            return "Hybrid A* 搜索已取消。";
        case PlanningStatus.InvalidRequest:
            return "Hybrid A* 搜索请求缺少必要对象或包含非法数值。";
        case PlanningStatus.InvalidMap:
            return "Hybrid A* 搜索地图结构无效。";
        case PlanningStatus.MapNotReady:
            return "Hybrid A* 搜索地图尚未准备好。";
        case PlanningStatus.InvalidVehicleParameters:
            return "Hybrid A* 搜索车辆参数无效。";
        case PlanningStatus.InvalidCurvatureConfiguration:
            return "Hybrid A* 搜索曲率、离散、代价或资源配置无效。";
        case PlanningStatus.StartOutsideMap:
            return "Hybrid A* 搜索起始扩大车体不完全位于地图内。";
        case PlanningStatus.StartInCollision:
            return "Hybrid A* 搜索起始扩大车体与障碍物相交或擦边。";
        case PlanningStatus.GoalOutsideMap:
            return "Hybrid A* 搜索目标扩大车体不完全位于地图内。";
        case PlanningStatus.GoalInCollision:
            return "Hybrid A* 搜索目标扩大车体与障碍物相交或擦边。";
        case PlanningStatus.SearchTimeout:
            return "Hybrid A* 搜索使用的总规划预算已耗尽。";
        case PlanningStatus.SearchNodeLimitExceeded:
            return "Hybrid A* 搜索达到扩展节点上限。";
        case PlanningStatus.NoFeasiblePath:
            return "Hybrid A* 搜索的 Open List 已耗尽，未找到满足运动和碰撞约束的路径。";
        case PlanningStatus.BacktrackingFailed:
            return "Hybrid A* 成功节点无法回溯为完整父链。";
        case PlanningStatus.FinalValidationFailed:
            return "Hybrid A* 路径未通过最终复核。";
        case PlanningStatus.InternalError:
            return "Hybrid A* 搜索发生未预期内部错误。";
        default:
            return "Hybrid A* 搜索以未识别状态终止：" + status + "。";
    }
}
```

Change the two `NoFeasiblePath` exits so the caller can distinguish their location:

```csharp
if (openList.Count == 0)
    return CreateResult(PlanningStatus.NoFeasiblePath, nodes, expandedNodeCount, generatedNodeCount,
        reopenedNodeCount, staleOpenListEntryCount, peakOpenListCount, null,
        "二维启发式标记起点不可达目标，或起始方向无法进入 Open List。");
```

```csharp
return CreateResult(PlanningStatus.NoFeasiblePath, nodes, expandedNodeCount, generatedNodeCount,
    reopenedNodeCount, staleOpenListEntryCount, peakOpenListCount, null,
    "Hybrid A* 搜索的 Open List 已耗尽，未找到满足运动和碰撞约束的路径。");
```

Replace the catch block with:

```csharp
catch (Exception exception)
{
    string reason = "Hybrid A* 搜索内部错误：" + exception.GetType().Name +
        (string.IsNullOrEmpty(exception.Message) ? "。" : "。" + exception.Message);
    return CreateResult(PlanningStatus.InternalError, nodes, expandedNodeCount, generatedNodeCount,
        reopenedNodeCount, staleOpenListEntryCount, peakOpenListCount, null, reason);
}
```

- [ ] **Step 5: Run the search script and verify GREEN**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_search.ps1
```

Expected:

```text
Coarse path search primitive checks passed.
Coarse path Hybrid A star search checks passed.
```

- [ ] **Step 6: Commit the search reason contract**

```powershell
git add -- ClumsyPilot/ParkrobTrajplanner/CoarsePath/Search/HybridAStarSearch.cs ClumsyPilot/tests/verify_coarse_path_search.ps1
git commit -m "fix: retain coarse path search failure reasons"
```

### Task 2: Add Planner-Level Diagnostics and Path Search Timing

**Files:**
- Modify: `ClumsyPilot/tests/verify_coarse_path_integration.ps1:75-135,189-210,357-360`
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Contracts/PlanningDiagnostics.cs:6-60`
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/HybridAStarPlanner.cs:1-226`

**Interfaces:**
- Consumes: Task 1 `HybridAStarSearchResult.TerminationReason`.
- Produces: `PlanningDiagnostics.PathSearchElapsed : TimeSpan`.
- Produces: `BuildSearchFailureReason(HybridAStarSearchResult, HybridAStarConfiguration) : string`.
- Contract: planner success and search-stage failure satisfy `0 <= PathSearchElapsed <= Elapsed`; map/preflight failure remains zero.

- [ ] **Step 1: Add failing planner-diagnostic assertions**

After the existing successful planner diagnostic assertions add:

```powershell
Assert-True ($result.Diagnostics.GetType().GetProperty('PathSearchElapsed') -ne $null) `
    'Planner diagnostics must expose path-search elapsed time.'
Assert-True ($result.Diagnostics.PathSearchElapsed -ge [TimeSpan]::Zero) `
    'Successful planning must retain non-negative path-search time.'
Assert-True ($result.Diagnostics.PathSearchElapsed -le $result.Diagnostics.Elapsed) `
    'Path-search time must not exceed total elapsed time.'
```

After the invalid-map result assertions add:

```powershell
Assert-Equal ([TimeSpan]::Zero) $mapFailureResult.PlanningResult.Diagnostics.PathSearchElapsed `
    'Map failure must report zero path-search time.'
```

After creating a valid planner request, add a search-stage node-limit case:

```powershell
$nodeLimitedRequest = [Activator]::CreateInstance($requestType)
$nodeLimitedRequest.Map = $request.Map
$nodeLimitedRequest.Start = $request.Start
$nodeLimitedRequest.Goal = $request.Goal
$nodeLimitedRequest.Vehicle = $request.Vehicle
$nodeLimitedRequest.Configuration = [Activator]::CreateInstance($configurationType)
$nodeLimitedRequest.Configuration.MaximumExpandedNodes = 0
$nodeLimitedRequest.Configuration.SearchTimeout = [TimeSpan]::FromSeconds(2)
$nodeLimitedRequest.Configuration.GoalPositionToleranceMeters = 0.001
$nodeLimitedRequest.Configuration.GoalHeadingToleranceRadians = 0.001
$nodeLimitedRequest.Configuration.AllowReverse = $false
$nodeLimitedRequest.GoalDirection = [Enum]::Parse($goalDirectionType, 'Forward')
$nodeLimitedResult = $plan.Invoke($planner, @($nodeLimitedRequest, [Threading.CancellationToken]::None))
Assert-Equal 'SearchNodeLimitExceeded' $nodeLimitedResult.Status.ToString() `
    'A zero node limit must fail after planner preflight.'
Assert-True ($nodeLimitedResult.Diagnostics.PathSearchElapsed -ge [TimeSpan]::Zero) `
    'Search-stage node-limit failure must retain path-search time.'
Assert-True ($nodeLimitedResult.Diagnostics.PathSearchElapsed -le $nodeLimitedResult.Diagnostics.Elapsed) `
    'Failed path-search time must not exceed total elapsed time.'
Assert-True (-not [string]::IsNullOrWhiteSpace($nodeLimitedResult.Diagnostics.TerminationReason)) `
    'Planner diagnostics must retain a node-limit reason.'
```

Extend the no-path scenario assertions:

```powershell
Assert-True ($noPathResult.PlanningResult.Diagnostics.TerminationReason.Contains('Open List')) `
    'No-path diagnostics must retain the exact search exhaustion reason.'
Assert-True ($noPathResult.PlanningResult.Diagnostics.TerminationReason.Contains('扩展=')) `
    'No-path diagnostics must include search statistics.'
```

- [ ] **Step 2: Run integration checks and verify RED**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected: build succeeds; the script fails first with `Planner diagnostics must expose path-search elapsed time.`

- [ ] **Step 3: Extend `PlanningDiagnostics` compatibly**

Append the optional constructor parameter:

```csharp
TimeSpan pathSearchElapsed = default(TimeSpan)
```

Assign it after `Elapsed`:

```csharp
Elapsed = elapsed;
PathSearchElapsed = pathSearchElapsed;
TerminationReason = terminationReason ?? string.Empty;
```

Add the property:

```csharp
/// <summary>
/// 地图和起终点预检通过后，二维启发式、Hybrid A*、回溯、装配和最终复核的耗时；
/// 不含建图，搜索前失败时为零。
/// </summary>
public TimeSpan PathSearchElapsed { get; }
```

Update the constructor XML comment so `elapsed` is described as total service elapsed time and `pathSearchElapsed` as the map-ready path-production elapsed time.

- [ ] **Step 4: Start the path-search stopwatch at the exact boundary**

Add:

```csharp
using System.Diagnostics;
using System.Globalization;
```

Declare the stopwatch before the `try`:

```csharp
Stopwatch pathSearchStopwatch = null;
```

Start it immediately before invoking the search:

```csharp
pathSearchStopwatch = Stopwatch.StartNew();
HybridAStarSearchResult searchResult = _search.Search(request, budget);
```

For `searchResult == null`, search failure, backtracking failure, assembly failure and final validation failure, pass `pathSearchStopwatch` into `Failure`. On success, pass its elapsed value:

```csharp
return PlanningResult.Success(path, segments, CreateDiagnostics(searchResult, budget.Elapsed,
    pathLengthMeters, minimumClearanceMeters, string.Empty, pathSearchStopwatch.Elapsed));
```

Replace the planner catch block with:

```csharp
catch (Exception exception)
{
    string reason = "规划内部错误：" + exception.GetType().Name +
        (string.IsNullOrEmpty(exception.Message) ? "。" : "。" + exception.Message);
    return Failure(PlanningStatus.InternalError,
        budget ?? PlanningOperationBudget.Unlimited(CancellationToken.None),
        reason, null, pathSearchStopwatch);
}
```

- [ ] **Step 5: Compose actionable search diagnostics**

For non-success search results, use:

```csharp
if (searchResult.Status != PlanningStatus.Success)
    return Failure(searchResult.Status, budget,
        BuildSearchFailureReason(searchResult, request.Configuration),
        searchResult, pathSearchStopwatch);
```

Add:

```csharp
private static string BuildSearchFailureReason(HybridAStarSearchResult searchResult,
    HybridAStarConfiguration configuration)
{
    string reason = string.IsNullOrEmpty(searchResult.TerminationReason)
        ? "Hybrid A* 搜索以 " + searchResult.Status + " 状态终止。"
        : searchResult.TerminationReason;
    string limit = string.Empty;
    if (searchResult.Status == PlanningStatus.SearchTimeout)
    {
        limit = "总预算=" + configuration.SearchTimeout.TotalSeconds.ToString(
            "F3", CultureInfo.InvariantCulture) + "s；";
    }
    else if (searchResult.Status == PlanningStatus.SearchNodeLimitExceeded)
    {
        limit = "节点上限=" + configuration.MaximumExpandedNodes.ToString(
            CultureInfo.InvariantCulture) + "；";
    }

    return reason + limit +
        "扩展=" + searchResult.ExpandedNodeCount.ToString(CultureInfo.InvariantCulture) + "，" +
        "生成=" + searchResult.GeneratedNodeCount.ToString(CultureInfo.InvariantCulture) + "，" +
        "重开=" + searchResult.ReopenedNodeCount.ToString(CultureInfo.InvariantCulture) + "，" +
        "陈旧条目=" + searchResult.StaleOpenListEntryCount.ToString(CultureInfo.InvariantCulture) + "，" +
        "Open List峰值=" + searchResult.PeakOpenListCount.ToString(CultureInfo.InvariantCulture) + "。";
}
```

Replace the two helper signatures and bodies:

```csharp
private static PlanningResult Failure(PlanningStatus status, PlanningOperationBudget budget, string reason,
    HybridAStarSearchResult searchResult, Stopwatch pathSearchStopwatch = null)
{
    TimeSpan pathSearchElapsed = pathSearchStopwatch == null
        ? TimeSpan.Zero
        : pathSearchStopwatch.Elapsed;
    return PlanningResult.Failure(status, CreateDiagnostics(searchResult, budget.Elapsed, 0d, 0d,
        reason, pathSearchElapsed));
}

private static PlanningDiagnostics CreateDiagnostics(HybridAStarSearchResult searchResult, TimeSpan elapsed,
    double pathLengthMeters, double minimumClearanceMeters, string reason, TimeSpan pathSearchElapsed)
{
    return new PlanningDiagnostics(
        searchResult == null ? 0 : searchResult.ExpandedNodeCount,
        searchResult == null ? 0 : searchResult.GeneratedNodeCount,
        searchResult == null ? 0 : searchResult.ReopenedNodeCount,
        searchResult == null ? 0 : searchResult.StaleOpenListEntryCount,
        searchResult == null ? 0 : searchResult.PeakOpenListCount,
        pathLengthMeters,
        minimumClearanceMeters,
        elapsed,
        reason,
        pathSearchElapsed);
}
```

All search-preflight `Failure(...)` calls continue omitting the optional stopwatch and therefore report zero. All calls after `_search.Search` pass the running stopwatch.

- [ ] **Step 6: Run integration checks and verify GREEN**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected:

```text
Coarse path integration checks passed.
Coarse path facade checks passed.
Coarse path P1 scenario checks passed.
```

- [ ] **Step 7: Commit planner diagnostics**

```powershell
git add -- ClumsyPilot/ParkrobTrajplanner/CoarsePath/Contracts/PlanningDiagnostics.cs ClumsyPilot/ParkrobTrajplanner/CoarsePath/HybridAStarPlanner.cs ClumsyPilot/tests/verify_coarse_path_integration.ps1
git commit -m "feat: add actionable coarse path diagnostics"
```

### Task 3: Add Manual Timeout Input, UI Diagnostics, and Documentation

**Files:**
- Modify: `ClumsyPilot/tests/verify_coarse_path_ui.ps1:19-92`
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/MovementTest.CoarsePathTest.cs:104-190,340-519`
- Modify: `ClumsyPilot/ParkrobTrajplanner/CoarsePath/README.md:114-116,285-359`

**Interfaces:**
- Consumes: Task 2 `PlanningDiagnostics.PathSearchElapsed`.
- Produces: `ReadPositiveTimeoutInput(string) : TimeSpan`.
- Produces: `DrawStatus(string, CoarsePathPlanningJob, CoarsePathPlanningJobResult, PlanningGridMap)`.
- Contract: only the manual `[MovementTest(name = "粗路径规划")]` prompts for and overrides its request timeout; fixed scenarios keep their existing budgets.

- [ ] **Step 1: Add failing UI and README source checks**

After the current manual input assertions add:

```powershell
Assert-Match $source 'ReadPositiveTimeoutInput\s*\(' `
    'The manual UI must read a finite positive timeout.'
Assert-Match $source 'Configuration\.SearchTimeout\s*=\s*searchTimeout' `
    'The manual UI must apply the timeout to the current job.'
Assert-Match $source 'TimeSpan\.FromSeconds\s*\(' `
    'The manual timeout must convert seconds to TimeSpan.'
Assert-Match $source 'timeoutSeconds\s*<=\s*0' `
    'The manual timeout must reject zero and negative values.'
Assert-True (([regex]::Matches($source, 'PathSearchElapsed')).Count -ge 2) `
    'The status layer and Toast must both show path-search elapsed time.'
Assert-Match $source 'BuildToastMessage[\s\S]*TerminationReason' `
    'The failure Toast must include the termination reason.'
Assert-Match $source 'ExpandedNodeCount' `
    'The status layer must show expanded-node statistics.'
Assert-Match $source 'VehicleKinematics\.TryGetMaximumCurvaturePerMeter' `
    'The status layer must show the effective turning radius.'
```

Append these entries to `$readmeStructure`:

```powershell
'PathSearchElapsed',
'1.20 m',
'Reeds-Shepp',
```

- [ ] **Step 2: Run UI checks and verify RED**

Run:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_ui.ps1
```

Expected: FAIL with `The manual UI must read a finite positive timeout.`

- [ ] **Step 3: Read and apply one-shot manual timeout**

Add:

```csharp
using MultiWheelC.TrajectoryPlanning.CoarsePath.Vehicle;
```

In `CoarsePathPlanningTest.Test()`, read the timeout after goal heading and apply it after creating the job:

```csharp
double goalHeadingDeg = ReadFiniteInput("粗路径终点航向（世界 deg）");
TimeSpan searchTimeout = ReadPositiveTimeoutInput("粗路径规划总超时（秒，必须大于 0）");
IReadOnlyList<ManualCoarsePathObstacle> obstacles = ReadManualObstacles();
long snapshotVersion = obstacles.Count == 0 ? 0L :
    Interlocked.Increment(ref _nextManualObstacleSnapshotVersion);
CoarsePathPlanningJob job = CoarsePathScenarioFactory.CreateManualObstacleDemo(
    amrPose.x, amrPose.y, amrPose.th, goalXmm, goalYmm, goalHeadingDeg,
    obstacles, snapshotVersion);
job.Configuration.SearchTimeout = searchTimeout;
CoarsePathPlanningTestRunner.Run("AMR 位姿 + 手动终点 + 手动障碍物", job);
```

Add:

```csharp
private static TimeSpan ReadPositiveTimeoutInput(string prompt)
{
    double timeoutSeconds = ReadFiniteInput(prompt);
    if (timeoutSeconds <= 0d)
        throw new ArgumentOutOfRangeException(nameof(prompt), "输入必须为正数：" + prompt);
    try
    {
        return TimeSpan.FromSeconds(timeoutSeconds);
    }
    catch (OverflowException)
    {
        throw new ArgumentOutOfRangeException(nameof(prompt), "输入超出允许范围：" + prompt);
    }
}
```

- [ ] **Step 4: Show timings, search counts, vehicle configuration, and failure reason**

Change the call site to:

```csharp
DrawStatus(scenarioName, job, result, map);
```

Replace `DrawStatus` with:

```csharp
private static void DrawStatus(string scenarioName, CoarsePathPlanningJob job,
    CoarsePathPlanningJobResult result, PlanningGridMap map)
{
    float x = map == null ? 0f : map.Bounds.XMin + 150f;
    float y = map == null ? -250f : map.Bounds.YMin + 150f;
    string snapshot = map == null ? "无" : map.SnapshotId.ToString(CultureInfo.InvariantCulture);
    string resolution = map == null ? "无" :
        map.ResolutionMm.ToString("F0", CultureInfo.InvariantCulture) + " mm";
    PlanningDiagnostics diagnostics = result.PlanningResult.Diagnostics;
    string reason = diagnostics.TerminationReason ?? string.Empty;
    string turningRadius = "无";
    if (job != null && VehicleKinematics.TryGetMaximumCurvaturePerMeter(
        job.Vehicle, out double maximumCurvaturePerMeter))
    {
        turningRadius = (1d / maximumCurvaturePerMeter).ToString(
            "F2", CultureInfo.InvariantCulture) + " m";
    }

    Painter.DrawText(Color.White, "场景：" + scenarioName, x, y);
    Painter.DrawText(Color.White, "地图：" + result.MapResult.Status + "，缓存：" +
        result.MapResult.CacheHit + "，快照：" + snapshot, x, y + 120f);
    Painter.DrawText(Color.White, "栅格：" + resolution + "，规划：" +
        result.PlanningResult.Status + "，总耗时：" +
        diagnostics.Elapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) +
        " ms，路径搜索：" +
        diagnostics.PathSearchElapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) +
        " ms", x, y + 240f);
    Painter.DrawText(Color.White, "节点：扩展=" +
        diagnostics.ExpandedNodeCount.ToString(CultureInfo.InvariantCulture) + "，生成=" +
        diagnostics.GeneratedNodeCount.ToString(CultureInfo.InvariantCulture) + "，Open List峰值=" +
        diagnostics.PeakOpenListCount.ToString(CultureInfo.InvariantCulture), x, y + 360f);
    if (job != null && job.Vehicle != null)
    {
        Painter.DrawText(Color.White, "演示车辆：长=" +
            job.Vehicle.LengthMeters.ToString("F2", CultureInfo.InvariantCulture) + " m，宽=" +
            job.Vehicle.WidthMeters.ToString("F2", CultureInfo.InvariantCulture) + " m，余量=" +
            job.Vehicle.SafetyMarginMeters.ToString("F2", CultureInfo.InvariantCulture) +
            " m，最小转弯半径=" + turningRadius, x, y + 480f);
    }
    if (!string.IsNullOrEmpty(reason))
        Painter.DrawText(Color.LightYellow, "原因：" + reason, x, y + 600f);
}
```

Replace `BuildToastMessage` with:

```csharp
private static string BuildToastMessage(string scenarioName, CoarsePathPlanningJobResult result)
{
    PlanningDiagnostics diagnostics = result.PlanningResult.Diagnostics;
    string message = "粗路径[" + scenarioName + "]：地图=" + result.MapResult.Status +
        "，规划=" + result.PlanningResult.Status + "，总耗时=" +
        diagnostics.Elapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) +
        "ms，路径搜索=" +
        diagnostics.PathSearchElapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) + "ms";
    if (result.PlanningResult.Status != PlanningStatus.Success &&
        !string.IsNullOrEmpty(diagnostics.TerminationReason))
    {
        message += "，原因=" + diagnostics.TerminationReason;
    }
    return message + "。";
}
```

- [ ] **Step 5: Document the exact manual-test boundaries**

After “总预算与取消” add:

```markdown
### 总耗时与路径搜索耗时

`PlanningDiagnostics.Elapsed` 是从 `CoarsePathPlanningService.Plan` 开始的总耗时，包含
地图来源、缓存、栅格化、距离场和路径规划。`PathSearchElapsed` 是地图和起终点预检
通过后的路径搜索耗时，包含二维启发式、Hybrid A*、回溯、装配、方向分段和最终复核；
搜索开始前失败时为零。
```

In “AMR 位姿、手动终点与障碍物”, add:

```markdown
手动入口还要求输入一次“粗路径规划总超时”，单位为秒，只接受 `TimeSpan` 可表示范围内
的有限正数秒；`0`、负数、NaN、Infinity 或溢出值都会在启动规划前拒绝。该值只覆盖
本次 `CoarsePathPlanningJob.Configuration.SearchTimeout`，不会改变固定场景或全局默认值。

此入口使用固定演示车辆：长 `0.80 m`、宽 `0.60 m`、四周安全余量 `0.05 m`、最小转弯
半径 `1.20 m`。这些值不是从现场 AMR 配置读取的，判断现场可行性前必须确认车辆参数一致。
```

In “后台执行、停止与图层”, add:

```markdown
状态图层显示规划状态、总耗时、`PathSearchElapsed`（路径搜索耗时）、扩展/生成节点数、
Open List 峰值、失败原因和固定演示车辆参数。Toast 同时显示两种耗时，并在失败时附加
`TerminationReason`，因此超时、节点上限、无解、碰撞和内部错误不会只显示成泛化失败。
```

Extend “第一版限制” with:

```markdown
- Reeds-Shepp 或 Dubins 精确终点连接；
- 原地旋转。

当前只生成汽车式恒曲率前进/倒车原语，并允许在原语边界换向；未实现的 Reeds-Shepp、
横移、蟹行和原地旋转是整个粗规划核心的第一版能力边界，不是 MovementTest 单独关闭。
```

- [ ] **Step 6: Run UI checks and verify GREEN**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_ui.ps1
```

Expected:

```text
Coarse path P1 UI source checks passed.
```

- [ ] **Step 7: Commit the manual-test UX**

```powershell
git add -- ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/MovementTest.CoarsePathTest.cs ClumsyPilot/ParkrobTrajplanner/CoarsePath/README.md ClumsyPilot/tests/verify_coarse_path_ui.ps1
git commit -m "feat: expose coarse path test diagnostics"
```

### Task 4: Regress the Slow Feasible Case and Run the Full Suite

**Files:**
- Modify: `ClumsyPilot/tests/verify_coarse_path_integration.ps1:273-369`
- Verify: all files modified by Tasks 1-3

**Interfaces:**
- Consumes: Task 3 manual scenario factory and configurable `SearchTimeout`.
- Produces: regression proof that the formerly 5-second-limited feasible pose succeeds under a caller-selected longer budget.
- Contract: the regression uses a 30 秒 upper bound without asserting an exact wall-clock duration.

- [ ] **Step 1: Add the slow feasible-case regression**

After the manual empty-map factory assertions, add:

```powershell
$slowFeasibleJob = $factoryManual.Invoke($null, @(
    1000.0, 2000.0, 0.0, 1500.0, 2500.0, 90.0))
$slowFeasibleJob.Configuration.SearchTimeout = [TimeSpan]::FromSeconds(30)
$slowFeasibleJob.Configuration.MaximumExpandedNodes = 1000000
$slowFeasibleService = [Activator]::CreateInstance($serviceType)
$slowFeasibleResult = $servicePlan.Invoke(
    $slowFeasibleService, @($slowFeasibleJob, [Threading.CancellationToken]::None))
Assert-Equal 'Success' $slowFeasibleResult.PlanningResult.Status.ToString() `
    'The previously five-second-limited feasible pose must succeed with a caller-selected longer budget.'
Assert-True ($slowFeasibleResult.PlanningResult.Diagnostics.PathSearchElapsed -le
    $slowFeasibleResult.PlanningResult.Diagnostics.Elapsed) `
    'Slow feasible path-search time must remain within total elapsed time.'
```

- [ ] **Step 2: Run the integration script and confirm the regression passes**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
```

Expected:

```text
Coarse path integration checks passed.
Coarse path facade checks passed.
Coarse path P1 scenario checks passed.
```

The slow case is allowed up to 30 seconds and should normally complete near the observed 10-second baseline; do not assert an exact wall-clock value.

- [ ] **Step 3: Run all coarse-path and map regressions**

Run:

```powershell
dotnet build .\ClumsyPilot\ClumsyPilot.csproj --no-restore
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_adapter.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_factory.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_documentation.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_image.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_planning_map_test_config.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_collision.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_search.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_integration.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\ClumsyPilot\tests\verify_coarse_path_ui.ps1
```

Expected: build exits 0 and every script prints its existing `passed` summary without an unhandled exception.

- [ ] **Step 4: Inspect the final diff for scope and accidental edits**

Run:

```powershell
git diff -- ClumsyPilot/ParkrobTrajplanner/CoarsePath/Search/HybridAStarSearch.cs ClumsyPilot/ParkrobTrajplanner/CoarsePath/Contracts/PlanningDiagnostics.cs ClumsyPilot/ParkrobTrajplanner/CoarsePath/HybridAStarPlanner.cs ClumsyPilot/ParkrobTrajplanner/CoarsePath/Test/MovementTest.CoarsePathTest.cs ClumsyPilot/ParkrobTrajplanner/CoarsePath/README.md ClumsyPilot/tests/verify_coarse_path_search.ps1 ClumsyPilot/tests/verify_coarse_path_integration.ps1 ClumsyPilot/tests/verify_coarse_path_ui.ps1
```

Expected: only the approved reason propagation, timing, manual timeout, UI, documentation and tests are present; collision/search semantics and unrelated worktree files are unchanged.

- [ ] **Step 5: Perform the Clumsy UI acceptance**

Run `[MovementTest(name = "粗路径规划")]` with:

```text
Start from current AMR pose corresponding to: 1000 mm, 2000 mm, 0 deg
Goal X: 1500 mm
Goal Y: 2500 mm
Goal heading: 90 deg
Timeout: 30 seconds
Manual obstacle count: 0
```

Confirm:

```text
Planning status: Success
Toast and status layer both show total elapsed and path-search elapsed
Status layer shows expanded/generated/Open List peak and the fixed demo vehicle parameters
```

Then run an intentionally impossible barrier or a deliberately short positive timeout and confirm the Toast includes a non-empty reason.

- [ ] **Step 6: Commit the regression coverage**

```powershell
git add -- ClumsyPilot/tests/verify_coarse_path_integration.ps1
git commit -m "test: cover slow feasible coarse path planning"
```
