✨ feat(*): 添加 ConfigRoot 运行时配置目录隔离
* 新增 ControllerClientCompatOptions.ConfigRoot 及解析方法 * 兼容层默认从运行目录 Config 加载模型、轨迹和配置 * 移除隐式父工作区根目录推断,旧路径仅在显式配置时生效 * Host 项目编译时将 Config 目录复制到输出目录 * 请求响应日志中间件忽略 /api/status/snapshot 高频轮询 * 补充 ConfigRoot 和日志过滤相关单元测试
This commit is contained in:
@@ -120,10 +120,35 @@ public sealed class ConfigCompatibilityTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证路径兼容层既能补旧目录候选,也能按平台策略生成默认用户数据目录。
|
||||
/// 验证路径兼容层只从当前服务配置目录解析相对配置,并按平台策略生成默认用户数据目录。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PathCompatibility_ResolvesLegacyCandidates_AndBuildsUserDataRoots()
|
||||
public void PathCompatibility_ResolvesConfigDirectoryOnly_AndBuildsUserDataRoots()
|
||||
{
|
||||
var tempRoot = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var configPath = Path.Combine(tempRoot, "Config", "sample.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
|
||||
File.WriteAllText(configPath, "{}");
|
||||
|
||||
var resolved = PathCompatibility.ResolveConfigPath("sample.json", tempRoot);
|
||||
|
||||
Assert.Equal(configPath, resolved);
|
||||
Assert.Equal("/home/tester/.Rvbust/Data", PathCompatibility.BuildUserDataRoot("/home/tester", CompatibilityPathStyle.Posix));
|
||||
Assert.Equal(@"C:\Users\tester\.Rvbust\Data", PathCompatibility.BuildUserDataRoot(@"C:\Users\tester", CompatibilityPathStyle.Windows));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证旧父工作区候选路径存在时也不会被相对配置解析隐式命中。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PathCompatibility_DoesNotResolveLegacyWorkspaceFallbacks()
|
||||
{
|
||||
var tempRoot = CreateTempDirectory();
|
||||
try
|
||||
@@ -132,11 +157,9 @@ public sealed class ConfigCompatibilityTests
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(legacyConfigPath)!);
|
||||
File.WriteAllText(legacyConfigPath, "{}");
|
||||
|
||||
var resolved = PathCompatibility.ResolveConfigPath("sample.json", tempRoot);
|
||||
var exception = Assert.Throws<FileNotFoundException>(() => PathCompatibility.ResolveConfigPath("sample.json", tempRoot));
|
||||
|
||||
Assert.Equal(legacyConfigPath, resolved);
|
||||
Assert.Equal("/home/tester/.Rvbust/Data", PathCompatibility.BuildUserDataRoot("/home/tester", CompatibilityPathStyle.Posix));
|
||||
Assert.Equal(@"C:\Users\tester\.Rvbust\Data", PathCompatibility.BuildUserDataRoot(@"C:\Users\tester", CompatibilityPathStyle.Windows));
|
||||
Assert.Equal("sample.json", exception.FileName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -144,6 +167,19 @@ public sealed class ConfigCompatibilityTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证默认加载配置时使用当前 replacement 仓库内的 Config/RobotConfig.json。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RobotConfigLoader_LoadsRepositoryConfigFromReplacementConfigDirectory()
|
||||
{
|
||||
var replacementRoot = GetReplacementRoot();
|
||||
|
||||
var loaded = new RobotConfigLoader().Load("RobotConfig.json");
|
||||
|
||||
Assert.Equal(Path.Combine(replacementRoot, "Config", "RobotConfig.json"), loaded.SourcePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定位当前工作区根目录,便于复用父仓库中的真实样本。
|
||||
/// </summary>
|
||||
@@ -164,6 +200,25 @@ public sealed class ConfigCompatibilityTests
|
||||
throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定位 replacement 仓库根目录,供测试读取仓库内固化配置。
|
||||
/// </summary>
|
||||
private static string GetReplacementRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Unable to locate the flyshot replacement root.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建当前测试专用的临时目录,避免不同测试之间相互污染。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using Flyshot.ControllerClientCompat;
|
||||
using Flyshot.Core.Config;
|
||||
|
||||
namespace Flyshot.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 ControllerClient 兼容层默认围绕运行目录 Config 读写配置和轨迹文件。
|
||||
/// </summary>
|
||||
public sealed class ControllerClientCompatConfigRootTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证路径兼容层优先命中运行目录 Config 下的 RobotConfig.json,而不是旧仓库根目录候选。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PathCompatibility_ResolvesRuntimeConfigBeforeLegacyCandidates()
|
||||
{
|
||||
var runtimeRoot = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var configPath = Path.Combine(runtimeRoot, "Config", "RobotConfig.json");
|
||||
var legacyPath = Path.Combine(runtimeRoot, "RobotConfig.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
|
||||
File.WriteAllText(configPath, "{}");
|
||||
File.WriteAllText(legacyPath, "{}");
|
||||
|
||||
var resolved = PathCompatibility.ResolveConfigPath("RobotConfig.json", runtimeRoot);
|
||||
|
||||
Assert.Equal(configPath, resolved);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(runtimeRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证机器人目录优先从显式 ConfigRoot/Models 加载 .robot 文件。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ControllerClientCompatRobotCatalog_LoadsModelFromConfigRootModels()
|
||||
{
|
||||
var configRoot = CreateTempConfigRoot();
|
||||
try
|
||||
{
|
||||
CopySampleRobotModel(configRoot);
|
||||
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
|
||||
var catalog = new ControllerClientCompatRobotCatalog(options, new RobotModelLoader());
|
||||
|
||||
var profile = catalog.LoadProfile("FANUC_LR_Mate_200iD");
|
||||
|
||||
Assert.Equal(Path.Combine(configRoot, "Models", "LR_Mate_200iD_7L.robot"), profile.ModelPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(configRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 JSON 轨迹存储保存、加载和删除都落在 ConfigRoot/TrajectoryStore 目录。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void JsonFlyshotTrajectoryStore_PersistsTrajectoriesUnderConfigRootStore()
|
||||
{
|
||||
var configRoot = CreateTempConfigRoot();
|
||||
try
|
||||
{
|
||||
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
|
||||
var store = new JsonFlyshotTrajectoryStore(options, new RobotConfigLoader());
|
||||
var settings = new CompatibilityRobotSettings(
|
||||
useDo: true,
|
||||
ioAddresses: [7, 8],
|
||||
ioKeepCycles: 2,
|
||||
accLimitScale: 1.0,
|
||||
jerkLimitScale: 1.0,
|
||||
adaptIcspTryNum: 5);
|
||||
var trajectory = TestRobotFactory.CreateUploadedTrajectoryWithSingleShot();
|
||||
|
||||
store.Save("FANUC_LR_Mate_200iD", settings, trajectory);
|
||||
var expectedPath = Path.Combine(configRoot, "TrajectoryStore", "FANUC_LR_Mate_200iD_trajectories.json");
|
||||
|
||||
Assert.True(File.Exists(expectedPath), $"应在运行目录 Config 下创建轨迹文件: {expectedPath}");
|
||||
var loaded = store.LoadAll("FANUC_LR_Mate_200iD", out var loadedSettings);
|
||||
Assert.NotNull(loadedSettings);
|
||||
Assert.Contains(trajectory.Name, loaded);
|
||||
|
||||
store.Delete("FANUC_LR_Mate_200iD", trajectory.Name);
|
||||
|
||||
var afterDelete = store.LoadAll("FANUC_LR_Mate_200iD", out _);
|
||||
Assert.Empty(afterDelete);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(configRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建测试专用的运行目录 Config 根,避免污染真实输出目录。
|
||||
/// </summary>
|
||||
private static string CreateTempConfigRoot()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "flyshot-config-root-tests", Guid.NewGuid().ToString("N"), "Config");
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建测试专用的临时目录。
|
||||
/// </summary>
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "flyshot-config-root-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制仓库内已固化的现场机器人模型到临时 Config/Models 目录。
|
||||
/// </summary>
|
||||
private static void CopySampleRobotModel(string configRoot)
|
||||
{
|
||||
var modelDir = Path.Combine(configRoot, "Models");
|
||||
Directory.CreateDirectory(modelDir);
|
||||
File.Copy(
|
||||
Path.Combine(GetReplacementRoot(), "Config", "Models", "LR_Mate_200iD_7L.robot"),
|
||||
Path.Combine(modelDir, "LR_Mate_200iD_7L.robot"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定位 replacement 仓库根目录,供测试读取仓库内固化样本。
|
||||
/// </summary>
|
||||
private static string GetReplacementRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Unable to locate the flyshot replacement root.");
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
|
||||
var options = new ControllerClientCompatOptions
|
||||
{
|
||||
WorkspaceRoot = TestRobotFactory.GetWorkspaceRoot()
|
||||
ConfigRoot = TestRobotFactory.GetConfigRoot()
|
||||
};
|
||||
var robot = new ControllerClientCompatRobotCatalog(options, new RobotModelLoader())
|
||||
.LoadProfile("FANUC_LR_Mate_200iD", accLimitScale: 1.0, jerkLimitScale: 1.0);
|
||||
@@ -547,7 +547,7 @@ public sealed class FanucControllerRuntimeDenseTests
|
||||
{
|
||||
var options = new ControllerClientCompatOptions
|
||||
{
|
||||
WorkspaceRoot = TestRobotFactory.GetWorkspaceRoot()
|
||||
ConfigRoot = TestRobotFactory.GetConfigRoot()
|
||||
};
|
||||
|
||||
return new ControllerClientCompatService(
|
||||
|
||||
@@ -35,11 +35,12 @@ public sealed class OfflinePlanTests
|
||||
double speedRatio)
|
||||
{
|
||||
var workspaceRoot = GetWorkspaceRoot();
|
||||
var resolvedConfigPath = Path.Combine(workspaceRoot, configPath);
|
||||
var outputDir = Path.Combine(workspaceRoot, "analysis", "output", "dotnet", $"{trajName}_sr{speedRatio:F2}_{(useSelfAdapt ? "adapt" : "icsp")}");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
// 1. 加载配置和模型。
|
||||
var loadedConfig = new RobotConfigLoader().Load(configPath, repoRoot: workspaceRoot);
|
||||
var loadedConfig = new RobotConfigLoader().Load(resolvedConfigPath, repoRoot: workspaceRoot);
|
||||
var program = loadedConfig.Programs[trajName];
|
||||
var resolvedRobotModelPath = Path.Combine(workspaceRoot, robotModelPath);
|
||||
var baseProfile = new RobotModelLoader().LoadProfile(resolvedRobotModelPath, loadedConfig.Robot.AccLimitScale, loadedConfig.Robot.JerkLimitScale);
|
||||
|
||||
@@ -263,11 +263,11 @@ public sealed class RuntimeOrchestrationTests
|
||||
[Fact]
|
||||
public void ControllerClientCompatService_SetUpRobot_AppliesRobotConfigLimitScales()
|
||||
{
|
||||
var tempRoot = CreateTempWorkspaceRoot();
|
||||
var configRoot = CreateTempConfigRoot();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(
|
||||
Path.Combine(tempRoot, "RobotConfig.json"),
|
||||
Path.Combine(configRoot, "RobotConfig.json"),
|
||||
"""
|
||||
{
|
||||
"robot": {
|
||||
@@ -282,7 +282,7 @@ public sealed class RuntimeOrchestrationTests
|
||||
}
|
||||
""");
|
||||
|
||||
var options = new ControllerClientCompatOptions { WorkspaceRoot = tempRoot };
|
||||
var options = new ControllerClientCompatOptions { ConfigRoot = configRoot };
|
||||
var runtime = new RecordingControllerRuntime();
|
||||
var service = new ControllerClientCompatService(
|
||||
options,
|
||||
@@ -300,28 +300,27 @@ public sealed class RuntimeOrchestrationTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
Directory.Delete(configRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建只包含当前支持机器人模型和 RobotConfig.json 的临时工作区。
|
||||
/// 创建只包含当前支持机器人模型和 RobotConfig.json 的临时运行配置根。
|
||||
/// </summary>
|
||||
private static string CreateTempWorkspaceRoot()
|
||||
private static string CreateTempConfigRoot()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "flyshot-runtime-tests", Guid.NewGuid().ToString("N"));
|
||||
var modelDir = Path.Combine(tempRoot, "FlyingShot", "FlyingShot", "Models");
|
||||
var configRoot = Path.Combine(Path.GetTempPath(), "flyshot-runtime-tests", Guid.NewGuid().ToString("N"), "Config");
|
||||
var modelDir = Path.Combine(configRoot, "Models");
|
||||
Directory.CreateDirectory(modelDir);
|
||||
|
||||
var sourceModel = Path.Combine(
|
||||
TestRobotFactory.GetWorkspaceRoot(),
|
||||
"FlyingShot",
|
||||
"FlyingShot",
|
||||
TestRobotFactory.GetReplacementRoot(),
|
||||
"Config",
|
||||
"Models",
|
||||
"LR_Mate_200iD_7L.robot");
|
||||
File.Copy(sourceModel, Path.Combine(modelDir, "LR_Mate_200iD_7L.robot"));
|
||||
|
||||
return tempRoot;
|
||||
return configRoot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +381,7 @@ internal static class TestRobotFactory
|
||||
{
|
||||
var options = new ControllerClientCompatOptions
|
||||
{
|
||||
WorkspaceRoot = GetWorkspaceRoot()
|
||||
ConfigRoot = GetConfigRoot()
|
||||
};
|
||||
|
||||
return new ControllerClientCompatService(
|
||||
@@ -394,6 +393,35 @@ internal static class TestRobotFactory
|
||||
new InMemoryFlyshotTrajectoryStore());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定位 replacement 仓库内的运行配置根目录。
|
||||
/// </summary>
|
||||
/// <returns>当前仓库 Config 目录。</returns>
|
||||
public static string GetConfigRoot()
|
||||
{
|
||||
return Path.Combine(GetReplacementRoot(), "Config");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定位 replacement 仓库根目录,供测试读取仓库内固化配置。
|
||||
/// </summary>
|
||||
/// <returns>replacement 仓库根目录。</returns>
|
||||
public static string GetReplacementRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Unable to locate the flyshot replacement root.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定位父工作区根目录,供兼容服务加载真实机器人模型。
|
||||
/// </summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user