From 0292e077ffb701ef17d463db6baaa5cb0d180364 Mon Sep 17 00:00:00 2001 From: "yunxiao.zhu" Date: Mon, 27 Apr 2026 10:33:53 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(server):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E5=86=85=20OpenAPI=20=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E9=A1=B5=E5=8F=8A=E8=AF=8A=E6=96=AD=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增 DebugConsoleController,提供 /debug 纯内嵌调试页 - 零外部依赖,基于 Swagger JSON 自动生成各端点表单 - 与 Swagger:Enabled 同步开关,避免生产环境误暴露 * 启用 ,将 XML 注释注入 OpenAPI - 调试页与 Swagger UI 共用同一份端点标题和说明 * 为 Health/Status/LegacyHttpApi 控制器添加 Tags 分组 * 补充 VS Code launch.json 与 tasks.json,支持现场调试 * 新增 DebugConsoleEndpointTests 覆盖调试页基础响应 * 同步更新 README 进度与待办清单 --- .claude/settings.local.json | 14 + .vscode/launch.json | 67 + .vscode/tasks.json | 160 +++ README.md | 4 +- .../FANUC_LR_Mate_200iD_trajectories.json | 20 +- .../Controllers/DebugConsoleController.cs | 1207 +++++++++++++++++ .../Controllers/HealthController.cs | 1 + .../Controllers/LegacyHttpApiController.cs | 3 + .../Controllers/StatusController.cs | 30 +- .../Flyshot.Server.Host.csproj | 7 + src/Flyshot.Server.Host/Program.cs | 8 + .../DebugConsoleEndpointTests.cs | 141 ++ 12 files changed, 1650 insertions(+), 12 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs create mode 100644 tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..760813e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(git commit -m ':*)", + "Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln -v minimal 2>&1')", + "Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj -v minimal 2>&1')", + "Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test FlyshotReplacement.sln --no-build -v minimal 2>&1')", + "Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal)", + "Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj --no-build -v minimal)", + "Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json'\\)\\); json.load\\(open\\('.vscode/tasks.json'\\)\\); print\\('JSON valid.'\\)\")", + "Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json', encoding='utf-8'\\)\\); json.load\\(open\\('.vscode/tasks.json', encoding='utf-8'\\)\\); print\\('JSON valid.'\\)\")" + ] + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3793b36 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,67 @@ +{ + // VS Code 启动与调试配置 + // 依赖 C# 扩展(OmniSharp 或 C# Dev Kit)提供 coreclr 调试器。 + // 文档:https://code.visualstudio.com/docs/csharp/debugger-settings + "version": "0.2.0", + "configurations": [ + { + // 标准调试启动:编译并启动 Host,命中断点,浏览器自动打开首页 + "name": ".NET Core Launch (Host)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj", + "--no-launch-profile" + ], + "cwd": "${workspaceFolder}", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5190" + }, + "stopAtEntry": false, + "console": "internalConsole", + "preLaunchTask": "build", + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)", + "uriFormat": "%s" + } + }, + { + // 热重载调试启动:自动编译、自动重启、断点保留;迭代 Web / 控制器层时首选 + "name": ".NET Core Watch (Host)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj", + "--no-launch-profile" + ], + "cwd": "${workspaceFolder}", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5190" + }, + "stopAtEntry": false, + "console": "integratedTerminal", + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)", + "uriFormat": "%s" + } + }, + { + // 附加到正在运行的 dotnet 进程(如已手动 `dotnet run` 或 Windows Service 模式) + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..140df5e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,160 @@ +{ + // VS Code 任务配置 + // 文档:https://code.visualstudio.com/docs/editor/tasks + "version": "2.0.0", + "tasks": [ + { + // 构建整个解决方案,是 launch.json 启动前的默认 preLaunchTask + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/FlyshotReplacement.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-v", + "minimal" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile", + "presentation": { + "reveal": "silent", + "clear": true + } + }, + { + // 仅构建宿主项目,迭代 Web 层时比整解决方案快 + "label": "build-host", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "-v", + "minimal" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + // 还原 NuGet 包,新增引用或克隆后第一次打开时使用 + "label": "restore", + "command": "dotnet", + "type": "process", + "args": [ + "restore", + "${workspaceFolder}/FlyshotReplacement.sln" + ], + "problemMatcher": [] + }, + { + // 清理所有项目的 bin/obj + "label": "clean", + "command": "dotnet", + "type": "process", + "args": [ + "clean", + "${workspaceFolder}/FlyshotReplacement.sln" + ], + "problemMatcher": "$msCompile" + }, + { + // 跑全部测试(领域 + 集成) + "label": "test", + "command": "dotnet", + "type": "process", + "args": [ + "test", + "${workspaceFolder}/FlyshotReplacement.sln", + "--no-restore", + "-v", + "minimal" + ], + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": "$msCompile" + }, + { + // 仅跑领域 / 算法层测试,迭代规划逻辑时使用 + "label": "test-core", + "command": "dotnet", + "type": "process", + "args": [ + "test", + "${workspaceFolder}/tests/Flyshot.Core.Tests/Flyshot.Core.Tests.csproj", + "-v", + "minimal" + ], + "group": "test", + "problemMatcher": "$msCompile" + }, + { + // 仅跑宿主集成测试,迭代 HTTP / 控制器层时使用 + "label": "test-integration", + "command": "dotnet", + "type": "process", + "args": [ + "test", + "${workspaceFolder}/tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj", + "-v", + "minimal" + ], + "group": "test", + "problemMatcher": "$msCompile" + }, + { + // 启动宿主,供 launch.json 的 watch 配置作为前置任务 + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj", + "--launch-profile", + "http" + ], + "isBackground": true, + "problemMatcher": { + "owner": "dotnet-watch", + "pattern": [ + { + "regexp": "^.*$", + "file": 1, + "location": 2, + "message": 3 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "^.*Watch run started.*$", + "endsPattern": "^.*Application started.*$" + } + } + }, + { + // Release 配置发布到 publish/,用于现场部署包打包 + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj", + "-c", + "Release", + "-o", + "${workspaceFolder}/publish" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/README.md b/README.md index 20464a4..c29e2d0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ - [x] 完成 ICSP 轨迹导出结果与旧系统对齐 - [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入 FANUC 运行时链路 - [x] 落地 Web 状态页 +- [x] 落地浏览器内 OpenAPI 自动驱动的接口调试页(`/debug`),与 `Swagger:Enabled` 同步可见 - [x] 固化 `10010 / 10012 / 60015` FANUC 基础协议帧编解码,确认 `10010` 状态帧为 90B - [x] 使用本地 TCP/UDP 模拟器覆盖命令通道、状态通道和 J519 基础收发 - [x] 补齐 `Get/SetSpeedRatio`、`Get/SetTCP`、`Get/SetIO` 真机命令体与响应解析 @@ -47,13 +48,14 @@ 1. 配置与测试基线 - [x] 修正 `ConfigCompatibilityTests` 当前样本路径漂移:`Rvbust/EOL10_EAU_0/RobotConfig.json` 不再包含 `001`,应改用稳定样本或更新断言。 - [x] 将 `RobotConfig.json` 中的 `use_do`、`io_keep_cycles`、`acc_limit`、`jerk_limit`、`adapt_icsp_try_num` 全部贯通到规划和执行链路。 - - [ ] 为新 HTTP API 补一份当前现场调用顺序文档,替代旧 `ControllerClient` 工作流。 + - [ ] 为新 HTTP API 补一份当前现场调用顺序文档,替代旧 `ControllerClient` 工作流(`/debug` 页已提供交互式覆盖,仍需补静态文档说明现场调用顺序)。 2. 轨迹规划 - [x] 补齐 ICSP 最终 `global_scale > 1.0` 失败判定,避免未收敛轨迹被当作有效结果执行。 - [x] 将 self-adapt-icsp 的补点次数改为使用配置中的 `adapt_icsp_try_num`。 - [ ] 如果现场仍需要 `method="doubles"`,实现 `TrajectoryDoubleS` 等价规划;否则在 HTTP 文档中明确标为不支持。 - [ ] 把已完成对齐的旧系统轨迹样本固化为 golden tests,防止后续重构破坏轨迹一致性。 + - [ ] 补齐 `save_traj` / `SaveTrajInfo` 的规划结果导出,将稠密关节轨迹、笛卡尔轨迹和 ShotEvents 写入可诊断 artifacts。 3. FANUC TCP 10012 命令通道 - [x] 补齐 `GetSpeedRatio` / `SetSpeedRatio` 真机命令体与响应解析。 diff --git a/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json b/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json index 998e6ab..82febce 100644 --- a/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json +++ b/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json @@ -1,11 +1,11 @@ -{ - "robot": { - "use_do": false, - "io_addr": [], - "io_keep_cycles": 2, - "acc_limit": 1, - "jerk_limit": 1, - "adapt_icsp_try_num": 5 - }, - "flying_shots": {} +{ + "robot": { + "use_do": false, + "io_addr": [], + "io_keep_cycles": 2, + "acc_limit": 1, + "jerk_limit": 1, + "adapt_icsp_try_num": 5 + }, + "flying_shots": {} } \ No newline at end of file diff --git a/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs b/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs new file mode 100644 index 0000000..d0e226f --- /dev/null +++ b/src/Flyshot.Server.Host/Controllers/DebugConsoleController.cs @@ -0,0 +1,1207 @@ +using Flyshot.Server.Host; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Flyshot.Server.Host.Controllers; + +/// +/// 提供浏览器内的在线 API 调试页面。 +/// 页面会在加载时拉取 Swagger JSON,按 OpenAPI 自动渲染所有端点的入参表单、Body 编辑器和响应面板。 +/// +/// +/// 本控制器自身不进入 Swagger 文档(), +/// 仅作为静态 HTML 出口;调试页和 Swagger UI 共用 Swagger:Enabled 开关, +/// 关闭后两者一同下线,避免生产环境意外暴露调试入口。 +/// +[ApiController] +[ApiExplorerSettings(IgnoreApi = true)] +[Tags("基础与状态")] +public sealed class DebugConsoleController : ControllerBase +{ + /// + /// 调试页 HTML 模板:内嵌 CSS、HTML 骨架与原生 JS,零外部依赖,适配现场离线环境。 + /// 模板中的 __SWAGGER_JSON_URL__ 占位符在返回前会被替换为实际的 Swagger JSON 地址。 + /// + private const string DebugConsoleHtmlTemplate = """ + + + + + + Flyshot Replacement 接口调试 + + + +
+
+

Flyshot Replacement 接口调试

+
+ 回到状态页 + Swagger UI + +
+
+
+
+
+
+
OpenAPI 文档
+
--
+
API 数量
+
--
+
加载状态
+
初始化中...
+
+
+
+
正在加载接口列表...
+
+
+ + + + +"""; + + /// + /// Swagger 配置项,用于决定调试页是否对外暴露以及拼接 OpenAPI JSON 地址。 + /// + private readonly HostSwaggerOptions _swaggerOptions; + + /// + /// 初始化在线调试页控制器。 + /// + /// 来自 Swagger 配置节的标准选项。 + public DebugConsoleController(IOptions swaggerOptions) + { + ArgumentNullException.ThrowIfNull(swaggerOptions); + _swaggerOptions = swaggerOptions.Value ?? new HostSwaggerOptions(); + } + + /// + /// 返回浏览器内可直接打开的 API 调试控制台。 + /// + /// 当 Swagger 启用时返回 HTML;否则返回 404,与 Swagger UI 保持一致的可见性策略。 + [HttpGet("/debug")] + public IActionResult GetDebugConsole() + { + // Swagger 关闭时调试页一同下线,避免生产环境意外暴露调试入口。 + if (!_swaggerOptions.Enabled) + { + return NotFound(); + } + + // 由控制器一侧解析 Swagger JSON 路径,前端不再硬编码路由前缀和文档名。 + var swaggerJsonUrl = ResolveSwaggerJsonUrl(_swaggerOptions); + var html = DebugConsoleHtmlTemplate.Replace("__SWAGGER_JSON_URL__", swaggerJsonUrl, StringComparison.Ordinal); + return Content(html, "text/html; charset=utf-8"); + } + + /// + /// 根据 解析出 Swagger JSON 实际地址。 + /// + /// Swagger 配置选项。 + /// 形如 /swagger/v1/swagger.json 的绝对路径。 + private static string ResolveSwaggerJsonUrl(HostSwaggerOptions options) + { + // Swashbuckle 的 RouteTemplate 不带前导斜杠,这里统一加上保证前端 fetch 走绝对路径。 + var template = options.JsonRouteTemplate ?? "swagger/{documentName}/swagger.json"; + var path = template.Replace("{documentName}", options.DocumentName ?? "v1", StringComparison.Ordinal); + return path.StartsWith('/') ? path : "/" + path; + } +} diff --git a/src/Flyshot.Server.Host/Controllers/HealthController.cs b/src/Flyshot.Server.Host/Controllers/HealthController.cs index 3da7d41..123d505 100644 --- a/src/Flyshot.Server.Host/Controllers/HealthController.cs +++ b/src/Flyshot.Server.Host/Controllers/HealthController.cs @@ -6,6 +6,7 @@ namespace Flyshot.Server.Host.Controllers; /// 提供宿主基础探活与诊断接口。 /// [ApiController] +[Tags("基础与状态")] public sealed class HealthController : ControllerBase { /// diff --git a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs index 8a884c2..e73bc9a 100644 --- a/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs +++ b/src/Flyshot.Server.Host/Controllers/LegacyHttpApiController.cs @@ -8,6 +8,7 @@ namespace Flyshot.Server.Host.Controllers; /// 提供对 `flyshot-uaes-interface` 既有 FastAPI HTTP 路由层的一比一 MVC 兼容控制器。 /// [ApiController] +[Tags("ControllerClient 兼容")] public sealed class LegacyHttpApiController : ControllerBase { private readonly IControllerClientCompatService _compatService; @@ -431,6 +432,8 @@ public sealed class LegacyHttpApiController : ControllerBase /// 兼容旧 `/execute_trajectory/` 路由,并接受两种历史请求体形状。 /// /// 轨迹请求体。 + /// 查询字符串中的 method 覆盖值(兼容历史调用方式)。 + /// 查询字符串中的 save_traj 覆盖值(兼容历史调用方式)。 /// 旧 FastAPI 层风格的状态响应。 [HttpPost("/execute_trajectory/")] public IActionResult ExecuteTrajectory( diff --git a/src/Flyshot.Server.Host/Controllers/StatusController.cs b/src/Flyshot.Server.Host/Controllers/StatusController.cs index 1706d38..f993e4e 100644 --- a/src/Flyshot.Server.Host/Controllers/StatusController.cs +++ b/src/Flyshot.Server.Host/Controllers/StatusController.cs @@ -7,6 +7,7 @@ namespace Flyshot.Server.Host.Controllers; /// 提供只读状态监控页面和控制器状态快照 API。 /// [ApiController] +[Tags("基础与状态")] public sealed class StatusController : ControllerBase { /// @@ -84,6 +85,30 @@ public sealed class StatusController : ControllerBase cursor: default; } + /* 顶部操作区按钮和外链按钮共用同一组视觉样式,便于现场顺手跳转。 */ + .actions { + display: flex; + align-items: center; + gap: 10px; + } + + .link-button { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 14px; + border: 1px solid var(--accent); + border-radius: 6px; + background: transparent; + color: var(--accent); + font: inherit; + text-decoration: none; + } + + .link-button:hover { + background: rgba(0, 124, 137, 0.08); + } + main { width: min(1180px, calc(100% - 32px)); margin: 22px auto; @@ -216,7 +241,10 @@ public sealed class StatusController : ControllerBase

Flyshot Replacement 状态监控

- +
+ 调试接口 + +
diff --git a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj index 95481dd..88c78ea 100644 --- a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj +++ b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj @@ -1,4 +1,11 @@ + + + true + $(NoWarn);1591 + + diff --git a/src/Flyshot.Server.Host/Program.cs b/src/Flyshot.Server.Host/Program.cs index c96c8fc..6bcd974 100644 --- a/src/Flyshot.Server.Host/Program.cs +++ b/src/Flyshot.Server.Host/Program.cs @@ -2,6 +2,7 @@ using Flyshot.ControllerClientCompat; using Flyshot.Server.Host; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; var builder = WebApplication.CreateBuilder(args); @@ -19,6 +20,13 @@ builder.Services.AddSwaggerGen(options => Title = swaggerOptions.Title, Version = swaggerOptions.Version }); + + // 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。 + var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml"); + if (File.Exists(xmlDocumentationPath)) + { + options.IncludeXmlComments(xmlDocumentationPath, includeControllerXmlComments: true); + } }); var corsOptions = builder.Configuration.GetSection("Cors").Get() ?? new HostCorsOptions(); diff --git a/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs new file mode 100644 index 0000000..5278a25 --- /dev/null +++ b/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs @@ -0,0 +1,141 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace Flyshot.Server.IntegrationTests; + +/// +/// 验证 `/debug` 在线 API 调试页的暴露策略与基础内容契约。 +/// +/// +/// 调试页与 Swagger UI 共用 Swagger:Enabled 开关,开关关闭时 +/// 调试页与 `/swagger` 一同下线,避免生产环境意外暴露调试入口。 +/// +public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IClassFixture +{ + private readonly FlyshotServerFactory _factory = factory; + + /// + /// 当 Swagger 启用时,`/debug` 应当返回完整的 HTML 调试页。 + /// + [Fact] + public async Task GetDebug_WhenSwaggerEnabled_ReturnsConsoleHtml() + { + // 默认配置即开启 Swagger,调试页应当作为浏览器可直接打开的 HTML 暴露。 + using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); + using var client = configuredFactory.CreateClient(); + + using var response = await client.GetAsync("/debug"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType); + + var html = await response.Content.ReadAsStringAsync(); + + // 页面标题与稳定锚点用于回归保护:调试页骨架一旦丢失,测试立即报警。 + Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal); + Assert.Contains("id=\"debug-console-app\"", html, StringComparison.Ordinal); + + // 控制器需要在返回 HTML 前把 Swagger JSON URL 注入到页面占位符里, + // 否则前端无法在加载时拉取 OpenAPI 文档。 + Assert.Contains("/swagger/v1/swagger.json", html, StringComparison.Ordinal); + Assert.DoesNotContain("__SWAGGER_JSON_URL__", html, StringComparison.Ordinal); + } + + /// + /// 当 Swagger 关闭时,`/debug` 应当与 `/swagger` 同步下线(404)。 + /// + [Fact] + public async Task GetDebug_WhenSwaggerDisabled_ReturnsNotFound() + { + // 显式把 Swagger:Enabled 置为 false,此时调试页也不应当被访问到。 + using var configuredFactory = CreateFactoryWithSwaggerEnabled(false); + using var client = configuredFactory.CreateClient(); + + using var response = await client.GetAsync("/debug"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + /// + /// 调试页需要从 Swagger JSON 中读取所有端点,因此 Swagger 文档必须包含基础和兼容层的代表性路由。 + /// + [Fact] + public async Task SwaggerDocument_ContainsRepresentativeRoutesForDebugConsole() + { + using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); + using var client = configuredFactory.CreateClient(); + + using var response = await client.GetAsync("/swagger/v1/swagger.json"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await using var responseStream = await response.Content.ReadAsStreamAsync(); + using var document = await System.Text.Json.JsonDocument.ParseAsync(responseStream); + var paths = document.RootElement.GetProperty("paths"); + + // 这些路径分别覆盖:基础探活、状态快照、版本查询、上传飞拍轨迹四种典型形态。 + AssertPathExists(paths, "/healthz"); + AssertPathExists(paths, "/api/status/snapshot"); + AssertPathExists(paths, "/get_server_version"); + AssertPathExists(paths, "/upload_flyshot"); + } + + /// + /// 状态页应当提供跳转到调试页的入口,便于现场顺手跳转。 + /// + [Fact] + public async Task GetStatusPage_LinksToDebugConsole() + { + using var configuredFactory = CreateFactoryWithSwaggerEnabled(true); + using var client = configuredFactory.CreateClient(); + + using var response = await client.GetAsync("/status"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var html = await response.Content.ReadAsStringAsync(); + + // 状态页头部需要至少一个指向 `/debug` 的链接,文案不强制以保留排版调整空间。 + Assert.Contains("href=\"/debug\"", html, StringComparison.Ordinal); + } + + /// + /// 检查 Swagger 文档中是否存在指定路径,兼容尾斜杠归一化两种形态。 + /// + /// OpenAPI 文档中的 paths 节点。 + /// 期望存在的路由字符串。 + private static void AssertPathExists(System.Text.Json.JsonElement paths, string route) + { + // OpenAPI 生成器会把部分尾斜杠路径规范化,这里同时接受两种形态。 + var withSlash = route.EndsWith('/') ? route : route + "/"; + var withoutSlash = route.EndsWith('/') ? route.TrimEnd('/') : route; + + Assert.True( + paths.TryGetProperty(withSlash, out _) || paths.TryGetProperty(withoutSlash, out _), + $"Swagger 文档应当包含路径 {route}"); + } + + /// + /// 构造覆盖了 Swagger:Enabled 配置项的测试宿主工厂。 + /// + /// 期望的 Swagger 启用状态。 + /// 已应用配置覆盖的测试工厂。 + private WebApplicationFactory CreateFactoryWithSwaggerEnabled(bool enabled) + { + return _factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configurationBuilder) => + { + // 通过 InMemory 配置覆盖 appsettings.json 中的 Swagger 开关,避免修改磁盘文件。 + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["Swagger:Enabled"] = enabled ? "true" : "false", + ["Swagger:DocumentName"] = "v1", + ["Swagger:Title"] = "Flyshot Replacement HTTP API", + ["Swagger:Version"] = "v1", + ["Swagger:JsonRouteTemplate"] = "swagger/{documentName}/swagger.json", + ["Swagger:RoutePrefix"] = "swagger" + }); + }); + }); + } +}