Files
FlyShotHost/tests/Flyshot.Server.IntegrationTests/DebugConsoleEndpointTests.cs
yunxiao.zhu 5193f537b0 ✨ feat(planning): 添加离线飞拍规划能力
* 固化离线飞拍轨迹加载、保存和规划结果。

* 真机初始化失败时保留模型加载后的离线规划能力。

* 更新状态接口、调试页和回归测试覆盖。
2026-06-24 11:40:07 +08:00

239 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
namespace Flyshot.Server.IntegrationTests;
/// <summary>
/// 验证 `wwwroot` 静态调试页和调试配置 API 的基础内容契约。
/// </summary>
/// <remarks>
/// 调试页自身是静态 HTML,真正的 Swagger 地址由配置 API 下发;
/// 当 Swagger 关闭时,配置 API 返回 404,前端据此显示不可用状态。
/// </remarks>
public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
{
private readonly FlyshotServerFactory _factory = factory;
/// <summary>
/// `debug.html` 应当作为可直接调试的静态页面暴露。
/// </summary>
[Fact]
public async Task GetDebugHtml_ReturnsConsoleStaticPage()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var response = await client.GetAsync("/debug.html");
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);
Assert.Contains("id=\"debug-static-docs\"", html, StringComparison.Ordinal);
Assert.Contains("POST /move_pose/", html, StringComparison.Ordinal);
Assert.Contains("POST /upload_flyshot/", html, StringComparison.Ordinal);
Assert.Contains("POST /init_mpc_robot", html, StringComparison.Ordinal);
Assert.Contains("id=\"offline-planning-panel\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"offline-trajectory-select\"", html, StringComparison.Ordinal);
Assert.Contains("生成轨迹不会向机器人发送命令", html, StringComparison.Ordinal);
Assert.DoesNotContain("/init_mpc_robt", html, StringComparison.Ordinal);
Assert.DoesNotContain("/set_active_controller/", html, StringComparison.Ordinal);
Assert.DoesNotContain("\"sim\"", html, StringComparison.Ordinal);
Assert.DoesNotContain("\"server_ip\"", html, StringComparison.Ordinal);
Assert.DoesNotContain("__SWAGGER_JSON_URL__", html, StringComparison.Ordinal);
Assert.Contains("/assets/debug.css", html, StringComparison.Ordinal);
Assert.Contains("/assets/debug.js", html, StringComparison.Ordinal);
using var scriptResponse = await client.GetAsync("/assets/debug.js");
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
var script = await scriptResponse.Content.ReadAsStringAsync();
Assert.Contains("/api/debug/config", script, StringComparison.Ordinal);
Assert.Contains("/list_flyShotTraj/", script, StringComparison.Ordinal);
Assert.Contains("/save_traj_info/", script, StringComparison.Ordinal);
Assert.Contains("renderOfflinePlanningResult", script, StringComparison.Ordinal);
}
/// <summary>
/// 调试页应当为 MovePose 提供可直接发送的六字段请求体模板。
/// </summary>
[Fact]
public async Task GetDebugScript_ContainsMovePoseRequestSample()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var scriptResponse = await client.GetAsync("/assets/debug.js");
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
var script = await scriptResponse.Content.ReadAsStringAsync();
Assert.Contains("/move_pose/", script, StringComparison.Ordinal);
Assert.Contains("x: 100.0", script, StringComparison.Ordinal);
Assert.Contains("y: 200.0", script, StringComparison.Ordinal);
Assert.Contains("z: 300.0", script, StringComparison.Ordinal);
Assert.Contains("w: 0.0", script, StringComparison.Ordinal);
Assert.Contains("p: 45.0", script, StringComparison.Ordinal);
Assert.Contains("r: 0.0", script, StringComparison.Ordinal);
}
/// <summary>
/// 调试脚本应当优先读取静态说明并在缺失时回退到 OpenAPI / “暂无说明”。
/// </summary>
[Fact]
public async Task GetDebugScript_ContainsStaticDocumentationMergeLogic()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var scriptResponse = await client.GetAsync("/assets/debug.js");
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
var script = await scriptResponse.Content.ReadAsStringAsync();
Assert.Contains("debug-static-docs", script, StringComparison.Ordinal);
Assert.Contains("op.method + \" \" + op.path", script, StringComparison.Ordinal);
Assert.Contains("mergedSummary", script, StringComparison.Ordinal);
Assert.Contains("mergedDescription", script, StringComparison.Ordinal);
Assert.Contains("requestBodyFieldDocs", script, StringComparison.Ordinal);
Assert.Contains("暂无说明", script, StringComparison.Ordinal);
}
/// <summary>
/// 调试卡片默认应直接展开,确保静态说明和字段表在初始页面就可见。
/// </summary>
[Fact]
public async Task GetDebugScript_DefaultsCardsToExpandedForVisibleDocumentation()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var scriptResponse = await client.GetAsync("/assets/debug.js");
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
var script = await scriptResponse.Content.ReadAsStringAsync();
Assert.DoesNotContain("card.className = \"card collapsed\"", script, StringComparison.Ordinal);
Assert.Contains("card.className = \"card\"", script, StringComparison.Ordinal);
Assert.Contains("toggle.textContent = \"收起 ▴\"", script, StringComparison.Ordinal);
}
/// <summary>
/// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址。
/// </summary>
[Fact]
public async Task GetDebugConfig_WhenSwaggerEnabled_ReturnsSwaggerJsonUrl()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var response = await client.GetAsync("/api/debug/config");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var document = await System.Text.Json.JsonDocument.ParseAsync(responseStream);
Assert.Equal("/swagger/v1/swagger.json", document.RootElement.GetProperty("swaggerJsonUrl").GetString());
}
/// <summary>
/// 当 Swagger 关闭时,调试配置 API 应当与 Swagger UI 同步下线(404)。
/// </summary>
[Fact]
public async Task GetDebugConfig_WhenSwaggerDisabled_ReturnsNotFound()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(false);
using var client = configuredFactory.CreateClient();
using var response = await client.GetAsync("/api/debug/config");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
/// <summary>
/// 调试页需要从 Swagger JSON 中读取所有端点,因此 Swagger 文档必须包含基础和兼容层的代表性路由。
/// </summary>
[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, "/init_mpc_robot");
AssertPathExists(paths, "/upload_flyshot");
AssertPathExists(paths, "/save_traj_info");
Assert.False(paths.TryGetProperty("/init_mpc_robt", out _));
Assert.False(paths.TryGetProperty("/set_active_controller/", out _));
}
/// <summary>
/// 状态页应当提供跳转到静态调试页的入口,便于现场顺手跳转。
/// </summary>
[Fact]
public async Task GetStatusHtml_LinksToDebugConsole()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var response = await client.GetAsync("/status.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("href=\"/debug.html\"", html, StringComparison.Ordinal);
}
/// <summary>
/// 检查 Swagger 文档中是否存在指定路径,兼容尾斜杠归一化两种形态。
/// </summary>
/// <param name="paths">OpenAPI 文档中的 paths 节点。</param>
/// <param name="route">期望存在的路由字符串。</param>
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}");
}
/// <summary>
/// 构造覆盖了 <c>Swagger:Enabled</c> 配置项的测试宿主工厂。
/// </summary>
/// <param name="enabled">期望的 Swagger 启用状态。</param>
/// <returns>已应用配置覆盖的测试工厂。</returns>
private WebApplicationFactory<Program> CreateFactoryWithSwaggerEnabled(bool enabled)
{
return _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
// 通过 InMemory 配置覆盖 appsettings.json 中的 Swagger 开关,避免修改磁盘文件。
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["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"
});
});
});
}
}