Merge commit '0952958af7685646cdbf2a4907c6e2ae59d4d352'
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
using Flyshot.ControllerClientCompat;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Flyshot.Server.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// 锁定宿主当前应注册 HTTP-only 的 ControllerClient 兼容服务,而不是旧 TCP 网关入口。
|
||||
/// </summary>
|
||||
public sealed class ControllerClientCompatRegistrationTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证宿主能从 DI 中解析新的兼容服务。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Host_Registers_ControllerClientCompat_Service()
|
||||
{
|
||||
var service = factory.Services.GetService<IControllerClientCompatService>();
|
||||
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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.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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 当 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, "/get_server_version");
|
||||
AssertPathExists(paths, "/upload_flyshot");
|
||||
}
|
||||
|
||||
/// <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"
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Flyshot.ControllerClientCompat\Flyshot.ControllerClientCompat.csproj" />
|
||||
<ProjectReference Include="..\..\src\Flyshot.Server.Host\Flyshot.Server.Host.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Flyshot.Server.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// 锁定标准 MVC 宿主需要提供的 Swagger 与 CORS 行为,避免后续回退成只够跑通的最小配置。
|
||||
/// </summary>
|
||||
public sealed class HostMvcConfigurationTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证宿主会公开标准 Swagger JSON,并且文档标题和旧 HTTP 兼容路径都能从配置和控制器路由中导出。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SwaggerDocument_ExposesConfiguredMetadataAndLegacyRoutes()
|
||||
{
|
||||
using var configuredFactory = CreateConfiguredFactory(factory);
|
||||
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 JsonDocument.ParseAsync(responseStream);
|
||||
var root = document.RootElement;
|
||||
var paths = root.GetProperty("paths");
|
||||
|
||||
Assert.Equal("3.0.1", root.GetProperty("openapi").GetString());
|
||||
Assert.Equal("Flyshot Replacement HTTP API", root.GetProperty("info").GetProperty("title").GetString());
|
||||
// OpenAPI 文档会把部分带尾斜杠的路由规范化为无尾斜杠形式,这里同时接受两种键。
|
||||
Assert.True(paths.TryGetProperty("/robot_info/", out _) || paths.TryGetProperty("/robot_info", out _));
|
||||
Assert.True(paths.TryGetProperty("/healthz", out _) || paths.TryGetProperty("/healthz/", out _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证宿主会按配置对旧 HTTP API 路由返回标准 CORS 预检响应。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CorsPreflight_ReturnsConfiguredAllowOriginHeaders()
|
||||
{
|
||||
using var configuredFactory = CreateConfiguredFactory(factory);
|
||||
using var client = configuredFactory.CreateClient();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Options, "/robot_info/");
|
||||
request.Headers.Add("Origin", "http://localhost:3000");
|
||||
request.Headers.Add("Access-Control-Request-Method", "GET");
|
||||
request.Headers.Add("Access-Control-Request-Headers", "content-type");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var allowedOrigins));
|
||||
Assert.Contains("http://localhost:3000", allowedOrigins);
|
||||
Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Methods", out var allowedMethods));
|
||||
Assert.Contains("GET", string.Join(",", allowedMethods));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为测试宿主注入标准 Swagger 与 CORS 配置,避免依赖开发机本地环境。
|
||||
/// </summary>
|
||||
private static WebApplicationFactory<Program> CreateConfiguredFactory(FlyshotServerFactory factory)
|
||||
{
|
||||
return factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
||||
{
|
||||
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Swagger:Enabled"] = "true",
|
||||
["Swagger:DocumentName"] = "v1",
|
||||
["Swagger:Title"] = "Flyshot Replacement HTTP API",
|
||||
["Swagger:Version"] = "v1",
|
||||
["Cors:PolicyName"] = "LegacyHttpApi",
|
||||
["Cors:AllowedOrigins:0"] = "http://localhost:3000",
|
||||
["Cors:AllowedMethods:0"] = "GET",
|
||||
["Cors:AllowedMethods:1"] = "POST",
|
||||
["Cors:AllowedMethods:2"] = "OPTIONS",
|
||||
["Cors:AllowedHeaders:0"] = "content-type"
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Flyshot.Server.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// 锁定 flyshot-uaes-interface 现有 FastAPI 层的 HTTP 路径、参数绑定和返回 JSON 外形。
|
||||
/// </summary>
|
||||
public sealed class LegacyHttpApiCompatibilityTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证根路径会返回旧 HTTP 服务使用的 Hello World JSON,而不是跳转到健康检查页。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Root_ReturnsLegacyHelloWorldPayload()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var json = await ReadJsonAsync(response);
|
||||
Assert.Equal("Hello World", json.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证初始化链路和机器人信息接口会保持旧 FastAPI 服务的路径与返回字段风格。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitEndpoints_ExposeLegacyRobotInfoAndSpeedRatioShape()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using (var connectServerResponse = await client.PostAsync("/connect_server/?server_ip=127.0.0.1&port=50001", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, connectServerResponse.StatusCode);
|
||||
using var connectServerJson = await ReadJsonAsync(connectServerResponse);
|
||||
Assert.Equal("connected", connectServerJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var setupResponse = await client.PostAsync("/setup_robot/?robot_name=FANUC_LR_Mate_200iD", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, setupResponse.StatusCode);
|
||||
using var setupJson = await ReadJsonAsync(setupResponse);
|
||||
Assert.Equal("robot setup", setupJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var isSetupResponse = await client.GetAsync("/is_setup/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, isSetupResponse.StatusCode);
|
||||
using var isSetupJson = await ReadJsonAsync(isSetupResponse);
|
||||
Assert.True(isSetupJson.RootElement.GetProperty("is_setup").GetBoolean());
|
||||
}
|
||||
|
||||
using (var activeControllerResponse = await client.PostAsync("/set_active_controller/?sim=true", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, activeControllerResponse.StatusCode);
|
||||
using var activeControllerJson = await ReadJsonAsync(activeControllerResponse);
|
||||
Assert.Equal("active controller set", activeControllerJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var connectRobotResponse = await client.PostAsync("/connect_robot/?ip=192.168.10.101", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, connectRobotResponse.StatusCode);
|
||||
using var connectRobotJson = await ReadJsonAsync(connectRobotResponse);
|
||||
Assert.Equal("robot connected", connectRobotJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var enableRobotResponse = await client.GetAsync("/enable_robot/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, enableRobotResponse.StatusCode);
|
||||
using var enableRobotJson = await ReadJsonAsync(enableRobotResponse);
|
||||
Assert.True(enableRobotJson.RootElement.GetProperty("enable_robot").GetBoolean());
|
||||
}
|
||||
|
||||
using (var robotInfoResponse = await client.GetAsync("/robot_info/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, robotInfoResponse.StatusCode);
|
||||
using var robotInfoJson = await ReadJsonAsync(robotInfoResponse);
|
||||
var robotInfoRoot = robotInfoJson.RootElement;
|
||||
|
||||
Assert.Equal("FANUC_LR_Mate_200iD", robotInfoRoot.GetProperty("name").GetString());
|
||||
Assert.Equal("flyshot-replacement-controller-client-compat/0.1.0", robotInfoRoot.GetProperty("server_version").GetString());
|
||||
Assert.Equal(6, robotInfoRoot.GetProperty("dof").GetInt32());
|
||||
Assert.Equal(1.0, robotInfoRoot.GetProperty("speed_ratio").GetDouble(), precision: 6);
|
||||
}
|
||||
|
||||
using (var setSpeedRatioResponse = await client.PostAsJsonAsync("/set_speedRatio/", new { speed = 0.8 }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, setSpeedRatioResponse.StatusCode);
|
||||
using var setSpeedRatioJson = await ReadJsonAsync(setSpeedRatioResponse);
|
||||
Assert.Equal("set_speedRatio executed", setSpeedRatioJson.RootElement.GetProperty("message").GetString());
|
||||
Assert.Equal(0, setSpeedRatioJson.RootElement.GetProperty("returnCode").GetInt32());
|
||||
}
|
||||
|
||||
using var updatedRobotInfoResponse = await client.GetAsync("/robot_info/");
|
||||
Assert.Equal(HttpStatusCode.OK, updatedRobotInfoResponse.StatusCode);
|
||||
using var updatedRobotInfoJson = await ReadJsonAsync(updatedRobotInfoResponse);
|
||||
Assert.Equal(0.8, updatedRobotInfoJson.RootElement.GetProperty("speed_ratio").GetDouble(), precision: 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 TCP、关节位置和位姿相关 HTTP 接口会保持旧服务的请求体与响应体结构。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MotionStateEndpoints_RoundTripLegacyPayloadShapes()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
await InitializeRobotAsync(client);
|
||||
|
||||
using (var setTcpResponse = await client.PostAsJsonAsync("/set_tcp/", new { x = 1.0, y = 2.0, z = 3.0 }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, setTcpResponse.StatusCode);
|
||||
using var setTcpJson = await ReadJsonAsync(setTcpResponse);
|
||||
Assert.Equal("TCP set", setTcpJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var getTcpResponse = await client.GetAsync("/get_tcp/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, getTcpResponse.StatusCode);
|
||||
using var getTcpJson = await ReadJsonAsync(getTcpResponse);
|
||||
var tcpValues = getTcpJson.RootElement.GetProperty("tcp").EnumerateArray().Select(static value => value.GetDouble()).ToArray();
|
||||
Assert.Equal([1.0, 2.0, 3.0], tcpValues);
|
||||
}
|
||||
|
||||
using (var moveJointResponse = await client.PostAsJsonAsync("/move_joint/", new { joints = new[] { 1.0, 2.0, 3.0, 4.0, 5.0, 6.0 } }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, moveJointResponse.StatusCode);
|
||||
using var moveJointJson = await ReadJsonAsync(moveJointResponse);
|
||||
Assert.Equal("robot moved", moveJointJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var movePoseResponse = await client.PostAsJsonAsync("/move_pose/", new { x = 100.0, y = 200.0, z = 300.0, w = 1.0, p = 2.0, r = 3.0 }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, movePoseResponse.StatusCode);
|
||||
using var movePoseJson = await ReadJsonAsync(movePoseResponse);
|
||||
Assert.Equal("robot moved", movePoseJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var getJointPositionResponse = await client.GetAsync("/get_joint_position/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, getJointPositionResponse.StatusCode);
|
||||
using var getJointPositionJson = await ReadJsonAsync(getJointPositionResponse);
|
||||
var root = getJointPositionJson.RootElement;
|
||||
Assert.True(root.GetProperty("success").GetBoolean());
|
||||
var jointValues = root.GetProperty("points").EnumerateArray().Select(static value => value.GetDouble()).ToArray();
|
||||
Assert.Equal([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], jointValues);
|
||||
}
|
||||
|
||||
using var getPoseResponse = await client.GetAsync("/get_pose");
|
||||
Assert.Equal(HttpStatusCode.OK, getPoseResponse.StatusCode);
|
||||
using var getPoseJson = await ReadJsonAsync(getPoseResponse);
|
||||
Assert.Equal(7, getPoseJson.RootElement.GetProperty("pose").GetArrayLength());
|
||||
|
||||
using (var executeTrajectoryResponse = await client.PostAsJsonAsync("/execute_trajectory/", new
|
||||
{
|
||||
method = "icsp",
|
||||
save_traj = true,
|
||||
waypoints = new[]
|
||||
{
|
||||
new[] { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.1, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.2, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
new[] { 0.3, 0.0, 0.0, 0.0, 0.0, 0.0 }
|
||||
}
|
||||
}))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, executeTrajectoryResponse.StatusCode);
|
||||
using var executeTrajectoryJson = await ReadJsonAsync(executeTrajectoryResponse);
|
||||
Assert.Equal("trajectory executed", executeTrajectoryJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 MovePose 请求必须显式提供六个有限直角坐标字段,避免缺字段被模型绑定静默补 0。
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":2.0}""")]
|
||||
[InlineData("null")]
|
||||
[InlineData("""{"x":1e999,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":1000.1,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":-1000.1,"z":300.0,"w":1.0,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":-0.1,"w":1.0,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":-180.1,"p":2.0,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":90.1,"r":3.0}""")]
|
||||
[InlineData("""{"x":100.0,"y":200.0,"z":300.0,"w":1.0,"p":2.0,"r":180.1}""")]
|
||||
public async Task MovePose_InvalidPayload_ReturnsLegacyBadRequest(string payload)
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
await InitializeRobotAsync(client);
|
||||
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync("/move_pose/", content);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
using var json = await ReadJsonAsync(response);
|
||||
Assert.Equal("MovePose failed", json.RootElement.GetProperty("detail").GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证飞拍 HTTP 接口可以按旧 API 层的路径和字段完成上传、列出、执行与删除。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FlyshotEndpoints_RoundTripLegacyUploadExecuteAndDeleteFlow()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
await InitializeRobotAsync(client);
|
||||
|
||||
var uploadPayload = new
|
||||
{
|
||||
addrs = new[]
|
||||
{
|
||||
new[] { 7, 8 },
|
||||
new[] { 7, 8 },
|
||||
Array.Empty<int>(),
|
||||
Array.Empty<int>()
|
||||
},
|
||||
name = "demo-http-flyshot",
|
||||
offset_values = new[] { 0.0, 1.0, 0.0, 0.0 },
|
||||
shot_flags = new[] { false, true, false, false },
|
||||
waypoints = new[]
|
||||
{
|
||||
new[] { 0.1, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.2, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.3, 0.2, 0.3, 0.4, 0.5, 0.6 },
|
||||
new[] { 0.4, 0.2, 0.3, 0.4, 0.5, 0.6 }
|
||||
}
|
||||
};
|
||||
|
||||
using (var uploadResponse = await client.PostAsJsonAsync("/upload_flyshot/", uploadPayload))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, uploadResponse.StatusCode);
|
||||
using var uploadJson = await ReadJsonAsync(uploadResponse);
|
||||
Assert.Equal("FlyShot uploaded", uploadJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
using (var listResponse = await client.GetAsync("/list_flyShotTraj/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
using var listJson = await ReadJsonAsync(listResponse);
|
||||
var names = listJson.RootElement.GetProperty("flyshot_trajs").EnumerateArray().Select(static value => value.GetString()).ToArray();
|
||||
Assert.Contains("demo-http-flyshot", names);
|
||||
}
|
||||
|
||||
using (var validResponse = await client.PostAsJsonAsync("/is_flyShotTrajValid/", new
|
||||
{
|
||||
name = "demo-http-flyshot",
|
||||
method = "icsp",
|
||||
save_traj = false
|
||||
}))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, validResponse.StatusCode);
|
||||
using var validJson = await ReadJsonAsync(validResponse);
|
||||
Assert.True(validJson.RootElement.GetProperty("valid").GetBoolean());
|
||||
Assert.True(validJson.RootElement.GetProperty("time").GetDouble() > 0.0);
|
||||
}
|
||||
|
||||
using (var executeResponse = await client.PostAsJsonAsync("/execute_flyshot/", new
|
||||
{
|
||||
name = "demo-http-flyshot",
|
||||
move_to_start = true,
|
||||
method = "icsp",
|
||||
save_traj = true,
|
||||
use_cache = true,
|
||||
wait = true
|
||||
}))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
|
||||
using var executeJson = await ReadJsonAsync(executeResponse);
|
||||
var executeRoot = executeJson.RootElement;
|
||||
Assert.Equal("FlyShot executed", executeRoot.GetProperty("status").GetString());
|
||||
Assert.True(executeRoot.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
using (var saveInfoResponse = await client.PostAsJsonAsync("/save_traj_info/", new
|
||||
{
|
||||
name = "demo-http-flyshot",
|
||||
method = "icsp"
|
||||
}))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, saveInfoResponse.StatusCode);
|
||||
using var saveInfoJson = await ReadJsonAsync(saveInfoResponse);
|
||||
Assert.True(saveInfoJson.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
|
||||
using (var deleteResponse = await client.PostAsJsonAsync("/delete_flyshot/", new { name = "demo-http-flyshot" }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
|
||||
using var deleteJson = await ReadJsonAsync(deleteResponse);
|
||||
Assert.Equal("FlyShot deleted", deleteJson.RootElement.GetProperty("status").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复用旧 API 层常见的初始化顺序,把当前宿主推进到可执行动作的最小状态。
|
||||
/// </summary>
|
||||
private static async Task InitializeRobotAsync(HttpClient client)
|
||||
{
|
||||
using var initResponse = await client.PostAsJsonAsync("/init_mpc_robt", new
|
||||
{
|
||||
server_ip = "127.0.0.1",
|
||||
port = 50001,
|
||||
robot_name = "FANUC_LR_Mate_200iD",
|
||||
robot_ip = "192.168.10.101",
|
||||
sim = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode);
|
||||
using var initJson = await ReadJsonAsync(initResponse);
|
||||
Assert.Equal("init_Success", initJson.RootElement.GetProperty("message").GetString());
|
||||
Assert.Equal(0, initJson.RootElement.GetProperty("returnCode").GetInt32());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一把 HTTP 响应体解析成 JsonDocument,便于对旧接口的字段形状做精确断言。
|
||||
/// </summary>
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
{
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonDocument.ParseAsync(responseStream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Flyshot.Server.Host.Middleware;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Flyshot.Server.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 请求响应日志中间件测试。
|
||||
/// </summary>
|
||||
public sealed class RequestResponseLoggingMiddlewareTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 高频状态快照路径命中忽略前缀时,不应写入请求和响应日志。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenPathMatchesIgnoredPrefix_DoesNotWriteRequestResponseLogs()
|
||||
{
|
||||
var logger = new CapturingLogger<RequestResponseLoggingMiddleware>();
|
||||
var nextWasCalled = false;
|
||||
var middleware = new RequestResponseLoggingMiddleware(
|
||||
async context =>
|
||||
{
|
||||
nextWasCalled = true;
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
await context.Response.WriteAsync("ok");
|
||||
},
|
||||
logger);
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/api/status/snapshot/current";
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(nextWasCalled);
|
||||
Assert.Empty(logger.Entries);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 捕获中间件写出的日志条目,避免测试依赖真实 NLog 目标。
|
||||
/// </summary>
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// 已捕获的日志条目。
|
||||
/// </summary>
|
||||
public List<LogEntry> Entries { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试用日志条目。
|
||||
/// </summary>
|
||||
/// <param name="Level">日志级别。</param>
|
||||
/// <param name="Message">格式化后的日志消息。</param>
|
||||
private sealed record LogEntry(LogLevel Level, string Message);
|
||||
}
|
||||
157
tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs
Normal file
157
tests/Flyshot.Server.IntegrationTests/StatusEndpointTests.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Flyshot.Server.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证状态监控页面和状态快照 API 能读取当前 ControllerClient 兼容层状态。
|
||||
/// </summary>
|
||||
public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证状态页作为 wwwroot 静态 HTML 暴露,并引用状态快照 API。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetStatusHtml_ReturnsMonitoringStaticPage()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/status.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("/assets/status.css", html, StringComparison.Ordinal);
|
||||
Assert.Contains("/assets/status.js", html, StringComparison.Ordinal);
|
||||
|
||||
using var scriptResponse = await client.GetAsync("/assets/status.js");
|
||||
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
|
||||
|
||||
var script = await scriptResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("/api/status/snapshot", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 状态页应当提供双摇杆直角坐标点动面板,并复用现有 MovePose HTTP 接口。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetStatusPageAssets_ExposeCartesianJogControls()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var htmlResponse = await client.GetAsync("/status.html");
|
||||
Assert.Equal(HttpStatusCode.OK, htmlResponse.StatusCode);
|
||||
|
||||
var html = await htmlResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("直角坐标点动", html, StringComparison.Ordinal);
|
||||
Assert.Contains("XYZ 平移摇杆", html, StringComparison.Ordinal);
|
||||
Assert.Contains("WPR 姿态摇杆", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"linear-step\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"angular-step\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("max=\"20\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("请求日志", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"request-log\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"clear-log\"", html, StringComparison.Ordinal);
|
||||
|
||||
foreach (var (axis, negative, positive) in new[]
|
||||
{
|
||||
("x", "X-", "X+"),
|
||||
("y", "Y-", "Y+"),
|
||||
("z", "Z-", "Z+"),
|
||||
("w", "W-", "W+"),
|
||||
("p", "P-", "P+"),
|
||||
("r", "R-", "R+")
|
||||
})
|
||||
{
|
||||
Assert.Contains($"data-axis=\"{axis}\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains($"data-axis=\"{axis}\" data-direction=\"1\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains($"data-axis=\"{axis}\" data-direction=\"-1\"", html, StringComparison.Ordinal);
|
||||
Assert.Contains(negative, html, StringComparison.Ordinal);
|
||||
Assert.Contains(positive, html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
using var scriptResponse = await client.GetAsync("/assets/status.js");
|
||||
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
|
||||
|
||||
var script = await scriptResponse.Content.ReadAsStringAsync();
|
||||
Assert.Contains("/api/status/snapshot", script, StringComparison.Ordinal);
|
||||
Assert.Contains("/move_pose/", script, StringComparison.Ordinal);
|
||||
Assert.Contains("cartesianPose", script, StringComparison.Ordinal);
|
||||
Assert.Contains("pointerdown", script, StringComparison.Ordinal);
|
||||
Assert.Contains("pointerup", script, StringComparison.Ordinal);
|
||||
Assert.Contains("pointerleave", script, StringComparison.Ordinal);
|
||||
Assert.Contains("blur", script, StringComparison.Ordinal);
|
||||
Assert.Contains("request-log", script, StringComparison.Ordinal);
|
||||
Assert.Contains("clear-log", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证状态快照 API 会返回运行时连接、使能、速度和机器人元数据。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetStatusSnapshot_ReturnsRuntimeStateAfterLegacyInitialization()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
await InitializeRobotAsync(client);
|
||||
|
||||
using (var speedResponse = await client.PostAsJsonAsync("/set_speedRatio/", new { speed = 0.75 }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, speedResponse.StatusCode);
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync("/api/status/snapshot");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using var json = await JsonDocument.ParseAsync(responseStream);
|
||||
var root = json.RootElement;
|
||||
var snapshot = root.GetProperty("snapshot");
|
||||
|
||||
Assert.Equal("ok", root.GetProperty("status").GetString());
|
||||
Assert.True(root.GetProperty("isSetup").GetBoolean());
|
||||
Assert.Equal("FANUC_LR_Mate_200iD", root.GetProperty("robotName").GetString());
|
||||
Assert.Equal(6, root.GetProperty("degreesOfFreedom").GetInt32());
|
||||
var uploadedTrajectories = root.GetProperty("uploadedTrajectories")
|
||||
.EnumerateArray()
|
||||
.Select(static value => value.GetString())
|
||||
.ToArray();
|
||||
Assert.Contains("20251015", uploadedTrajectories);
|
||||
Assert.Contains("UTTC_MS11", uploadedTrajectories);
|
||||
Assert.Equal("Connected", snapshot.GetProperty("connectionState").GetString());
|
||||
Assert.True(snapshot.GetProperty("isEnabled").GetBoolean());
|
||||
Assert.False(snapshot.GetProperty("isInMotion").GetBoolean());
|
||||
Assert.Equal(0.75, snapshot.GetProperty("speedRatio").GetDouble(), precision: 6);
|
||||
Assert.Equal(6, snapshot.GetProperty("jointPositions").GetArrayLength());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化旧 HTTP 兼容链路,使状态页可以读取一个完整的已连接状态。
|
||||
/// </summary>
|
||||
/// <param name="client">测试 HTTP 客户端。</param>
|
||||
private static async Task InitializeRobotAsync(HttpClient client)
|
||||
{
|
||||
using (var setupResponse = await client.PostAsync("/setup_robot/?robot_name=FANUC_LR_Mate_200iD", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, setupResponse.StatusCode);
|
||||
}
|
||||
|
||||
using (var activeControllerResponse = await client.PostAsync("/set_active_controller/?sim=true", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, activeControllerResponse.StatusCode);
|
||||
}
|
||||
|
||||
using (var connectRobotResponse = await client.PostAsync("/connect_robot/?ip=192.168.10.101", content: null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, connectRobotResponse.StatusCode);
|
||||
}
|
||||
|
||||
using (var enableRobotResponse = await client.GetAsync("/enable_robot/"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, enableRobotResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user