From a6579f1e5b35a945c2789182d1559784e7c654fd Mon Sep 17 00:00:00 2001 From: "yunxiao.zhu" Date: Wed, 29 Apr 2026 18:27:03 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(*):=20=E6=B7=BB=E5=8A=A0=20Con?= =?UTF-8?q?figRoot=20=E8=BF=90=E8=A1=8C=E6=97=B6=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增 ControllerClientCompatOptions.ConfigRoot 及解析方法 * 兼容层默认从运行目录 Config 加载模型、轨迹和配置 * 移除隐式父工作区根目录推断,旧路径仅在显式配置时生效 * Host 项目编译时将 Config 目录复制到输出目录 * 请求响应日志中间件忽略 /api/status/snapshot 高频轮询 * 补充 ConfigRoot 和日志过滤相关单元测试 --- NLog.config | 5 +- .../FANUC_LR_Mate_200iD_trajectories.json | 11 -- .../ControllerClientCompatOptions.cs | 31 +++- .../ControllerClientCompatRobotCatalog.cs | 39 ++--- .../ControllerClientCompatService.cs | 58 ++++--- .../FlyshotTrajectoryStore.cs | 34 +--- src/Flyshot.Core.Config/PathCompatibility.cs | 29 +--- src/Flyshot.Core.Config/RobotConfigLoader.cs | 4 +- .../Flyshot.Server.Host.csproj | 2 + .../RequestResponseLoggingMiddleware.cs | 25 +++ .../ConfigCompatibilityTests.cs | 67 +++++++- .../ControllerClientCompatConfigRootTests.cs | 148 ++++++++++++++++++ .../FanucControllerRuntimeDenseTests.cs | 4 +- tests/Flyshot.Core.Tests/OfflinePlanTests.cs | 3 +- .../RuntimeOrchestrationTests.cs | 54 +++++-- .../RequestResponseLoggingMiddlewareTests.cs | 80 ++++++++++ 16 files changed, 451 insertions(+), 143 deletions(-) delete mode 100644 TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json create mode 100644 tests/Flyshot.Core.Tests/ControllerClientCompatConfigRootTests.cs create mode 100644 tests/Flyshot.Server.IntegrationTests/RequestResponseLoggingMiddlewareTests.cs diff --git a/NLog.config b/NLog.config index d9341a3..3435dc3 100644 --- a/NLog.config +++ b/NLog.config @@ -28,8 +28,9 @@ - - + + + diff --git a/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json b/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json deleted file mode 100644 index 998e6ab..0000000 --- a/TrajectoryStore/FANUC_LR_Mate_200iD_trajectories.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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.ControllerClientCompat/ControllerClientCompatOptions.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatOptions.cs index 5a656b3..100aa18 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatOptions.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatOptions.cs @@ -11,7 +11,36 @@ public sealed class ControllerClientCompatOptions public string ServerVersion { get; set; } = "flyshot-replacement-controller-client-compat/0.1.0"; /// - /// 获取或设置父工作区根目录;为空时由运行时自动推断。 + /// 获取或设置运行配置根目录;为空时默认使用程序基目录下的 Config。 + /// + public string? ConfigRoot { get; set; } + + /// + /// 获取或设置旧父工作区根目录;仅用于测试或旧样本显式兼容。 /// public string? WorkspaceRoot { get; set; } + + /// + /// 解析运行配置根目录,确保运行时默认不再依赖源码仓库位置。 + /// + /// 运行配置根目录的绝对路径。 + public string ResolveConfigRoot() + { + var root = string.IsNullOrWhiteSpace(ConfigRoot) + ? Path.Combine(AppContext.BaseDirectory, "Config") + : ConfigRoot; + + return Path.GetFullPath(root); + } + + /// + /// 解析显式配置的旧父工作区根目录;未配置时返回 null。 + /// + /// 旧父工作区根目录的绝对路径,或 null。 + public string? ResolveLegacyWorkspaceRoot() + { + return string.IsNullOrWhiteSpace(WorkspaceRoot) + ? null + : Path.GetFullPath(WorkspaceRoot); + } } diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs index 6ab5e35..5f97ddf 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatRobotCatalog.cs @@ -9,12 +9,12 @@ namespace Flyshot.ControllerClientCompat; public sealed class ControllerClientCompatRobotCatalog { /// - /// 保存当前现场支持的机器人名称到模型相对路径映射。 + /// 保存当前现场支持的机器人名称到运行目录模型文件名映射。 /// - private static readonly IReadOnlyDictionary SupportedRobotModelMap = new Dictionary(StringComparer.Ordinal) + private static readonly IReadOnlyDictionary SupportedRobotModelFileMap = new Dictionary(StringComparer.Ordinal) { - ["FANUC_LR_Mate_200iD"] = Path.Combine("FlyingShot", "FlyingShot", "Models", "LR_Mate_200iD_7L.robot"), - ["FANUC_LR_Mate_200iD_7L"] = Path.Combine("FlyingShot", "FlyingShot", "Models", "LR_Mate_200iD_7L.robot") + ["FANUC_LR_Mate_200iD"] = "LR_Mate_200iD_7L.robot", + ["FANUC_LR_Mate_200iD_7L"] = "LR_Mate_200iD_7L.robot" }; private readonly ControllerClientCompatOptions _options; @@ -47,39 +47,34 @@ public sealed class ControllerClientCompatRobotCatalog throw new ArgumentException("机器人名称不能为空。", nameof(robotName)); } - if (!SupportedRobotModelMap.TryGetValue(robotName, out var modelRelativePath)) + if (!SupportedRobotModelFileMap.TryGetValue(robotName, out var modelFileName)) { throw new InvalidOperationException($"Unsupported robot name: {robotName}"); } - var workspaceRoot = ResolveWorkspaceRoot(); - var modelPath = Path.Combine(workspaceRoot, modelRelativePath); + var modelPath = ResolveModelPath(modelFileName); return _robotModelLoader.LoadProfile(modelPath, accLimitScale, jerkLimitScale); } /// - /// 解析父工作区根目录,优先使用显式配置。 + /// 解析机器人模型路径,运行目录 Config/Models 优先,旧父工作区只作为显式兼容入口。 /// - /// 包含 `FlyingShot/` 与 `Rvbust/` 的父工作区根目录。 - private string ResolveWorkspaceRoot() + /// 运行目录 Models 下的机器人模型文件名。 + /// 可传给 .robot 加载器的模型文件绝对路径。 + private string ResolveModelPath(string modelFileName) { - if (!string.IsNullOrWhiteSpace(_options.WorkspaceRoot)) + var configModelPath = Path.Combine(_options.ResolveConfigRoot(), "Models", modelFileName); + if (File.Exists(configModelPath)) { - return Path.GetFullPath(_options.WorkspaceRoot); + return configModelPath; } - var current = new DirectoryInfo(AppContext.BaseDirectory); - while (current is not null) + var legacyWorkspaceRoot = _options.ResolveLegacyWorkspaceRoot(); + if (legacyWorkspaceRoot is not null) { - // 宿主和测试都从 replacement 仓库内启动;找到 sln 后回退一级就是父工作区根目录。 - if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln"))) - { - return Path.GetFullPath(Path.Combine(current.FullName, "..")); - } - - current = current.Parent; + return Path.Combine(legacyWorkspaceRoot, "FlyingShot", "FlyingShot", "Models", modelFileName); } - throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root."); + return configModelPath; } } diff --git a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs index 96f3374..fd2629a 100644 --- a/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs +++ b/src/Flyshot.ControllerClientCompat/ControllerClientCompatService.cs @@ -709,24 +709,41 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi } /// - /// 尝试从工作区加载旧版 RobotConfig.json 获取机器人配置;失败时返回 null。 + /// 尝试从配置根目录加载 RobotConfig.json 获取机器人配置;失败时返回 null。 /// /// 加载到的机器人配置,或 null。 private CompatibilityRobotSettings? TryLoadRobotSettings() { - try + foreach (var root in EnumerateRobotConfigRoots()) { - var workspaceRoot = !string.IsNullOrWhiteSpace(_options.WorkspaceRoot) - ? Path.GetFullPath(_options.WorkspaceRoot) - : ResolveWorkspaceRootFromBaseDirectory(); - - var configPath = PathCompatibility.ResolveConfigPath("RobotConfig.json", workspaceRoot); - var loaded = _configLoader.Load(configPath, workspaceRoot); - return loaded.Robot; + try + { + // 运行配置根本身已经是 Config 目录,这里用绝对路径避免再次追加 Config。 + var configPath = Path.Combine(root, "RobotConfig.json"); + var loaded = _configLoader.Load(configPath, root); + return loaded.Robot; + } + catch + { + // 单个候选根目录加载失败时继续尝试下一个兼容入口。 + } } - catch + + return null; + } + + /// + /// 枚举 RobotConfig.json 的配置根目录,运行目录 Config 优先,旧父工作区仅在显式配置时参与。 + /// + /// 待尝试的配置根目录列表。 + private IEnumerable EnumerateRobotConfigRoots() + { + yield return _options.ResolveConfigRoot(); + + var legacyWorkspaceRoot = _options.ResolveLegacyWorkspaceRoot(); + if (legacyWorkspaceRoot is not null) { - return null; + yield return legacyWorkspaceRoot; } } @@ -745,23 +762,4 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi adaptIcspTryNum: 5); } - /// - /// 从当前程序基目录向上搜索 FlyshotReplacement.sln 以推断工作区根目录。 - /// - /// 父工作区根目录。 - private static string ResolveWorkspaceRootFromBaseDirectory() - { - var current = new DirectoryInfo(AppContext.BaseDirectory); - while (current is not null) - { - if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln"))) - { - return Path.GetFullPath(Path.Combine(current.FullName, "..")); - } - - current = current.Parent; - } - - throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root."); - } } diff --git a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs index 7f54aff..68b7e43 100644 --- a/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs +++ b/src/Flyshot.ControllerClientCompat/FlyshotTrajectoryStore.cs @@ -36,7 +36,7 @@ public interface IFlyshotTrajectoryStore } /// -/// 使用与旧版 RobotConfig.json 一致的 JSON 格式持久化飞拍轨迹和机器人配置。 +/// 使用与旧版 RobotConfig.json 一致的 JSON 格式在运行目录 Config 中持久化飞拍轨迹和机器人配置。 /// public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore { @@ -47,7 +47,7 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore /// /// 初始化基于 JSON 文件的轨迹存储。 /// - /// 兼容层基础配置,用于定位工作区根目录。 + /// 兼容层基础配置,用于定位运行配置根目录。 /// 旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。 /// 日志记录器;允许 null。 public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader, ILogger? logger = null) @@ -166,8 +166,7 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore { _logger?.LogInformation("TrajectoryStore 正在加载: {Path}", path); - var workspaceRoot = ResolveWorkspaceRoot(); - var loaded = _configLoader.Load(path, workspaceRoot); + var loaded = _configLoader.Load(path, _options.ResolveConfigRoot()); settings = loaded.Robot; var dict = new Dictionary(StringComparer.Ordinal); @@ -234,32 +233,7 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore /// private string ResolveStorePath(string robotName) { - var workspaceRoot = ResolveWorkspaceRoot(); - var storeDir = Path.Combine(workspaceRoot, "flyshot-replacement", "TrajectoryStore"); + var storeDir = Path.Combine(_options.ResolveConfigRoot(), "TrajectoryStore"); return Path.Combine(storeDir, $"{robotName}_trajectories.json"); } - - /// - /// 解析父工作区根目录,优先使用显式配置。 - /// - private string ResolveWorkspaceRoot() - { - if (!string.IsNullOrWhiteSpace(_options.WorkspaceRoot)) - { - return Path.GetFullPath(_options.WorkspaceRoot); - } - - var current = new DirectoryInfo(AppContext.BaseDirectory); - while (current is not null) - { - if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln"))) - { - return Path.GetFullPath(Path.Combine(current.FullName, "..")); - } - - current = current.Parent; - } - - throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root."); - } } diff --git a/src/Flyshot.Core.Config/PathCompatibility.cs b/src/Flyshot.Core.Config/PathCompatibility.cs index 18a95ef..035d478 100644 --- a/src/Flyshot.Core.Config/PathCompatibility.cs +++ b/src/Flyshot.Core.Config/PathCompatibility.cs @@ -22,7 +22,7 @@ public enum CompatibilityPathStyle public static class PathCompatibility { /// - /// 按旧系统常见目录约定解析配置文件路径。 + /// 按当前服务配置目录约定解析配置文件路径。 /// /// 调用方传入的原始配置路径。 /// 当前兼容搜索的仓库根目录。 @@ -48,11 +48,10 @@ public static class PathCompatibility } var normalizedRepoRoot = Path.GetFullPath(repoRoot); - var fileName = Path.GetFileName(rawPath); var checkedPaths = new List(); - // 先按最常见的候选路径顺序尝试,保持与旧工具链相近的定位逻辑。 - foreach (var candidate in BuildConfigCandidates(normalizedRepoRoot, rawPath, fileName)) + // 相对路径只允许落在当前服务根目录的 Config 下,避免隐式回退到父工作区旧文件。 + foreach (var candidate in BuildConfigCandidates(normalizedRepoRoot, rawPath)) { var fullCandidate = Path.GetFullPath(candidate); if (checkedPaths.Contains(fullCandidate, StringComparer.OrdinalIgnoreCase)) @@ -67,18 +66,6 @@ public static class PathCompatibility } } - // 最后一层兜底按文件名全仓库搜索,但只接受唯一命中,避免同名配置误判。 - var matches = Directory - .EnumerateFiles(normalizedRepoRoot, fileName, SearchOption.AllDirectories) - .Select(Path.GetFullPath) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (matches.Length == 1) - { - return matches[0]; - } - throw new FileNotFoundException( $"未找到配置文件 '{configPath}'。已检查: {string.Join(", ", checkedPaths)}", configPath); @@ -106,15 +93,11 @@ public static class PathCompatibility } /// - /// 枚举旧系统中最常见的配置候选路径。 + /// 枚举当前服务配置目录下允许的配置候选路径。 /// - private static IEnumerable BuildConfigCandidates(string repoRoot, string rawPath, string fileName) + private static IEnumerable BuildConfigCandidates(string repoRoot, string rawPath) { - yield return Path.Combine(repoRoot, rawPath); - yield return Path.Combine(repoRoot, "Rvbust", "Data", fileName); - yield return Path.Combine(repoRoot, "Rvbust", "Install", "FlyingShot", "Config", fileName); - yield return Path.Combine(repoRoot, "Rvbust", fileName); - yield return Path.Combine(repoRoot, fileName); + yield return Path.Combine(repoRoot, "Config", rawPath); } /// diff --git a/src/Flyshot.Core.Config/RobotConfigLoader.cs b/src/Flyshot.Core.Config/RobotConfigLoader.cs index a21906f..6d04c23 100644 --- a/src/Flyshot.Core.Config/RobotConfigLoader.cs +++ b/src/Flyshot.Core.Config/RobotConfigLoader.cs @@ -253,7 +253,7 @@ public sealed class RobotConfigLoader } /// - /// 推断仓库根目录,优先使用调用方显式传入的值。 + /// 推断当前 replacement 仓库根目录,优先使用调用方显式传入的值。 /// private static string ResolveRepoRoot(string? repoRoot) { @@ -267,7 +267,7 @@ public sealed class RobotConfigLoader { if (File.Exists(Path.Combine(current.FullName, "FlyshotReplacement.sln"))) { - return Path.GetFullPath(Path.Combine(current.FullName, "..")); + return current.FullName; } current = current.Parent; diff --git a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj index f76444e..3dbd0b7 100644 --- a/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj +++ b/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj @@ -14,6 +14,8 @@ + + diff --git a/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs b/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs index 5334b19..e8a6794 100644 --- a/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs +++ b/src/Flyshot.Server.Host/Middleware/RequestResponseLoggingMiddleware.cs @@ -18,6 +18,14 @@ public sealed class RequestResponseLoggingMiddleware /// private const int MaxBodyLogLength = 4096; + /// + /// 请求/响应日志忽略路径前缀列表,用于跳过高频轮询接口的常规日志。 + /// + private static readonly string[] IgnoredLogPathPrefixes = + [ + "/api/status/snapshot" + ]; + /// /// 初始化请求响应日志中间件。 /// @@ -35,6 +43,13 @@ public sealed class RequestResponseLoggingMiddleware /// HTTP 上下文。 public async Task InvokeAsync(HttpContext context) { + // 高频状态轮询接口只转发请求,不记录请求/响应日志,避免控制台和文件日志被刷屏。 + if (ShouldSkipRequestResponseLog(context.Request.Path)) + { + await _next(context).ConfigureAwait(false); + return; + } + var stopwatch = Stopwatch.StartNew(); var request = context.Request; var requestId = Activity.Current?.Id ?? context.TraceIdentifier; @@ -144,4 +159,14 @@ public sealed class RequestResponseLoggingMiddleware return body[..MaxBodyLogLength] + " ... [截断,总长度=" + body.Length + "]"; } + + /// + /// 判断当前请求路径是否命中请求/响应日志忽略前缀。 + /// + private static bool ShouldSkipRequestResponseLog(PathString path) + { + var pathValue = path.Value; + return !string.IsNullOrEmpty(pathValue) + && IgnoredLogPathPrefixes.Any(prefix => pathValue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs b/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs index 083be66..47e71bd 100644 --- a/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs +++ b/tests/Flyshot.Core.Tests/ConfigCompatibilityTests.cs @@ -120,10 +120,35 @@ public sealed class ConfigCompatibilityTests } /// - /// 验证路径兼容层既能补旧目录候选,也能按平台策略生成默认用户数据目录。 + /// 验证路径兼容层只从当前服务配置目录解析相对配置,并按平台策略生成默认用户数据目录。 /// [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); + } + } + + /// + /// 验证旧父工作区候选路径存在时也不会被相对配置解析隐式命中。 + /// + [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(() => 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 } } + /// + /// 验证默认加载配置时使用当前 replacement 仓库内的 Config/RobotConfig.json。 + /// + [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); + } + /// /// 定位当前工作区根目录,便于复用父仓库中的真实样本。 /// @@ -164,6 +200,25 @@ public sealed class ConfigCompatibilityTests throw new DirectoryNotFoundException("Unable to locate the flyshot workspace root."); } + /// + /// 定位 replacement 仓库根目录,供测试读取仓库内固化配置。 + /// + 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."); + } + /// /// 创建当前测试专用的临时目录,避免不同测试之间相互污染。 /// diff --git a/tests/Flyshot.Core.Tests/ControllerClientCompatConfigRootTests.cs b/tests/Flyshot.Core.Tests/ControllerClientCompatConfigRootTests.cs new file mode 100644 index 0000000..e5d3af1 --- /dev/null +++ b/tests/Flyshot.Core.Tests/ControllerClientCompatConfigRootTests.cs @@ -0,0 +1,148 @@ +using Flyshot.ControllerClientCompat; +using Flyshot.Core.Config; + +namespace Flyshot.Core.Tests; + +/// +/// 验证 ControllerClient 兼容层默认围绕运行目录 Config 读写配置和轨迹文件。 +/// +public sealed class ControllerClientCompatConfigRootTests +{ + /// + /// 验证路径兼容层优先命中运行目录 Config 下的 RobotConfig.json,而不是旧仓库根目录候选。 + /// + [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); + } + } + + /// + /// 验证机器人目录优先从显式 ConfigRoot/Models 加载 .robot 文件。 + /// + [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); + } + } + + /// + /// 验证 JSON 轨迹存储保存、加载和删除都落在 ConfigRoot/TrajectoryStore 目录。 + /// + [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); + } + } + + /// + /// 创建测试专用的运行目录 Config 根,避免污染真实输出目录。 + /// + private static string CreateTempConfigRoot() + { + var root = Path.Combine(Path.GetTempPath(), "flyshot-config-root-tests", Guid.NewGuid().ToString("N"), "Config"); + Directory.CreateDirectory(root); + return root; + } + + /// + /// 创建测试专用的临时目录。 + /// + private static string CreateTempDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "flyshot-config-root-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + /// + /// 复制仓库内已固化的现场机器人模型到临时 Config/Models 目录。 + /// + 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")); + } + + /// + /// 定位 replacement 仓库根目录,供测试读取仓库内固化样本。 + /// + 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."); + } +} diff --git a/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs b/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs index 321bf4f..6d7cb7e 100644 --- a/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs +++ b/tests/Flyshot.Core.Tests/FanucControllerRuntimeDenseTests.cs @@ -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( diff --git a/tests/Flyshot.Core.Tests/OfflinePlanTests.cs b/tests/Flyshot.Core.Tests/OfflinePlanTests.cs index a536cdd..dc92379 100644 --- a/tests/Flyshot.Core.Tests/OfflinePlanTests.cs +++ b/tests/Flyshot.Core.Tests/OfflinePlanTests.cs @@ -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); diff --git a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs index 44bbfc6..08fb423 100644 --- a/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs +++ b/tests/Flyshot.Core.Tests/RuntimeOrchestrationTests.cs @@ -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); } } /// - /// 创建只包含当前支持机器人模型和 RobotConfig.json 的临时工作区。 + /// 创建只包含当前支持机器人模型和 RobotConfig.json 的临时运行配置根。 /// - 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()); } + /// + /// 定位 replacement 仓库内的运行配置根目录。 + /// + /// 当前仓库 Config 目录。 + public static string GetConfigRoot() + { + return Path.Combine(GetReplacementRoot(), "Config"); + } + + /// + /// 定位 replacement 仓库根目录,供测试读取仓库内固化配置。 + /// + /// replacement 仓库根目录。 + 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."); + } + /// /// 定位父工作区根目录,供兼容服务加载真实机器人模型。 /// diff --git a/tests/Flyshot.Server.IntegrationTests/RequestResponseLoggingMiddlewareTests.cs b/tests/Flyshot.Server.IntegrationTests/RequestResponseLoggingMiddlewareTests.cs new file mode 100644 index 0000000..ce027b7 --- /dev/null +++ b/tests/Flyshot.Server.IntegrationTests/RequestResponseLoggingMiddlewareTests.cs @@ -0,0 +1,80 @@ +using Flyshot.Server.Host.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Flyshot.Server.IntegrationTests; + +/// +/// HTTP 请求响应日志中间件测试。 +/// +public sealed class RequestResponseLoggingMiddlewareTests +{ + /// + /// 高频状态快照路径命中忽略前缀时,不应写入请求和响应日志。 + /// + [Fact] + public async Task InvokeAsync_WhenPathMatchesIgnoredPrefix_DoesNotWriteRequestResponseLogs() + { + var logger = new CapturingLogger(); + 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); + } + + /// + /// 捕获中间件写出的日志条目,避免测试依赖真实 NLog 目标。 + /// + private sealed class CapturingLogger : ILogger + { + /// + /// 已捕获的日志条目。 + /// + public List Entries { get; } = new(); + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + /// + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception))); + } + } + + /// + /// 测试用日志条目。 + /// + /// 日志级别。 + /// 格式化后的日志消息。 + private sealed record LogEntry(LogLevel Level, string Message); +}