From 4191a5bc2e23290a0cf09777b5f82cd85baa29f3 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Fri, 23 Oct 2026 01:50:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20TASK065=20=E8=A1=A5=E6=B5=8B=E7=AC=AC?= =?UTF-8?q?=E5=9B=9B=E6=B3=A2=E4=B8=89=E4=BB=A3=E7=90=86=E6=89=B9=E6=AC=A1?= =?UTF-8?q?=EF=BC=88372=E6=B5=8B=E8=AF=95+PTL/type=E5=8F=8Cbug=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D+372=E5=91=BD=E4=BB=A4=E6=B8=85=E9=9B=B6=EF=BC=89=20|?= =?UTF-8?q?=20=E5=86=B3=E7=AD=96:=20=E4=B8=89=E4=BB=A3=E7=90=86=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E2=80=94=E2=80=94A:=20CompactCommand=20PTL=20?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E8=80=97=E5=B0=BD=E5=BC=82=E5=B8=B8=E5=86=92?= =?UTF-8?q?=E6=B3=A1=E4=BF=AE=E5=A4=8D=EF=BC=88catch=20=E4=BD=93=E5=86=85?= =?UTF-8?q?=20break=20=E8=B5=B0=E6=9C=80=E7=BB=88=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=85=9C=E5=BA=95=EF=BC=8C=E5=BF=AB=E7=85=A7=E7=BF=BB=E8=BD=AC?= =?UTF-8?q?=E4=B8=BA=E9=99=8D=E7=BA=A7=E6=96=AD=E8=A8=80=EF=BC=89+=20Memor?= =?UTF-8?q?yCommand=20--type=20=E5=B0=8F=E5=86=99=E5=89=A5=E7=A6=BB?= =?UTF-8?q?=E6=AE=8B=E7=95=99=EF=BC=88StripOptionToken=20OrdinalIgnoreCase?= =?UTF-8?q?=20=E6=9C=89=E5=90=8D=E5=87=BD=E6=95=B0=EF=BC=89=EF=BC=8C34?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=BA=A2=E8=BD=AC=E7=BB=BF=EF=BC=9BB:=20agen?= =?UTF-8?q?ts/brain=20=E5=9F=9F=2015=20=E5=91=BD=E4=BB=A4=20186=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88insights/falv/goal/ask=20=E5=85=A8?= =?UTF-8?q?=20Moq=20=E9=9A=94=E7=A6=BB=EF=BC=89+3=20bug=20=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=EF=BC=88Proactive=20=E6=9C=8D=E5=8A=A1=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E8=AF=AF=E5=AF=BC=E6=8F=90=E7=A4=BA/Goal=20=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E4=BD=8D=E7=BD=AE=E5=8F=97=E9=99=90/Falv=20=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE=E5=90=8D=E5=9B=9E=E6=98=BE=EF=BC=89=EF=BC=9BC:=20guar?= =?UTF-8?q?d/hands/system/transport=2021=20=E5=91=BD=E4=BB=A4=20186=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95+2=20bug=20=E7=99=BB=E8=AE=B0=EF=BC=88Vim=20s?= =?UTF-8?q?tatus=20=E8=AF=AF=E5=88=87=E6=8D=A2/SandboxToggle=20=E6=95=B4?= =?UTF-8?q?=E4=B8=B2=E5=B0=8F=E5=86=99=E5=8C=96=EF=BC=89+=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=95=B4=E7=90=86=EF=BC=8834=20=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=A7=BB=20commands=5Fwave123/=20=E7=A0=B4?= =?UTF-8?q?=20JCC10001=20=E4=B8=8A=E9=99=90=EF=BC=89=EF=BC=9B=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=20composition=20608=E2=86=92981=EF=BC=8898=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E4=BB=85=E5=89=A9=E5=9F=BA=E7=A1=80=E8=AE=BE?= =?UTF-8?q?=E6=96=BD=E7=B1=BB=E6=97=A0=E6=B5=8B=E8=AF=95=EF=BC=89+?= =?UTF-8?q?=E9=AB=98=E5=8E=8B=E5=9B=9B=E8=BF=9E=E5=85=A8=E7=BB=BF+cli=20?= =?UTF-8?q?=E7=BC=96=E8=AF=91=200=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../composition.tests/GlobalUsings.cs | 9 + .../commands/AddDirCommandTest.cs | 164 ++++++++ .../commands/AdvisorCommandTest.cs | 136 +++++++ .../commands/AskClarifyCommandTest.cs | 160 ++++++++ .../commands/AssistantCommandTest.cs | 136 +++++++ .../commands/BriefCommandTest.cs | 241 +++++++++++ .../commands/BuddyCommandTest.cs | 93 +++++ .../commands/CodeIndexCommandTest.cs | 88 ++++ .../commands/ColorCommandTest.cs | 89 ++++ .../commands/CompactCommandTest.cs | 23 +- .../commands/DesktopCommandTest.cs | 86 ++++ .../commands/DiagCommandTest.cs | 179 ++++++++ .../commands/ExtraUsageCommandTest.cs | 102 +++++ .../commands/FalvCommandTest.cs | 383 ++++++++++++++++++ .../commands/FeedbackCommandTest.cs | 79 ++++ .../commands/GoalCommandTest.cs | 365 +++++++++++++++++ .../commands/HeapdumpCommandTest.cs | 53 +++ .../commands/HistoryCommandTest.cs | 86 ++++ .../commands/InsightsCommandTest.cs | 245 +++++++++++ .../commands/MemoryCommandTest.cs | 24 +- .../commands/OauthRefreshCommandTest.cs | 205 ++++++++++ .../commands/PeersCommandTest.cs | 76 ++++ .../commands/ProactiveCommandTest.cs | 151 +++++++ .../commands/RateLimitCommandTest.cs | 124 ++++++ .../commands/ReleaseNotesCommandTest.cs | 113 ++++++ .../commands/ReviewCommandTest.cs | 116 ++++++ .../commands/SamplingCommandTest.cs | 156 +++++++ .../commands/SandboxToggleCommandTest.cs | 266 ++++++++++++ .../commands/SecurityReviewCommandTest.cs | 135 ++++++ .../commands/SkillsCommandTest.cs | 157 +++++++ .../commands/StatuslineCommandTest.cs | 154 +++++++ .../commands/StickersCommandTest.cs | 66 +++ .../commands/SwitchCommandTest.cs | 154 +++++++ .../commands/TagCommandTest.cs | 185 +++++++++ .../commands/ThinkbackCommandTest.cs | 185 +++++++++ .../commands/ToolsCommandTest.cs | 120 ++++++ .../commands/VimCommandTest.cs | 138 +++++++ .../commands/VoiceCommandTest.cs | 156 +++++++ .../commands/WorkflowsCommandTest.cs | 240 +++++++++++ .../AgentsCommandTest.cs | 0 .../BtwCommandTests.cs | 0 .../CommandOutputCollection.cs | 0 .../CommandTestHarness.cs | 0 .../CommitCommandTests.cs | 0 .../ConfigCommandTest.cs | 0 .../ContextCommandTest.cs | 0 .../DebugLogCommandTest.cs | 0 .../DoctorCommandTest.cs | 0 .../EffortCommandTest.cs | 0 .../ExportCommandTest.cs | 0 .../FastCommandTest.cs | 0 .../ForkCommandTest.cs | 0 .../GitHubRepoNameValidatorTests.cs | 0 .../HelpCommandTest.cs | 0 .../HooksCommandTest.cs | 0 .../InstallGitHubAppCommandExeSmokeTests.cs | 0 .../InstallGitHubAppCommandInjectionTests.cs | 0 .../LoginCommandTest.cs | 0 .../LogoutCommandTest.cs | 0 .../McpCommandTest.cs | 0 .../ModelCommandTest.cs | 0 .../PermissionsCommandTest.cs | 0 .../PluginCommandTest.cs | 0 .../PrivacySettingsCommandTest.cs | 0 .../QuitCommandTest.cs | 0 .../ResetConfigCommandTest.cs | 0 .../StatsCommandTest.cs | 0 .../StatusCommandTest.cs | 0 .../ThemeCommandTest.cs | 0 .../TrustCommandTest.cs | 0 .../UpgradeCommandTest.cs | 0 .../VendorCommandTest.cs | 0 .../VersionCommandTest.cs | 0 .../slash/agents/agent/MemoryCommand.cs | 20 +- .../slash/brain/session/CompactCommand.cs | 6 +- 75 files changed, 5652 insertions(+), 12 deletions(-) create mode 100644 modules/composition/composition.tests/commands/AddDirCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/AdvisorCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/AskClarifyCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/AssistantCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/BriefCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/BuddyCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/CodeIndexCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/ColorCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/DesktopCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/DiagCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/ExtraUsageCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/FalvCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/FeedbackCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/GoalCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/HeapdumpCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/HistoryCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/InsightsCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/OauthRefreshCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/PeersCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/ProactiveCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/RateLimitCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/ReleaseNotesCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/ReviewCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/SamplingCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/SecurityReviewCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/SkillsCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/StatuslineCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/StickersCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/SwitchCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/TagCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/ThinkbackCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/ToolsCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/VimCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/VoiceCommandTest.cs create mode 100644 modules/composition/composition.tests/commands/WorkflowsCommandTest.cs rename modules/composition/composition.tests/{commands => commands_wave123}/AgentsCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/BtwCommandTests.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/CommandOutputCollection.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/CommandTestHarness.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/CommitCommandTests.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/ConfigCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/ContextCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/DebugLogCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/DoctorCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/EffortCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/ExportCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/FastCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/ForkCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/GitHubRepoNameValidatorTests.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/HelpCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/HooksCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/InstallGitHubAppCommandExeSmokeTests.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/InstallGitHubAppCommandInjectionTests.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/LoginCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/LogoutCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/McpCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/ModelCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/PermissionsCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/PluginCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/PrivacySettingsCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/QuitCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/ResetConfigCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/StatsCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/StatusCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/ThemeCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/TrustCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/UpgradeCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/VendorCommandTest.cs (100%) rename modules/composition/composition.tests/{commands => commands_wave123}/VersionCommandTest.cs (100%) diff --git a/modules/composition/composition.tests/GlobalUsings.cs b/modules/composition/composition.tests/GlobalUsings.cs index f65ad08d1d..1ca7d8ef8c 100644 --- a/modules/composition/composition.tests/GlobalUsings.cs +++ b/modules/composition/composition.tests/GlobalUsings.cs @@ -109,9 +109,18 @@ global using Tools; global using Tools.Handlers; global using Xunit.Abstractions; +global using JoinCode.Reasoning.Cone; +global using JoinCode.Reasoning.Engine; +global using JoinCode.Reasoning.Evidence; +global using JoinCode.Reasoning.State; +global using JoinCode.Abstractions.Security.Sandbox; +global using JoinCode.Abstractions.Models.Voice; +global using PluginContext = JoinCode.Abstractions.Entity.PluginContext; +global using Infrastructure.Utils.Resilience; global using AgentInfo = Core.Agents.Coordinator.AgentInfo; global using AgentMsg = JoinCode.Abstractions.Models.Agent.CoordinatorMessage; global using CompressionContentType = JoinCode.Abstractions.Interfaces.Context.ContentType; +global using BudgetStatus = JoinCode.Reasoning.Engine.BudgetStatus; global using CostTracker = Core.CostTracking.CostTracker; global using IMcpClient = JoinCode.Abstractions.Mcp.Client.IMcpClient; global using IToolRegistry = JoinCode.Abstractions.Tools.IToolRegistry; diff --git a/modules/composition/composition.tests/commands/AddDirCommandTest.cs b/modules/composition/composition.tests/commands/AddDirCommandTest.cs new file mode 100644 index 0000000000..894e0957fa --- /dev/null +++ b/modules/composition/composition.tests/commands/AddDirCommandTest.cs @@ -0,0 +1,164 @@ +namespace Composition.Tests.Commands; + +/// +/// /add-dir 命令单元测试 — 覆盖空参列目录/目录不存在/成功添加(信任联动+会话提示)/ +/// 重复添加/--remember 持久化(含重复幂等)/工作区服务缺失降级/已信任不重复信任 +/// IWorkspaceService 经 CommandServices 字段注入;ITrustFolderManager 与 IConfigurationService 经 DI 注入; +/// 目录存在性经 InMemoryFileSystem 验证 +/// +[Collection("command-output")] +public sealed class AddDirCommandTest { + private static (AddDirCommand Command, Mock Workspace, Mock Trust, Mock Config, ChatCommandContext Context, IFileSystem Fs) Arrange( + string arguments = "", + Mock? workspaceOverride = null, + bool withWorkspace = true, + Mock? trustOverride = null, + Mock? configOverride = null, + bool addReturns = true, + IReadOnlyList? additionalDirs = null) { + var workspace = workspaceOverride ?? new Mock(); + workspace.Setup(w => w.AddDirectory(It.IsAny())).Returns(addReturns); + workspace.Setup(w => w.GetAdditionalDirectories()).Returns(additionalDirs ?? []); + + var trust = trustOverride ?? new Mock(); + if (trustOverride is null) { + trust.Setup(t => t.IsTrusted(It.IsAny())).Returns(false); + } + + var config = configOverride ?? new Mock(); + if (configOverride is null) { + config.Setup(c => c.GetAsync("permissions.additionalDirectories", It.IsAny())) + .ReturnsAsync((string?)null); + } + + var fs = TestFileSystem.Current; + var services = CommandTestHarness.CreateServices( + fileSystem: fs, + workspaceService: withWorkspace ? workspace.Object : null, + configureDi: di => { + di.AddSingleton(trust.Object); + di.AddSingleton(config.Object); + }); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new AddDirCommand(), workspace, trust, config, context, fs); + } + + [Fact] + public async Task Execute_EmptyArgs_ListsCurrentDirectories() { + var (cmd, _, _, _, context, _) = Arrange(arguments: "", additionalDirs: [@"C:\extra\a", @"C:\extra\b"]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("当前额外工作目录:"); + output.Should().Contain(@"C:\extra\a"); + output.Should().Contain(@"C:\extra\b"); + } + + [Fact] + public async Task Execute_MissingDirectory_ShowsErrorWithoutAdding() { + var (cmd, workspace, _, _, context, _) = Arrange(arguments: @"C:\no-such-dir"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("目录不存在: C:\\no-such-dir"); + workspace.Verify(w => w.AddDirectory(It.IsAny()), Times.Never, "目录不存在时不应添加"); + } + + [Fact] + public async Task Execute_ExistingDirectory_AddsAndTrustsDirectory() { + var dir = Path.Combine(Path.GetTempPath(), "adddir-basic"); + var (cmd, workspace, trust, _, context, fs) = Arrange(arguments: dir); + fs.CreateDirectory(dir); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + var fullPath = Path.GetFullPath(dir); + workspace.Verify(w => w.AddDirectory(fullPath), Times.Once, "应添加 GetFullPath 归一后的目录"); + trust.Verify(t => t.Trust(fullPath), Times.Once, "添加成功时应联动信任该目录"); + output.Should().Contain($"已添加工作目录: {fullPath} (仅本次会话)"); + output.Should().Contain("/permissions workspace 管理工作区目录"); + } + + [Fact] + public async Task Execute_AlreadyAddedDirectory_ShowsExistsWithoutRetrust() { + var dir = Path.Combine(Path.GetTempPath(), "adddir-dup"); + var (cmd, workspace, trust, _, context, fs) = Arrange(arguments: dir, addReturns: false); + fs.CreateDirectory(dir); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain($"目录已存在: {Path.GetFullPath(dir)}"); + trust.Verify(t => t.Trust(It.IsAny()), Times.Never, "重复添加不应再次触发信任"); + } + + [Fact] + public async Task Execute_WithRemember_PersistsToLocalSettings() { + var dir = Path.Combine(Path.GetTempPath(), "adddir-remember"); + var (cmd, _, _, config, context, fs) = Arrange(arguments: $"{dir} --remember"); + fs.CreateDirectory(dir); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + var fullPath = Path.GetFullPath(dir); + config.Verify(c => c.SetAsync("permissions.additionalDirectories", fullPath, It.IsAny()), + Times.Once, "--remember 应把目录追加持久化到本地设置"); + output.Should().Contain($"已添加工作目录: {fullPath} (已保存到本地设置)"); + } + + [Fact] + public async Task Execute_RememberAlreadyPersisted_DoesNotDuplicate() { + var dir = Path.Combine(Path.GetTempPath(), "adddir-remember-dup"); + var fullPath = Path.GetFullPath(dir); + var config = new Mock(); + config.Setup(c => c.GetAsync("permissions.additionalDirectories", It.IsAny())) + .ReturnsAsync(fullPath); + var (cmd, _, _, _, context, fs) = Arrange(arguments: $"{dir} --remember", configOverride: config); + fs.CreateDirectory(dir); + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + config.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "目录已在持久化列表时不应重复写入相同目录"); + } + + [Fact] + public async Task Execute_WithoutWorkspaceService_DoesNotThrow() { + var dir = Path.Combine(Path.GetTempPath(), "adddir-nows"); + var (cmd, workspace, _, _, context, fs) = Arrange(arguments: dir, withWorkspace: false); + fs.CreateDirectory(dir); + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("工作区服务缺失应优雅降级而非抛异常"); + workspace.Verify(w => w.AddDirectory(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_TrustedDirectory_DoesNotRetrust() { + var dir = Path.Combine(Path.GetTempPath(), "adddir-trusted"); + var trust = new Mock(); + trust.Setup(t => t.IsTrusted(It.IsAny())).Returns(true); + var (cmd, _, trustMock, _, context, fs) = Arrange(arguments: dir, trustOverride: trust); + fs.CreateDirectory(dir); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + trustMock.Verify(t => t.Trust(It.IsAny()), Times.Never, "目录已信任时不应重复调用 Trust"); + output.Should().Contain("已添加工作目录:", "已信任不影响目录添加主路径"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new AddDirCommand(); + cmd.Name.Should().Be("add-dir"); + cmd.Usage.Should().Be("/add-dir [--remember]"); + } +} diff --git a/modules/composition/composition.tests/commands/AdvisorCommandTest.cs b/modules/composition/composition.tests/commands/AdvisorCommandTest.cs new file mode 100644 index 0000000000..5b53afb387 --- /dev/null +++ b/modules/composition/composition.tests/commands/AdvisorCommandTest.cs @@ -0,0 +1,136 @@ +namespace Composition.Tests.Commands; + +/// +/// /advisor 命令单元测试 — 覆盖空参状态查询(启用/禁用)/off 与 unset 关闭/ +/// 有效模型设置/未知模型警告仍设置/服务缺失静默/元数据一致性 +/// AdvisorService 用 Moq 桩经 DI 注入;ModelConfigLoader 用 Moq 桩构造注入 +/// +[Collection("command-output")] +public sealed class AdvisorCommandTest { + private static Mock LoaderWithModels(params string[] modelIds) { + var loader = new Mock(); + loader.Setup(l => l.GetAllModelIds()).Returns(modelIds); + return loader; + } + + private static (AdvisorCommand Command, ChatCommandContext Context, Mock Service) Arrange( + string arguments = "", + Mock? service = null, + Mock? loader = null) { + var advisorService = service ?? new Mock(); + var services = CommandTestHarness.CreateServices( + configureDi: di => di.AddSingleton(advisorService.Object)); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new AdvisorCommand(loader?.Object), context, advisorService); + } + + [Fact] + public async Task Execute_EmptyArgs_AdvisorEnabled_ShowsModelAndReviewHint() { + var service = new Mock(); + service.Setup(s => s.GetIsAdvisorEnabledAsync()).ReturnsAsync(true); + service.Setup(s => s.GetAdvisorModelAsync()).ReturnsAsync("gpt-4o"); + var (cmd, context, _) = Arrange(service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("顾问模型: gpt-4o"); + output.Should().Contain("顾问将审查主模型的工作"); + } + + [Fact] + public async Task Execute_EmptyArgs_AdvisorDisabled_ShowsEnableHint() { + var service = new Mock(); + service.Setup(s => s.GetIsAdvisorEnabledAsync()).ReturnsAsync(false); + var (cmd, context, _) = Arrange(service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("顾问模式:未启用"); + output.Should().Contain("使用 /advisor 启用顾问", "禁用状态应引导用户启用方式"); + } + + [Theory] + [InlineData("off")] + [InlineData("unset")] + [InlineData("OFF")] + [InlineData("Unset")] + public async Task Execute_OffOrUnset_ClearsAdvisorModel(string arguments) { + var service = new Mock(); + var (cmd, context, _) = Arrange(arguments: arguments, service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + service.Verify(s => s.ClearAdvisorModel(), Times.Once, $"{arguments} 应关闭顾问模式(大小写不敏感)"); + output.Should().Contain("顾问模式:已禁用"); + } + + [Fact] + public async Task Execute_KnownModel_SetsAdvisorModelDirectly() { + var service = new Mock(); + var loader = LoaderWithModels("gpt-4o", "claude-3"); + var (cmd, context, _) = Arrange(arguments: "claude-3", service: service, loader: loader); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + service.Verify(s => s.SetAdvisorModel("claude-3"), Times.Once, "已知模型应直接设置"); + output.Should().Contain("顾问模型: claude-3"); + output.Should().Contain("顾问将审查主模型的工作"); + output.Should().NotContain("已知支持模型:", "已知模型不应触发支持列表警告"); + } + + [Fact] + public async Task Execute_UnknownModel_WarnsButStillSets() { + var service = new Mock(); + var loader = LoaderWithModels("gpt-4o", "claude-3"); + var (cmd, context, _) = Arrange(arguments: "llama-x", service: service, loader: loader); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("模型 'llama-x' 可能不支持顾问功能", "未知模型应给出警告"); + output.Should().Contain("已知支持模型:"); + output.Should().Contain(" gpt-4o"); + output.Should().Contain(" claude-3", "警告应列出全部已知模型辅助选择"); + output.Should().Contain("确定设置?输入 /advisor 确认"); + service.Verify(s => s.SetAdvisorModel("llama-x"), Times.Once, + "未知模型仍执行设置(警告不拦截,交由用户确认)"); + } + + [Fact] + public async Task Execute_UnknownModel_WithoutLoader_StillSetsModel() { + var service = new Mock(); + var (cmd, context, _) = Arrange(arguments: "any-model", service: service, loader: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + service.Verify(s => s.SetAdvisorModel("any-model"), Times.Once, + "模型列表加载器缺失(空列表)时仍应设置模型"); + output.Should().Contain("顾问模型: any-model"); + } + + [Fact] + public async Task Execute_WithoutService_ReturnsSilently() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: "gpt-4o"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new AdvisorCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("服务缺失应静默返回而非崩溃"); + output.Should().NotContain("顾问模型:", "无服务时不应渲染顾问状态"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new AdvisorCommand(); + cmd.Name.Should().Be("advisor"); + cmd.Usage.Should().Be("/advisor [model|off]"); + cmd.Description.Should().Contain("顾问模型"); + cmd.ArgumentHint.Should().Be("[model|off]"); + } +} diff --git a/modules/composition/composition.tests/commands/AskClarifyCommandTest.cs b/modules/composition/composition.tests/commands/AskClarifyCommandTest.cs new file mode 100644 index 0000000000..2e4ecf86c4 --- /dev/null +++ b/modules/composition/composition.tests/commands/AskClarifyCommandTest.cs @@ -0,0 +1,160 @@ +namespace Composition.Tests.Commands; + +/// +/// /?? (别名 /ask) 命令单元测试 — 覆盖带需求主路径(系统提示词前缀+响应含完成标记时退出循环)/ +/// ChatService 异常收敛/退出命令识别纯函数/元数据一致性 +/// 命令内部将 TerminalHelper 输出切到真实 stdout(绕过测试捕获),故断言以 +/// ChatService 调用形态(Verify)与结果语义为主,输出文本不作强断言; +/// ChatService 用 Moq 桩自建 CommandServices 注入,不发真实请求 +/// +[Collection("command-output")] +public sealed class AskClarifyCommandTest { + /// 把事件序列包装为异步事件流 — 模拟 LLM 流式响应 + private static async IAsyncEnumerable EventStream( + IReadOnlyList events, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) { + foreach (var evt in events) { + ct.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return evt; + } + } + + private static (AskClarifyCommand Command, ChatCommandContext Context, Mock Chat) Arrange( + string arguments = "做一个 todo 应用") { + var chatService = new Mock(); + chatService.Setup(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny())) + .Returns((string message, CancellationToken ct) => EventStream( + new[] { + ChatStreamEvent.Text("【需求已明确】\n需求清单: 1. 增删改查 2. 本地存储"), + }, ct)); + var commandServices = new CommandServices { + ChatService = chatService.Object, + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + var context = new ChatCommandContext { + Arguments = arguments, + SessionId = "test-session", + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + return (new AskClarifyCommand(), context, chatService); + } + + [Fact] + public async Task Execute_WithRequirement_SendsSystemPromptAndUserNeed() { + var (cmd, context, chat) = Arrange("做一个 todo 应用"); + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + chat.Verify(c => c.StreamWithEventsAsync( + It.Is(msg => + msg.StartsWith(AskClarifyPrompts.SystemPrompt, StringComparison.Ordinal) + && msg.Contains("---") + && msg.Contains("用户需求: 做一个 todo 应用")), + It.IsAny()), Times.Once, + "首轮消息应为系统提示词 + 分隔线 + 用户需求形态"); + } + + [Fact] + public async Task Execute_ResponseContainsDoneMarker_ExitsLoopAfterSingleRound() { + var (cmd, context, chat) = Arrange(); + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + chat.Verify(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()), Times.Once, + "响应含【需求已明确】标记应直接退出澄清循环,只发起一轮请求"); + } + + [Fact] + public async Task Execute_DoneMarkerIsCaseInsensitive_AlsoExits() { + var chat = new Mock(); + chat.Setup(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny())) + .Returns((string message, CancellationToken ct) => EventStream( + new[] { ChatStreamEvent.Text("【需求已明确】") }, ct)); + var commandServices = new CommandServices { + ChatService = chat.Object, + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + var context = new ChatCommandContext { + Arguments = "需求", + SessionId = "test-session", + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => new AskClarifyCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + chat.Verify(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()), Times.Once, + "完成标记匹配应大小写不敏感并退出循环"); + } + + [Fact] + public async Task Execute_ChatServiceThrows_ConvergesWithoutCrash() { + var chat = new Mock(); + chat.Setup(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny())) + .Throws(new InvalidOperationException("provider unavailable")); + var commandServices = new CommandServices { + ChatService = chat.Object, + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + var context = new ChatCommandContext { + Arguments = "做一个 todo 应用", + SessionId = "test-session", + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => new AskClarifyCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("ChatService 异常应被命令内部捕获收敛,不应向上抛出"); + chat.Verify(c => c.StreamWithEventsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Theory] + [InlineData("/end")] + [InlineData("/done")] + [InlineData("/exit")] + [InlineData("/quit")] + [InlineData(" /END ")] + public void IsExitCommand_RecognizesExitCommands(string input) { + AskClarifyCommand.IsExitCommand(input).Should().BeTrue( + $"'{input}' 应识别为退出命令(大小写不敏感+两端空白容忍)"); + } + + [Theory] + [InlineData("/endd")] + [InlineData("hello")] + [InlineData("")] + public void IsExitCommand_RejectsNonExitInput(string input) { + AskClarifyCommand.IsExitCommand(input).Should().BeFalse($"'{input}' 不是退出命令"); + } + + [Fact] + public void ClarifyDoneMarker_ContractValue() { + AskClarifyCommand.ClarifyDoneMarker.Should().Be("【需求已明确】", + "完成标记是 LLM 与宿主的协议契约,变更需两侧同步"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new AskClarifyCommand(); + cmd.Name.Should().Be("??"); + cmd.Aliases.Should().Contain("ask", "ask 为 /?? 的别名"); + cmd.Usage.Should().Contain("/??").And.Contain("/ask"); + cmd.Description.Should().Contain("需求澄清"); + cmd.ArgumentHint.Should().Be("[需求描述]"); + } +} diff --git a/modules/composition/composition.tests/commands/AssistantCommandTest.cs b/modules/composition/composition.tests/commands/AssistantCommandTest.cs new file mode 100644 index 0000000000..36f1bb089c --- /dev/null +++ b/modules/composition/composition.tests/commands/AssistantCommandTest.cs @@ -0,0 +1,136 @@ +namespace Composition.Tests.Commands; + +/// +/// /assistant 命令单元测试 — 覆盖 on/1 启用(环境变量写入+持久化提示)/off/0 停用/ +/// status 状态查询(启用/停用)/未识别参数回退状态/服务缺失静默/元数据一致性 +/// AssistantModeService 用 Moq 桩经 DI 注入;环境变量写入后 finally 恢复避免进程污染 +/// +[Collection("command-output")] +public sealed class AssistantCommandTest { + private const string EnvVarName = "JCC_ASSISTANT_MODE"; + + private static (AssistantCommand Command, ChatCommandContext Context) Arrange( + string arguments = "", + Mock? service = null) { + var services = CommandTestHarness.CreateServices( + configureDi: di => { + if (service is not null) di.AddSingleton(service.Object); + }); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new AssistantCommand(), context); + } + + /// 环境变量作用域 — 写入并在退出时恢复原值,避免污染并行测试进程 + private static IDisposable EnvScope(string value) { + var original = Environment.GetEnvironmentVariable(EnvVarName); + Environment.SetEnvironmentVariable(EnvVarName, value); + return new EnvRestore(EnvVarName, original); + } + + private sealed class EnvRestore(string name, string? original) : IDisposable { + public void Dispose() => Environment.SetEnvironmentVariable(name, original); + } + + [Theory] + [InlineData("on")] + [InlineData("1")] + public async Task Execute_On_SetsEnvVarAndShowsEnabled(string arguments) { + using var _ = EnvScope("0"); + var (cmd, context) = Arrange(arguments: arguments); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + Environment.GetEnvironmentVariable(EnvVarName).Should().Be("1", + $"{arguments} 应写入 {EnvVarName}=1"); + output.Should().Contain("长期助手模式已启用"); + output.Should().Contain($"环境变量 {EnvVarName}=1 已设置", "启用提示应说明环境变量生效范围"); + output.Should().Contain("如需持久化,请在系统环境变量中设置", "应提示持久化方式"); + } + + [Theory] + [InlineData("off")] + [InlineData("0")] + public async Task Execute_Off_ClearsEnvVarAndShowsDisabled(string arguments) { + using var _ = EnvScope("1"); + var (cmd, context) = Arrange(arguments: arguments); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + Environment.GetEnvironmentVariable(EnvVarName).Should().Be("0", + $"{arguments} 应写入 {EnvVarName}=0"); + output.Should().Contain("长期助手模式已停用"); + } + + [Fact] + public async Task Execute_Status_ServiceEnabled_ShowsEnabledState() { + using var _ = EnvScope("1"); + var service = new Mock(); + service.Setup(s => s.IsAssistantModeEnabled).Returns(true); + var (cmd, context) = Arrange(arguments: "status", service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("长期助手模式:"); + output.Should().Contain("状态:已启用"); + output.Should().Contain($"环境变量:{EnvVarName}=1", "状态应回显环境变量当前取值"); + } + + [Fact] + public async Task Execute_Status_ServiceDisabled_ShowsDisabledState() { + var service = new Mock(); + service.Setup(s => s.IsAssistantModeEnabled).Returns(false); + var (cmd, context) = Arrange(arguments: "status", service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("状态:已停用"); + output.Should().Contain("环境变量:JCC_ASSISTANT_MODE=(未设置)", "环境变量未设置时应显示占位文本"); + } + + [Fact] + public async Task Execute_EmptyArgs_DefaultsToStatus() { + var service = new Mock(); + service.Setup(s => s.IsAssistantModeEnabled).Returns(true); + var (cmd, context) = Arrange(arguments: "", service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("长期助手模式:", "空参默认等同 status(NullAction=Status)"); + } + + [Fact] + public async Task Execute_UnrecognizedArg_FallsBackToStatus() { + var service = new Mock(); + service.Setup(s => s.IsAssistantModeEnabled).Returns(false); + var (cmd, context) = Arrange(arguments: "bogus", service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("长期助手模式:", "未识别参数应回退显示状态而非报错"); + } + + [Fact] + public async Task Execute_WithoutService_StatusPrintsNothingButContinues() { + var (cmd, context) = Arrange(arguments: "status", service: null); + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("服务缺失时状态打印跳过,命令不应崩溃"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new AssistantCommand(); + cmd.Name.Should().Be("assistant"); + cmd.Usage.Should().Be("/assistant [on|off|status]"); + cmd.Description.Should().Contain("长期助手模式"); + cmd.IsHidden.Should().BeTrue("assistant 为隐藏命令"); + cmd.ArgumentHint.Should().Be("[on|off|status]"); + } +} diff --git a/modules/composition/composition.tests/commands/BriefCommandTest.cs b/modules/composition/composition.tests/commands/BriefCommandTest.cs new file mode 100644 index 0000000000..ff4d3e0997 --- /dev/null +++ b/modules/composition/composition.tests/commands/BriefCommandTest.cs @@ -0,0 +1,241 @@ +namespace Composition.Tests.Commands; + +/// +/// /brief 命令单元测试 — 覆盖 on/off/toggle 三分支(BriefModeService 状态迁移+状态打印)/ +/// 权限拦截(无 entitlement 不启用)/服务缺失静默/状态变化注入 SystemReminder/ +/// IsEnabled 环境变量覆盖语义/元数据一致性 +/// BriefModeService 用 Moq 桩(闭包状态模拟 Enable/Disable/Toggle 副作用)经 +/// CommandServices.BriefModeService 注入;EntitlementService 经 DI 注入; +/// SystemReminderManager 用真实实例(AddReminderAsync 非 virtual 无法 Moq)验证提醒副作用 +/// +[Collection("command-output")] +public sealed class BriefCommandTest { + private const string EnvVarName = "JCC_BRIEF"; + + /// 带闭包状态的 BriefModeService 桩 — Enable/Disable/Toggle 真实迁移 IsEnabled + private sealed class StatefulBriefService { + public bool Enabled; + public DateTime? EnabledAt; + + public Mock ToMock() { + var mock = new Mock(); + mock.Setup(s => s.IsEnabled).Returns(() => Enabled); + mock.Setup(s => s.EnabledAt).Returns(() => EnabledAt); + mock.Setup(s => s.Enable()).Callback(() => { Enabled = true; EnabledAt = DateTime.UtcNow; }); + mock.Setup(s => s.Disable()).Callback(() => Enabled = false); + mock.Setup(s => s.Toggle()).Callback(() => { + Enabled = !Enabled; + EnabledAt = Enabled ? DateTime.UtcNow : null; + }).Returns(() => Enabled); + return mock; + } + } + + private static (BriefCommand Command, ChatCommandContext Context, StatefulBriefService State) Arrange( + string arguments = "", + IBriefModeService? briefService = null, + IEntitlementService? entitlement = null, + SystemReminderManager? reminderManager = null) { + var state = new StatefulBriefService(); + var di = new ServiceCollection(); + if (entitlement is not null) di.AddSingleton(entitlement); + if (reminderManager is not null) di.AddSingleton(reminderManager); + var commandServices = new CommandServices { + ChatService = Mock.Of(), + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = di.BuildServiceProvider(), + BriefModeService = briefService ?? state.ToMock().Object, + }; + var context = new ChatCommandContext { + Arguments = arguments, + SessionId = "test-session", + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + return (new BriefCommand(), context, state); + } + + private static IEntitlementService Entitlement(bool briefEntitled) { + var mock = new Mock(); + mock.Setup(e => e.IsBriefEntitled).Returns(briefEntitled); + return mock.Object; + } + + [Fact] + public async Task Execute_On_EnablesBriefModeAndPrintsStatus() { + var (cmd, context, state) = Arrange(arguments: "on"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + state.Enabled.Should().BeTrue("on 应调用 Enable 启用简要模式"); + output.Should().Contain("简要消息模式已启用"); + output.Should().Contain("LLM 将通过 send_user_message 工具回复用户", + "启用状态应说明回复通道切换"); + } + + [Fact] + public async Task Execute_On_EnabledWithTimestamp_ShowsEnabledAt() { + var fixedAt = new DateTime(2026, 1, 15, 10, 30, 0); + var mock = new Mock(); + mock.Setup(s => s.IsEnabled).Returns(true); + mock.Setup(s => s.EnabledAt).Returns(fixedAt); + var (cmd, context, _) = Arrange(arguments: "on", briefService: mock.Object); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("启用时间: 2026-01-15 10:30:00", "已启用且有时间时应渲染启用时间"); + } + + [Fact] + public async Task Execute_On_WithoutEntitlement_BlockedWithMutedHint() { + var mock = new Mock(); + mock.Setup(s => s.IsEnabled).Returns(false); + var (cmd, context, _) = Arrange( + arguments: "on", + briefService: mock.Object, + entitlement: Entitlement(briefEntitled: false)); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("简要模式未启用 — 当前账户无权限", "无权限时应拦截并说明原因"); + mock.Verify(s => s.Enable(), Times.Never, "无权限时不应启用"); + } + + [Fact] + public async Task Execute_Off_DisablesBriefModeAndPrintsStatus() { + var (cmd, context, state) = Arrange(arguments: "off"); + state.Enabled = true; + state.EnabledAt = DateTime.UtcNow; + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + state.Enabled.Should().BeFalse("off 应调用 Disable 停用简要模式"); + output.Should().Contain("简要消息模式已禁用 - LLM 将使用普通文本回复"); + } + + [Fact] + public async Task Execute_EmptyArgs_TogglesBriefMode() { + var (cmd, context, state) = Arrange(arguments: ""); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + state.Enabled.Should().BeTrue("空参走 Toggle 分支(NullAction 默认 Toggle),false→true"); + output.Should().Contain("简要消息模式已启用"); + } + + [Fact] + public async Task Execute_EmptyArgs_DisabledWithoutEntitlement_Blocked() { + var mock = new Mock(); + mock.Setup(s => s.IsEnabled).Returns(false); + var (cmd, context, _) = Arrange( + arguments: "", + briefService: mock.Object, + entitlement: Entitlement(briefEntitled: false)); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("简要模式未启用 — 当前账户无权限", "toggle 自关闭态开启同样受权限门控"); + mock.Verify(s => s.Toggle(), Times.Never, "无权限时 toggle 不应执行"); + } + + [Fact] + public async Task Execute_StateChange_InjectsBriefStateReminder() { + await using var reminderManager = new SystemReminderManager(); + var (cmd, context, _) = Arrange(arguments: "on", reminderManager: reminderManager); + + await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + var reminders = await reminderManager.GetRemindersAsync(); + reminders.Should().ContainSingle("状态变化应注入一条 brief-mode-state 提醒"); + var reminder = reminders[0]; + reminder.Id.Should().Be("brief-mode-state"); + reminder.Content.Should().Contain("Brief mode is now enabled"); + reminder.Content.Should().Contain("send_user_message", "提醒应告知 LLM 使用 SendUserMessage 工具"); + reminder.Priority.Should().Be(10); + } + + [Fact] + public async Task Execute_Disable_InjectsDisabledReminder() { + await using var reminderManager = new SystemReminderManager(); + var (cmd, context, state) = Arrange(arguments: "off", reminderManager: reminderManager); + state.Enabled = true; + + await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + var reminders = await reminderManager.GetRemindersAsync(); + reminders.Should().ContainSingle("停用状态变化应注入提醒"); + reminders[0].Content.Should().Contain("Brief mode is now disabled"); + reminders[0].Content.Should().Contain("no longer available"); + } + + [Fact] + public async Task Execute_NoStateChange_NoReminderInjected() { + await using var reminderManager = new SystemReminderManager(); + var mock = new Mock(); + mock.Setup(s => s.IsEnabled).Returns(true); // 已启用,on 无状态变化 + mock.Setup(s => s.Enable()).Callback(() => { }); + var (cmd, context, _) = Arrange(arguments: "on", briefService: mock.Object, reminderManager: reminderManager); + + await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + var reminders = await reminderManager.GetRemindersAsync(); + reminders.Should().BeEmpty("状态未变化时不应重复注入提醒"); + } + + [Fact] + public async Task Execute_WithoutBriefService_ReturnsSilently() { + var commandServices = new CommandServices { + ChatService = Mock.Of(), + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + var context = new ChatCommandContext { + Arguments = "on", + SessionId = "test-session", + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new BriefCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("服务缺失应静默返回而非崩溃"); + output.Should().NotContain("简要消息模式已启用"); + } + + [Theory] + [InlineData("0", false)] + [InlineData("false", false)] + [InlineData("FALSE", false)] + [InlineData("1", true)] + [InlineData("true", true)] + [InlineData("", true)] + public void IsEnabled_EnvVarOverridesDefaultState(string envValue, bool expected) { + var original = Environment.GetEnvironmentVariable(EnvVarName); + try { + Environment.SetEnvironmentVariable(EnvVarName, envValue.Length == 0 ? null : envValue); + new BriefCommand().IsEnabled.Should().Be(expected, + $"环境变量 {EnvVarName}='{envValue}' 应得到 IsEnabled={expected}(非 0/false 即启用,未设置默认启用)"); + } finally { + Environment.SetEnvironmentVariable(EnvVarName, original); + } + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new BriefCommand(); + cmd.Name.Should().Be("brief"); + cmd.Usage.Should().Be("/brief [on|off]"); + cmd.Description.Should().Contain("简要消息模式"); + } +} diff --git a/modules/composition/composition.tests/commands/BuddyCommandTest.cs b/modules/composition/composition.tests/commands/BuddyCommandTest.cs new file mode 100644 index 0000000000..65514a74d1 --- /dev/null +++ b/modules/composition/composition.tests/commands/BuddyCommandTest.cs @@ -0,0 +1,93 @@ +namespace Composition.Tests.Commands; + +/// +/// /buddy 命令单元测试 — 覆盖主路径渲染(ASCII/名称/稀有度/眼睛帽子)/闪光与空帽子形态/ +/// 服务缺失静默降级/元数据一致性 +/// BuddyService 用 Moq 桩经 DI 注入(GetService 解析) +/// +[Collection("command-output")] +public sealed class BuddyCommandTest { + private static BuddyInfo Buddy( + bool shiny = false, + string? hat = "wizard") => new() { + Name = "Pikachu", + Species = "electric-mouse", + Rarity = BuddyRarity.Rare, + Eye = "happy", + Hat = hat ?? string.Empty, + Shiny = shiny, + AsciiArt = @" /\_/\ + ( o.o )", + }; + + private static (BuddyCommand Command, ChatCommandContext Context, Mock Service) Arrange( + Mock? service = null, + string sessionId = "test-session") { + var buddyService = service ?? new Mock(); + var services = CommandTestHarness.CreateServices( + configureDi: di => di.AddSingleton(buddyService.Object)); + var context = CommandTestHarness.CreateContext(services, sessionId: sessionId); + return (new BuddyCommand(), context, buddyService); + } + + [Fact] + public async Task Execute_WithBuddy_RendersAsciiArtAndDetails() { + var service = new Mock(); + service.Setup(s => s.GetBuddy("test-session")).Returns(Buddy()); + var (cmd, context, _) = Arrange(service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain(@" /\_/\", "ASCII 艺术图应原样渲染"); + output.Should().Contain(" Pikachu (electric-mouse)", "名称与物种应成对渲染"); + output.Should().Contain(" 稀有度: Rare"); + output.Should().Contain(" 眼睛: happy 帽子: wizard", "眼睛与帽子应在同一行渲染"); + service.Verify(s => s.GetBuddy("test-session"), Times.Once, "应按会话 ID 查询伙伴"); + } + + [Fact] + public async Task Execute_ShinyBuddy_MarksShinyBadge() { + var service = new Mock(); + service.Setup(s => s.GetBuddy(It.IsAny())).Returns(Buddy(shiny: true)); + var (cmd, context, _) = Arrange(service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("稀有度: Rare ✨闪亮!", "闪光形态应在稀有度后追加徽记"); + } + + [Fact] + public async Task Execute_EmptyHat_ShowsNoneLabel() { + var service = new Mock(); + service.Setup(s => s.GetBuddy(It.IsAny())).Returns(Buddy(hat: null)); + var (cmd, context, _) = Arrange(service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("帽子: 无", "空帽子应显示占位'无'而非空白"); + } + + [Fact] + public async Task Execute_WithoutService_ReturnsSilently() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new BuddyCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("服务缺失应静默返回继续会话而非崩溃"); + output.Should().NotContain("Pikachu", "无服务时不应渲染任何伙伴信息"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new BuddyCommand(); + cmd.Name.Should().Be("buddy"); + cmd.Usage.Should().Be("/buddy"); + cmd.Description.Should().Contain("伙伴精灵"); + cmd.IsHidden.Should().BeTrue("buddy 为隐藏命令"); + } +} diff --git a/modules/composition/composition.tests/commands/CodeIndexCommandTest.cs b/modules/composition/composition.tests/commands/CodeIndexCommandTest.cs new file mode 100644 index 0000000000..e27c178372 --- /dev/null +++ b/modules/composition/composition.tests/commands/CodeIndexCommandTest.cs @@ -0,0 +1,88 @@ +namespace Composition.Tests.Commands; + +/// +/// /code-index 命令单元测试 — 覆盖索引器未注册降级/重建成功(统计渲染)/重建异常降级 +/// ICodeIndexer 经 DI 注入;统计经 IndexStats 桩值断言渲染不变量 +/// +[Collection("command-output")] +public sealed class CodeIndexCommandTest { + private static IndexStats CreateStats() => new() { + FileCount = 12, + SymbolCount = 340, + CallEdgeCount = 56, + DependencyEdgeCount = 78, + ProjectCount = 3, + LastUpdated = DateTimeOffset.UtcNow, + }; + + private static (CodeIndexCommand Command, Mock Indexer, ChatCommandContext Context) Arrange( + bool withIndexer = true, + IndexStats? stats = null, + Exception? rebuildError = null) { + var indexer = new Mock(); + if (rebuildError is not null) { + indexer.Setup(i => i.RebuildIndexAsync(It.IsAny())).ThrowsAsync(rebuildError); + } else { + indexer.Setup(i => i.RebuildIndexAsync(It.IsAny())).Returns(Task.CompletedTask); + } + indexer.Setup(i => i.GetStatsAsync(It.IsAny())).ReturnsAsync(stats ?? CreateStats()); + + var services = CommandTestHarness.CreateServices( + configureDi: di => { + if (withIndexer) di.AddSingleton(indexer.Object); + }); + var context = CommandTestHarness.CreateContext(services); + return (new CodeIndexCommand(), indexer, context); + } + + [Fact] + public async Task Execute_WithoutIndexer_ShowsNotRegistered() { + var (cmd, indexer, context) = Arrange(withIndexer: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("代码索引服务未注册,无法构建索引"); + indexer.Verify(i => i.RebuildIndexAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_WithIndexer_RebuildsAndRendersStats() { + var (cmd, indexer, context) = Arrange(); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + indexer.Verify(i => i.RebuildIndexAsync(It.IsAny()), Times.Once, + "应显式触发索引重建"); + output.Should().Contain("正在构建代码索引..."); + output.Should().Contain("代码索引构建完成"); + output.Should().Contain("文件数: 12"); + output.Should().Contain("符号数: 340"); + output.Should().Contain("调用边: 56"); + output.Should().Contain("依赖边: 78"); + output.Should().Contain("项目数: 3"); + } + + [Fact] + public async Task Execute_RebuildThrows_ShowsFailureWithExceptionMessage() { + var (cmd, indexer, context) = Arrange(rebuildError: new InvalidOperationException("parse failed")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("代码索引构建失败"); + output.Should().Contain("parse failed"); + indexer.Verify(i => i.GetStatsAsync(It.IsAny()), Times.Never, + "重建失败后不应继续读取统计"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new CodeIndexCommand(); + cmd.Name.Should().Be("code-index"); + cmd.Usage.Should().Be("/code-index"); + cmd.ExposeToMcp.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/ColorCommandTest.cs b/modules/composition/composition.tests/commands/ColorCommandTest.cs new file mode 100644 index 0000000000..4cc36ff550 --- /dev/null +++ b/modules/composition/composition.tests/commands/ColorCommandTest.cs @@ -0,0 +1,89 @@ +namespace Composition.Tests.Commands; + +/// +/// /color 命令单元测试 — 覆盖空参/test 颜色测试面板/reset+default 重置确认/ +/// 任意主题名回显+引导提示/大小写归一/命令元数据 +/// 命令零服务依赖,直接经 CommandTestHarness 构造上下文 +/// +[Collection("command-output")] +public sealed class ColorCommandTest { + private static async Task<(ChatCommandResult Result, string Output)> RunAsync(string arguments) { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return await CommandTestHarness.CaptureOutputAsync(() => new ColorCommand().ExecuteAsync(context)); + } + + [Fact] + public async Task Execute_EmptyArgs_ShowsColorTestPanel() { + var (result, output) = await RunAsync(""); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("=== 终端颜色测试 ==="); + output.Should().Contain("基础颜色:"); + output.Should().Contain("绿色(成功)"); + output.Should().Contain("红色(错误)"); + output.Should().Contain("ANSI 256色:"); + output.Should().Contain("样式:"); + output.Should().Contain("说明终端支持 ANSI 颜色"); + } + + [Fact] + public async Task Execute_Test_ShowsColorTestPanel() { + var (result, output) = await RunAsync("test"); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("=== 终端颜色测试 ==="); + output.Should().Contain("基础颜色:"); + } + + [Fact] + public async Task Execute_TestArg_IsCaseInsensitive() { + var (_, output) = await RunAsync("TEST"); + + output.Should().Contain("=== 终端颜色测试 ===", "参数应 ToLowerInvariant 归一后匹配 test"); + } + + [Fact] + public async Task Execute_Reset_ShowsResetConfirmationWithHints() { + var (result, output) = await RunAsync("reset"); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("颜色已重置为默认"); + output.Should().NotContain("颜色主题:"); + } + + [Fact] + public async Task Execute_DefaultAlias_SameAsReset() { + var (result, output) = await RunAsync("default"); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("颜色已重置为默认", "default 是 RESET_ALIASES 之一"); + } + + [Fact] + public async Task Execute_ThemeName_EchoesThemeWithGuide() { + var (result, output) = await RunAsync("ocean"); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("颜色主题: ocean"); + output.Should().Contain("使用 /color test 测试终端颜色支持"); + output.Should().Contain("使用 /color reset 重置为默认"); + output.Should().NotContain("颜色已重置为默认"); + } + + [Fact] + public async Task Execute_TrimsWhitespaceAroundArgs() { + var (_, output) = await RunAsync(" solarized "); + + output.Should().Contain("颜色主题: solarized", "GetNormalizedArgs 应 Trim 后再匹配"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new ColorCommand(); + cmd.Name.Should().Be("color"); + cmd.Usage.Should().Be("/color [theme|test|reset]"); + cmd.Aliases.Should().BeEmpty(); + } +} diff --git a/modules/composition/composition.tests/commands/CompactCommandTest.cs b/modules/composition/composition.tests/commands/CompactCommandTest.cs index da8076e373..d0a005b107 100644 --- a/modules/composition/composition.tests/commands/CompactCommandTest.cs +++ b/modules/composition/composition.tests/commands/CompactCommandTest.cs @@ -132,26 +132,33 @@ public async Task Execute_CompactHistoryPromptTooLong_RewindsAndRetries() { output.Should().Contain("上下文已压缩"); } - /// 生产 bug 登记(TASK065 ai 域): PTL 重试耗尽(attempt=2)时 catch 条件 - /// attempt < maxPtRetries - 1 不再满足,第 3 次 PTL 异常从 ExecuteAsync 直接冒泡 - /// 传播 REPL — "最终失败"兜底块只覆盖 Rewind 零移除 break 路径,不覆盖第三次异常路径。 - /// 本测试为现状快照,修复后应翻转为"走最终兜底 + HandleError 渲染"断言。 + /// 生产 bug 修复验证(TASK065 ai 域): PTL 重试耗尽(attempt=2)时第 3 次异常 + /// 曾因 catch 过滤器 attempt < maxPtRetries - 1 不满足而直接冒泡传播 REPL + /// ("最终失败"兜底当时只覆盖 Rewind 零移除 break 路径)。修复后第 3 次 PTL 同样被捕获, + /// 重试耗尽 break 走最终兜底降级路径:兜底再试一次,仍失败则 HandleError 收敛渲染, + /// 命令 Continue 而非异常传播。 [Fact] - public async Task Execute_PtlRetriesExhausted_ThirdAttemptExceptionEscapes() { + public async Task Execute_PtlRetriesExhausted_ThirdAttemptFallsToFinalAttempt() { var contextManager = new Mock(); contextManager.Setup(m => m.RewindLastTurnAsync(It.IsAny())) .ReturnsAsync(RewindResult.Ok(RewindKind.TrimLastTurn, 2, 1)); var (cmd, context, chat, _) = Arrange(history: CreateHistory(), contextManager: contextManager); chat.SetupSequence(c => c.CompactHistoryAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("prompt too long")) .ThrowsAsync(new InvalidOperationException("prompt too long")) .ThrowsAsync(new InvalidOperationException("prompt too long")) .ThrowsAsync(new InvalidOperationException("prompt too long")); - var act = () => CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); - await act.Should().ThrowAsync().WithMessage("prompt too long"); + result.ShouldContinue.Should().BeTrue("重试耗尽应走最终失败降级路径,命令继续而非异常传播 REPL"); contextManager.Verify(m => m.RewindLastTurnAsync(It.IsAny()), Times.Exactly(2), - "前两次 PTL 各回滚一次,第三次异常不再捕获"); + "前两次 PTL 各回滚一次,第 3 次重试耗尽不再回滚直接走最终兜底"); + chat.Verify(c => c.CompactHistoryAsync(It.IsAny(), It.IsAny()), Times.Exactly(4), + "3 次循环尝试 + 最终兜底 1 次"); + output.Should().NotContain("(3/3)", "重试耗尽不输出第 3 次重试提示"); + output.Should().Contain("生成摘要失败: prompt too long", + "最终兜底仍失败应 HandleError 收敛渲染失败提示"); } [Fact] diff --git a/modules/composition/composition.tests/commands/DesktopCommandTest.cs b/modules/composition/composition.tests/commands/DesktopCommandTest.cs new file mode 100644 index 0000000000..07753c2e15 --- /dev/null +++ b/modules/composition/composition.tests/commands/DesktopCommandTest.cs @@ -0,0 +1,86 @@ +namespace Composition.Tests.Commands; + +/// +/// /desktop 命令单元测试 — 覆盖服务缺失静默/桌面不可用提示安装引导/ +/// 移交成功(会话 ID+连接信息)/移交失败提示 +/// IDesktopHandoffService 经 DI 注入 +/// +[Collection("command-output")] +public sealed class DesktopCommandTest { + private static (DesktopCommand Command, Mock Handoff, ChatCommandContext Context) Arrange( + bool withService = true, + bool isAvailable = true, + bool handoffResult = true, + string? connectionInfo = "named-pipe://jcc-desktop") { + var handoff = new Mock(); + handoff.Setup(h => h.IsDesktopAvailable).Returns(isAvailable); + handoff.Setup(h => h.DesktopConnectionInfo).Returns(connectionInfo); + handoff.Setup(h => h.HandoffToDesktopAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(handoffResult); + + var services = CommandTestHarness.CreateServices( + configureDi: di => { + if (withService) di.AddSingleton(handoff.Object); + }); + var context = CommandTestHarness.CreateContext(services, sessionId: "sess-desktop"); + return (new DesktopCommand(), handoff, context); + } + + [Fact] + public async Task Execute_WithoutService_ReturnsContinueSilently() { + var (cmd, handoff, context) = Arrange(withService: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().BeEmpty("移交服务缺失应静默降级"); + handoff.Verify(h => h.HandoffToDesktopAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_DesktopUnavailable_ShowsInstallGuide() { + var (cmd, handoff, context) = Arrange(isAvailable: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("未检测到桌面应用。"); + output.Should().Contain("请安装 jcc-desktop 桌面应用"); + output.Should().Contain("/bridge 命令通过命名管道连接"); + handoff.Verify(h => h.HandoffToDesktopAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_HandoffSuccess_ShowsSessionAndConnectionInfo() { + var (cmd, handoff, context) = Arrange(handoffResult: true); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + handoff.Verify(h => h.HandoffToDesktopAsync("sess-desktop", It.IsAny()), Times.Once, + "移交应携带当前会话 ID"); + output.Should().Contain("会话 sess-desktop 已转移到桌面应用"); + output.Should().Contain("named-pipe://jcc-desktop"); + } + + [Fact] + public async Task Execute_HandoffFailure_ShowsFailureHint() { + var (cmd, handoff, context) = Arrange(handoffResult: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("会话转移失败,请检查桌面应用是否正在运行"); + output.Should().NotContain("已转移到桌面应用"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new DesktopCommand(); + cmd.Name.Should().Be("desktop"); + cmd.Usage.Should().Be("/desktop"); + cmd.Aliases.Should().Contain("app"); + cmd.IsHidden.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/DiagCommandTest.cs b/modules/composition/composition.tests/commands/DiagCommandTest.cs new file mode 100644 index 0000000000..8182f669e4 --- /dev/null +++ b/modules/composition/composition.tests/commands/DiagCommandTest.cs @@ -0,0 +1,179 @@ +namespace Composition.Tests.Commands; + +/// +/// /diag 命令单元测试 — 覆盖存储未注册降级/空参 recent(空报告+有快照报告)/ +/// fence 按围栏查询(有/无记录)/ack(非法 ID/不存在/成功确认)/detail(非法 ID/成功详情)/未知操作用法 +/// ICrashSnapshotStore 用真实 CrashSnapshotStore 实例经 DI 注入 +/// (recent 分支强转具体类,不能用 Moq);快照经 Add 构造真实数据 +/// +[Collection("command-output")] +public sealed class DiagCommandTest { + private static CrashSnapshot CreateSnapshot( + string fence = "fence-main", + CrashSeverity severity = CrashSeverity.Error, + string message = "计划执行失败") => + new(fence, severity, new InvalidOperationException(message)); + + private static (DiagCommand Command, CrashSnapshotStore Store, ChatCommandContext Context) Arrange( + string arguments = "", + bool withStore = true, + params CrashSnapshot[] snapshots) { + var store = new CrashSnapshotStore(); + foreach (var snapshot in snapshots) { + store.Add(snapshot); + } + var services = CommandTestHarness.CreateServices( + configureDi: di => { + if (withStore) di.AddSingleton(store); + }); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new DiagCommand(), store, context); + } + + [Fact] + public async Task Execute_WithoutStore_ShowsUnavailable() { + var (cmd, store, context) = Arrange(withStore: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("CrashSnapshotStore 未初始化 — 诊断功能不可用"); + } + + [Fact] + public async Task Execute_EmptyArgs_EmptyStore_ShowsEmptyReport() { + var (cmd, _, context) = Arrange(arguments: ""); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("无崩溃快照记录。"); + } + + [Fact] + public async Task Execute_EmptyArgs_WithSnapshots_ShowsRecentReport() { + var snapshot = CreateSnapshot(message: "严重故障"); + var (cmd, _, context) = Arrange(arguments: "", snapshots: [snapshot]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("崩溃快照报告(最近 1 条,共 1 条,未确认 1 条)"); + output.Should().Contain("[ERROR] fence-main"); + output.Should().Contain("严重故障"); + output.Should().Contain($"ID: {snapshot.Id:N}"); + } + + [Fact] + public async Task Execute_RecentArg_SameAsDefault() { + var (cmd, _, context) = Arrange(arguments: "recent", snapshots: [CreateSnapshot()]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("崩溃快照报告(最近 1 条"); + } + + [Fact] + public async Task Execute_Fence_WithRecords_ShowsFilteredSnapshots() { + var (cmd, _, context) = Arrange( + arguments: "fence fence-alpha", + snapshots: [ + CreateSnapshot(fence: "fence-alpha", message: "A 崩溃"), + CreateSnapshot(fence: "fence-beta", message: "B 崩溃"), + ]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("围栏 'fence-alpha' 崩溃记录 (1 条):"); + output.Should().Contain("A 崩溃"); + output.Should().NotContain("B 崩溃", "fence 过滤不应显示其他围栏的快照"); + } + + [Fact] + public async Task Execute_Fence_NoRecords_ShowsEmptyHint() { + var (cmd, _, context) = Arrange(arguments: "fence fence-nope", snapshots: [CreateSnapshot(fence: "fence-other")]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("围栏 'fence-nope' 无崩溃记录"); + } + + [Fact] + public async Task Execute_Ack_InvalidGuid_ShowsInvalidId() { + var (cmd, _, context) = Arrange(arguments: "ack not-a-guid"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("无效的快照 ID: not-a-guid"); + } + + [Fact] + public async Task Execute_Ack_UnknownGuid_ShowsNotFound() { + var (cmd, _, context) = Arrange(arguments: "ack 00000000-0000-0000-0000-000000000000"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("不存在"); + } + + [Fact] + public async Task Execute_Ack_ExistingSnapshot_AcknowledgesIt() { + var snapshot = CreateSnapshot(); + var (cmd, store, context) = Arrange(arguments: $"ack {snapshot.Id}", snapshots: [snapshot]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain($"快照 {snapshot.Id:N} 已确认"); + store.UnacknowledgedCount.Should().Be(0, "确认后未确认计数应归零"); + } + + [Fact] + public async Task Execute_Detail_InvalidGuid_ShowsInvalidId() { + var (cmd, _, context) = Arrange(arguments: "detail abc"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("无效的快照 ID: abc"); + } + + [Fact] + public async Task Execute_Detail_ExistingSnapshot_ShowsFullDetail() { + var snapshot = CreateSnapshot(fence: "fence-detail", message: "详情异常消息"); + var (cmd, _, context) = Arrange(arguments: $"detail {snapshot.Id}", snapshots: [snapshot]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("═══ 崩溃快照"); + output.Should().Contain("围栏: fence-detail"); + output.Should().Contain("严重度: ERROR"); + output.Should().Contain("详情异常消息"); + output.Should().Contain("异常类型: System.InvalidOperationException"); + } + + [Fact] + public async Task Execute_UnknownAction_ShowsUsage() { + var (cmd, _, context) = Arrange(arguments: "bogus"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("用法: /diag [recent|fence |ack |detail ]"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new DiagCommand(); + cmd.Name.Should().Be("diag"); + cmd.Usage.Should().Be("/diag [recent|fence |ack |clear]"); + cmd.IsHidden.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/ExtraUsageCommandTest.cs b/modules/composition/composition.tests/commands/ExtraUsageCommandTest.cs new file mode 100644 index 0000000000..5354e96bb6 --- /dev/null +++ b/modules/composition/composition.tests/commands/ExtraUsageCommandTest.cs @@ -0,0 +1,102 @@ +namespace Composition.Tests.Commands; + +/// +/// /extra-usage 命令单元测试 — 覆盖今日/累计统计渲染(N0/F4 格式化)/空统计全零/ +/// 追踪器缺失降级/统计异常收敛/元数据一致性 +/// UsageTracker 用 Moq 桩经 CommandServices.UsageTracker 可选字段注入 +/// +[Collection("command-output")] +public sealed class ExtraUsageCommandTest { + private static TokenUsageStatistics CreateStats( + int requests = 5, + long input = 1200, + long output = 800, + decimal cost = 0.05m) => new() { + TotalRequests = requests, + TotalInputTokens = input, + TotalOutputTokens = output, + TotalCostUsd = cost, + }; + + private static (ExtraUsageCommand Command, ChatCommandContext Context, Mock Tracker) Arrange( + string arguments = "", + Mock? tracker = null) { + var usageTracker = tracker ?? new Mock(); + var services = CommandTestHarness.CreateServices(usageTracker: usageTracker.Object); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new ExtraUsageCommand(), context, usageTracker); + } + + [Fact] + public async Task Execute_WithTracker_RendersTodayAndTotalStats() { + var tracker = new Mock(); + tracker.Setup(t => t.GetTodayStatistics()).Returns(CreateStats()); + tracker.Setup(t => t.GetTotalStatistics()).Returns(CreateStats(requests: 42, input: 999999, output: 888888, cost: 12.3456m)); + var (cmd, context, _) = Arrange(tracker: tracker); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("额外用量:"); + output.Should().Contain(" 今日统计:"); + output.Should().Contain(" 请求数: 5"); + output.Should().Contain(" 输入 Token: 1,200", "Token 数量应 N0 千分位格式化"); + output.Should().Contain(" 输出 Token: 800"); + output.Should().Contain(" 估算成本: $0.0500", "成本应 F4 四位小数格式化"); + output.Should().Contain(" 累计统计:"); + output.Should().Contain(" 总请求数: 42"); + output.Should().Contain(" 总输入 Token: 999,999"); + output.Should().Contain(" 总输出 Token: 888,888"); + output.Should().Contain(" 总估算成本: $12.3456"); + tracker.Verify(t => t.GetTodayStatistics(), Times.Once); + tracker.Verify(t => t.GetTotalStatistics(), Times.Once); + } + + [Fact] + public async Task Execute_EmptyStats_RendersZeroValues() { + var tracker = new Mock(); + tracker.Setup(t => t.GetTodayStatistics()).Returns(new TokenUsageStatistics()); + tracker.Setup(t => t.GetTotalStatistics()).Returns(new TokenUsageStatistics()); + var (cmd, context, _) = Arrange(tracker: tracker); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("请求数: 0", "空统计(全零)应渲染面板而非空输出或崩溃"); + output.Should().Contain("估算成本: $0.0000"); + } + + [Fact] + public async Task Execute_WithoutTracker_ShowsUnavailable() { + var services = CommandTestHarness.CreateServices(usageTracker: null); + var context = CommandTestHarness.CreateContext(services); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new ExtraUsageCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("追踪器缺失应降级提示而非崩溃"); + output.Should().Contain("额外用量:"); + output.Should().Contain(" 用量追踪器未初始化"); + } + + [Fact] + public async Task Execute_StatsFailure_ConvergesToErrorHint() { + var tracker = new Mock(); + tracker.Setup(t => t.GetTodayStatistics()).Throws(new InvalidOperationException("disk locked")); + var (cmd, context, _) = Arrange(tracker: tracker); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("统计异常应收敛为错误提示而非向上抛出"); + output.Should().Contain("获取用量数据失败: disk locked"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new ExtraUsageCommand(); + cmd.Name.Should().Be("extra-usage"); + cmd.Usage.Should().Be("/extra-usage"); + cmd.Description.Should().Contain("额外用量"); + cmd.IsHidden.Should().BeTrue("extra-usage 为隐藏命令"); + } +} diff --git a/modules/composition/composition.tests/commands/FalvCommandTest.cs b/modules/composition/composition.tests/commands/FalvCommandTest.cs new file mode 100644 index 0000000000..53f0d9f8e6 --- /dev/null +++ b/modules/composition/composition.tests/commands/FalvCommandTest.cs @@ -0,0 +1,383 @@ +namespace Composition.Tests.Commands; + +/// +/// /falv 命令单元测试 — 覆盖帮助(空参/-h)/引擎缺失降级/自由文本添加假定/--status 摘要与数据项渲染/ +/// --budget 预算渲染(正常/耗尽)/--judge 裁决渲染/--evidence 证据链(空/非空)/--reset/ +/// --continue 续费(未耗尽提示/耗尽续费/模式解析)/--cone 视锥/--conflict 冲突检测/元数据一致性 +/// ReasoningEngine 用 Moq 桩经 DI 注入,集合类方法全部 Setup 空返回避免 null 枚举崩溃 +/// +[Collection("command-output")] +public sealed class FalvCommandTest { + /// 剥离 ANSI 转义序列 — 预算/裁决/状态渲染带颜色码,断言基于纯文本 + private static string StripAnsi(string text) => + System.Text.RegularExpressions.Regex.Replace(text, @"\x1B\[[0-9;]*m", string.Empty); + + /// 构造全方法安全的引擎桩 — 集合方法返回空集合,摘要/预算返回默认实例 + private static Mock CreateEngine( + ReasoningSummary? summary = null, + BudgetStatus? budget = null, + IReadOnlyList? items = null, + IReadOnlyList? facts = null, + IReadOnlyList? evidence = null) { + var engine = new Mock(); + engine.Setup(e => e.GetSummary()).Returns(summary ?? new ReasoningSummary()); + engine.Setup(e => e.GetBudgetStatus()).Returns(budget ?? new BudgetStatus()); + engine.Setup(e => e.GetAllItems()).Returns((items ?? []).AsEnumerable()); + engine.Setup(e => e.GetFacts()).Returns((facts ?? []).AsEnumerable()); + engine.Setup(e => e.GetAllEvidence()).Returns((evidence ?? []).AsEnumerable()); + engine.Setup(e => e.AddAssumptionsAsync(It.IsAny>(), It.IsAny())) + .Returns(Task.CompletedTask); + engine.Setup(e => e.RunAdversarialProcessAsync(It.IsAny())).Returns(Task.CompletedTask); + engine.Setup(e => e.ContinueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + engine.Setup(e => e.ExpandFragment(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((ObservationFragment?)null); + engine.Setup(e => e.DetectConeConflict(It.IsAny(), It.IsAny())) + .Returns(new ConeConflictResult { + RoleA = AgentRole.Prosecutor, + RoleB = AgentRole.Defender, + RoleAConclusions = [], + RoleBConclusions = [], + HasConflict = false, + }); + return engine; + } + + private static (FalvCommand Command, ChatCommandContext Context, Mock Engine) Arrange( + string arguments = "", + Mock? engine = null) { + var reasoningEngine = engine ?? CreateEngine(); + var services = CommandTestHarness.CreateServices( + configureDi: di => di.AddSingleton(reasoningEngine.Object)); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new FalvCommand(), context, reasoningEngine); + } + + [Theory] + [InlineData("")] + [InlineData("-h")] + [InlineData("--help")] + public async Task Execute_HelpAlias_ShowsUsageHelp(string arguments) { + var (cmd, context, _) = Arrange(arguments: arguments); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("用法: /falv <假定内容> | /falv --status |", "空参与 -h/--help 应显示帮助"); + output.Should().Contain("结构化推理引擎 — 假定→验证→事实 三态跃迁"); + output.Should().Contain("--judge 触发三权裁决(控方→辩方→法官)"); + } + + [Fact] + public async Task Execute_WithoutEngine_ShowsError() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: "--status"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new FalvCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("推理引擎未初始化", "引擎缺失应提示而非崩溃"); + } + + [Fact] + public async Task Execute_FreeText_AddsAssumptionWithDefaults() { + var engine = CreateEngine(); + var (cmd, context, _) = Arrange(arguments: "地球是圆的", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.AddAssumptionsAsync( + It.Is>(list => + list.Count == 1 + && list[0].Content == "地球是圆的" + && list[0].State == DataState.Assumption + && list[0].Source == "用户输入"), + It.IsAny()), Times.Once, "自由文本应构造假定数据项入引擎"); + clean.Should().Contain("[假定] 地球是圆的"); + } + + [Fact] + public async Task Execute_Status_RendersSummaryAndItems() { + var engine = CreateEngine( + summary: new ReasoningSummary { + TotalAssumptions = 3, + TotalVerified = 2, + TotalFacts = 1, + TotalRejected = 1, + TotalPendingEvidence = 2, + TotalEvidence = 4, + LastRunAt = new DateTime(2026, 1, 15, 10, 30, 0), + }, + items: new List { + new() { Content = "已确认事实", State = DataState.Fact, Confidence = 95 }, + new() { Content = "待验证假定", State = DataState.Assumption, Confidence = 50 }, + }); + var (cmd, context, _) = Arrange(arguments: "--status", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + clean.Should().Contain("=== 推理引擎状态 ==="); + clean.Should().Contain(" 假定: 3"); + clean.Should().Contain(" 已验证: 2"); + clean.Should().Contain(" 事实: 1"); + clean.Should().Contain(" 被驳斥: 1"); + clean.Should().Contain(" 待补充: 2"); + clean.Should().Contain(" 证据总数: 4"); + clean.Should().Contain(" 最近裁决: 10:30:00", "LastRunAt 有值时应渲染裁决时间"); + clean.Should().Contain("=== 预算状态 ==="); + clean.Should().Contain("=== 所有数据项 ==="); + clean.Should().Contain("[事实] 已确认事实 (置信度:95%)"); + clean.Should().Contain("[假定] 待验证假定 (置信度:50%)", "数据项应按状态标签渲染"); + } + + [Fact] + public async Task Execute_Budget_RendersUsedAndRemaining() { + var engine = CreateEngine(budget: new BudgetStatus { + RoundsUsed = 2, + RoundsBudget = 5, + TokensUsed = 100, + TokensBudget = 200, + }); + var (cmd, context, _) = Arrange(arguments: "--budget", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + clean.Should().Contain("=== 预算状态 ==="); + clean.Should().Contain("轮次: 2/5 (剩余 3)"); + clean.Should().Contain("Token: 100/200 (剩余 100)", "预算应渲染已用/上限与剩余"); + } + + [Fact] + public async Task Execute_BudgetExhausted_ShowsContinueHint() { + var engine = CreateEngine(budget: new BudgetStatus { + RoundsUsed = 5, + RoundsBudget = 5, + TokensUsed = 200, + TokensBudget = 200, + }); + var (cmd, context, _) = Arrange(arguments: "--budget", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("同时触底 — 使用 /falv --continue 续费", "双预算耗尽应提示续费方式"); + } + + [Fact] + public async Task Execute_Judge_RunsAdversarialProcessAndRendersVerdicts() { + var engine = CreateEngine( + facts: new List { new() { Content = "裁决事实", State = DataState.Fact, Confidence = 90 } }, + items: new List { + new() { Content = "被驳斥项", State = DataState.Rejected }, + new() { Content = "待补充项", State = DataState.PendingEvidence }, + new() { Content = "已验证项", State = DataState.Verified, Confidence = 80 }, + }); + var (cmd, context, _) = Arrange(arguments: "--judge", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.RunAdversarialProcessAsync(It.IsAny()), Times.Once, + "--judge 应触发三权裁决流程"); + clean.Should().Contain("=== 裁决结果 ==="); + clean.Should().Contain("事实: 裁决事实 (置信度:90%)"); + clean.Should().Contain("被驳斥: 被驳斥项"); + clean.Should().Contain("待补充: 待补充项"); + clean.Should().Contain("已验证(部分接受): 已验证项 (置信度:80%)", "裁决应覆盖事实/驳斥/待补充/已验证四类"); + } + + [Fact] + public async Task Execute_EvidenceEmpty_ShowsPlaceholder() { + var (cmd, context, _) = Arrange(arguments: "--evidence"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("=== 证据链 ==="); + output.Should().Contain(" 暂无证据", "空证据链应显示占位提示"); + } + + [Fact] + public async Task Execute_EvidenceWithRecords_RendersCategoryAndTrust() { + var engine = CreateEngine(evidence: new List { + new() { + Content = "日志文件证明重启发生在 03:00", + Category = EvidenceCategory.Digital, + TrustLevel = TrustLevel.DirectEvidence, + SubmittedBy = AgentRole.Prosecutor, + IsUrlVerified = true, + }, + }); + var (cmd, context, _) = Arrange(arguments: "--evidence", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("=== 证据链 ==="); + output.Should().Contain("[Digital] 日志文件证明重启发生在 03:00 (信任度:DirectEvidence, 提交方:Prosecutor, URL验证:✓)", + "证据应渲染分类/内容/信任度/提交方/URL验证状态"); + } + + [Fact] + public async Task Execute_Reset_ResetsEngine() { + var engine = CreateEngine(); + var (cmd, context, _) = Arrange(arguments: "--reset", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.Reset(), Times.Once, "--reset 应清空引擎状态"); + output.Should().Contain("推理引擎已重置 — DAG清空,预算恢复,视锥重置"); + } + + [Fact] + public async Task Execute_Continue_BudgetNotExhausted_ShowsHintOnly() { + var engine = CreateEngine(budget: new BudgetStatus { + RoundsUsed = 1, + RoundsBudget = 5, + TokensUsed = 50, + TokensBudget = 200, + }); + var (cmd, context, _) = Arrange(arguments: "--continue", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("预算尚未耗尽 — 轮次:1/5 token:50/200"); + output.Should().Contain("仍可继续推理,输入 /falv --judge 即可", "未耗尽时应引导直接裁决而非续费"); + engine.Verify(e => e.ContinueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, + "预算未耗尽时不应触发续费"); + } + + [Fact] + public async Task Execute_Continue_Exhausted_RefillsAndContinues() { + var engine = CreateEngine(budget: new BudgetStatus { + RoundsUsed = 5, + RoundsBudget = 5, + TokensUsed = 200, + TokensBudget = 200, + }); + var (cmd, context, _) = Arrange(arguments: "--continue both", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.ContinueAsync(BudgetRefillMode.Both, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, + "--continue both 应以 Both 模式续费"); + clean.Should().Contain("[预算耗尽] 轮次和Token预算均耗尽"); + clean.Should().Contain("续费方式: Both", "续费方式回显解析后的枚举名"); + clean.Should().Contain("继续推理中..."); + } + + [Theory] + [InlineData("--continue rounds", BudgetRefillMode.RoundsOnly)] + [InlineData("--continue tokens", BudgetRefillMode.TokensOnly)] + [InlineData("--continue default", BudgetRefillMode.Default)] + [InlineData("--continue", BudgetRefillMode.Default)] + public async Task Execute_ContinueModes_ParsedAndForwarded(string arguments, BudgetRefillMode expectedMode) { + var engine = CreateEngine(budget: new BudgetStatus { + RoundsUsed = 5, + RoundsBudget = 5, + }); + var (cmd, context, _) = Arrange(arguments: arguments, engine: engine); + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.ContinueAsync(expectedMode, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, + $"'{arguments}' 应解析为 {expectedMode} 续费模式(缺省回退 default)"); + } + + [Fact] + public async Task Execute_Cone_RendersRoleFragments() { + var engine = CreateEngine(); + var (cmd, context, _) = Arrange(arguments: "--cone", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("=== 有限视锥 ==="); + output.Should().Contain("[Prosecutor] 活跃片段:"); + output.Should().Contain("[Defender] 活跃片段:"); + output.Should().Contain("[Judge] 活跃片段:", "视锥应遍历全部三权角色"); + engine.Verify(e => e.ExpandFragment(It.IsAny(), "", "*"), Times.AtLeast(3), + "视锥渲染应对每个角色展开片段"); + } + + [Fact] + public async Task Execute_Conflict_NoConflict_ShowsNone() { + var engine = CreateEngine(); + var (cmd, context, _) = Arrange(arguments: "--conflict", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.DetectConeConflict(AgentRole.Prosecutor, AgentRole.Defender), Times.Once); + engine.Verify(e => e.DetectConeConflict(AgentRole.Prosecutor, AgentRole.Judge), Times.Once); + engine.Verify(e => e.DetectConeConflict(AgentRole.Defender, AgentRole.Judge), Times.Once, + "冲突检测应覆盖控辩/控法官/辩法官三对组合"); + output.Should().Contain("=== 视锥冲突检测 ==="); + output.Should().Contain("冲突状态: 无冲突"); + output.Should().Contain("控方-法官冲突: 无"); + output.Should().Contain("辩方-法官冲突: 无"); + } + + [Fact] + public async Task Execute_Conflict_WithConflict_ShowsWarning() { + var engine = CreateEngine(); + engine.Setup(e => e.DetectConeConflict(AgentRole.Prosecutor, AgentRole.Defender)) + .Returns(new ConeConflictResult { + RoleA = AgentRole.Prosecutor, + RoleB = AgentRole.Defender, + RoleAConclusions = ["控方结论A"], + RoleBConclusions = ["辩方结论B"], + HasConflict = true, + }); + var (cmd, context, _) = Arrange(arguments: "--conflict", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + clean.Should().Contain("控方结论: 控方结论A"); + clean.Should().Contain("辩方结论: 辩方结论B"); + clean.Should().Contain("冲突状态: 存在冲突", "检测到冲突时应高亮警示"); + } + + [Fact] + public async Task Execute_AddAssumption_BudgetExhausted_ShowsRefillHint() { + var engine = CreateEngine(budget: new BudgetStatus { + RoundsUsed = 5, + RoundsBudget = 5, + TokensUsed = 10, + TokensBudget = 100, + }); + var (cmd, context, _) = Arrange(arguments: "新假定", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + clean.Should().Contain("[假定] 新假定"); + clean.Should().Contain("⚠ 轮次预算耗尽", "添加假定后预算耗尽应附加续费提示"); + clean.Should().Contain("使用 /falv --continue [rounds|tokens|both|default] 续费并继续推理"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new FalvCommand(); + cmd.Name.Should().Be("falv"); + cmd.Description.Should().Contain("结构化推理"); + cmd.Usage.Should().StartWith("/falv <假定内容>"); + cmd.ArgumentHint.Should().Contain("--status").And.Contain("--judge"); + } +} diff --git a/modules/composition/composition.tests/commands/FeedbackCommandTest.cs b/modules/composition/composition.tests/commands/FeedbackCommandTest.cs new file mode 100644 index 0000000000..8fc2b591cf --- /dev/null +++ b/modules/composition/composition.tests/commands/FeedbackCommandTest.cs @@ -0,0 +1,79 @@ +namespace Composition.Tests.Commands; + +/// +/// /feedback 命令单元测试 — 覆盖空参输入引导/有参脱敏保存(feedback 目录创建+markdown 落盘+ +/// 成功渲染)/保存路径提示 +/// 写入经 InMemoryFileSystem(0磁盘IO),反馈目录为 AppDataConstants.Paths.JccDirectory 派生路径 +/// +[Collection("command-output")] +public sealed class FeedbackCommandTest { + private static (FeedbackCommand Command, ChatCommandContext Context, IFileSystem Fs) Arrange(string arguments = "") { + var fs = TestFileSystem.Current; + var services = CommandTestHarness.CreateServices(fileSystem: fs); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new FeedbackCommand(), context, fs); + } + + [Fact] + public async Task Execute_EmptyArgs_ShowsInputGuide() { + var (cmd, context, _) = Arrange(arguments: ""); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("反馈"); + output.Should().Contain("请输入您的反馈内容:"); + output.Should().NotContain("已保存到:"); + } + + [Fact] + public async Task Execute_WithFeedbackText_SavesMarkdownFile() { + var (cmd, context, fs) = Arrange(arguments: "界面加载有点慢"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + var feedbackDir = Path.Combine(AppDataConstants.Paths.JccDirectory, "feedback"); + fs.DirectoryExists(feedbackDir).Should().BeTrue("反馈目录应被创建"); + var files = fs.GetFiles(feedbackDir, "feedback_*.md", System.IO.SearchOption.TopDirectoryOnly); + files.Should().HaveCount(1, "应恰好保存一个反馈文件"); + var content = await fs.ReadAllTextAsync(files[0], CancellationToken.None); + content.Should().Contain("# 反馈"); + content.Should().Contain("**时间**:"); + content.Should().Contain("界面加载有点慢"); + } + + [Fact] + public async Task Execute_WithFeedbackText_ShowsSuccessAndSavedPath() { + var (cmd, context, _) = Arrange(arguments: "功能建议:增加主题切换"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("反馈已提交"); + output.Should().Contain("功能建议:增加主题切换"); + output.Should().Contain("已保存到:"); + output.Should().Contain("feedback"); + } + + [Fact] + public async Task Execute_TrimsSurroundingWhitespace() { + var (cmd, context, fs) = Arrange(arguments: " 带空格的反馈 "); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("反馈已提交", "Trim 后非空应走保存主路径"); + output.Should().Contain("带空格的反馈"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new FeedbackCommand(); + cmd.Name.Should().Be("feedback"); + cmd.Usage.Should().Be("/feedback [反馈内容]"); + cmd.Aliases.Should().Contain("bug"); + cmd.IsHidden.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/GoalCommandTest.cs b/modules/composition/composition.tests/commands/GoalCommandTest.cs new file mode 100644 index 0000000000..abaa9bcad2 --- /dev/null +++ b/modules/composition/composition.tests/commands/GoalCommandTest.cs @@ -0,0 +1,365 @@ +namespace Composition.Tests.Commands; + +/// +/// /goal 命令单元测试 — 覆盖引擎缺失降级/空参(无目标启动 GoalSpec 收集/有目标显示状态)/ +/// pause/resume/clear 生命周期(含别名族)/带初始提示与约束预算启动/StartAsync 异常收敛/ +/// --cron 定时模式(注册任务/存储缺失/表达式缺失)/registry 优先路径(列表/收集/生命周期)/ +/// ParseGoalArgs 参数解析纯函数/元数据一致性 +/// GoalEngine/GoalRegistry/CronTaskStore 用 Moq 桩经 CommandServices 可选字段注入 +/// +[Collection("command-output")] +public sealed class GoalCommandTest { + /// 剥离 ANSI 转义序列 — 目标状态渲染带颜色码,断言基于纯文本 + private static string StripAnsi(string text) => + System.Text.RegularExpressions.Regex.Replace(text, @"\x1B\[[0-9;]*m", string.Empty); + + private static GoalState Goal( + string id = "goal-1", + string objective = "优化启动性能", + GoalStatus status = GoalStatus.Pursuing) => new() { + GoalId = id, + Objective = objective, + Status = status, + TokensUsed = 500, + TurnsCompleted = 3, + TokenBudget = 1000, + Constraints = ["不许改公共 API"], + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + }; + + /// 构造 GoalEngine 桩 — StartAsync 返回运行中目标,生命周期方法空实现 + private static Mock CreateEngine(GoalState? currentState = null) { + var engine = new Mock(); + engine.Setup(e => e.CurrentState).Returns(currentState); + engine.Setup(e => e.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Goal()); + engine.Setup(e => e.PauseAsync(It.IsAny())).Returns(Task.CompletedTask); + engine.Setup(e => e.ResumeAsync(It.IsAny())).Returns(Task.CompletedTask); + engine.Setup(e => e.ClearAsync(It.IsAny())).Returns(Task.CompletedTask); + engine.Setup(e => e.RehydrateAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + return engine; + } + + private static (GoalCommand Command, ChatCommandContext Context) Arrange( + string arguments = "", + Mock? engine = null, + Mock? registry = null, + Mock? cronStore = null) { + var commandServices = new CommandServices { + ChatService = Mock.Of(), + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + GoalEngine = engine?.Object, + GoalRegistry = registry?.Object, + CronTaskStore = cronStore?.Object, + }; + var context = new ChatCommandContext { + Arguments = arguments, + SessionId = "test-session", + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + return (new GoalCommand(), context); + } + + [Fact] + public async Task Execute_WithoutEngineOrRegistry_ShowsError() { + var (cmd, context) = Arrange(arguments: "--status"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("错误: 目标引擎未注册", "引擎与注册表均缺失应明确报错"); + } + + [Fact] + public async Task Execute_EmptyArgs_NoCurrentGoal_StartsGoalSpecCollection() { + var engine = CreateEngine(currentState: null); + var (cmd, context) = Arrange(arguments: "", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.RehydrateAsync(It.IsAny(), It.IsAny()), Times.Once, + "空参应先尝试从持久化恢复目标"); + engine.Verify(e => e.StartAsync( + It.Is(p => p.Contains("GoalSpec") && p.Contains("6 个字段")), + It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once, + "无当前目标应启动 GoalSpec 收集流程"); + output.Should().Contain("◎ /goal active — GoalSpec 收集模式"); + output.Should().Contain(" LLM 将逐个询问目标规格字段,收集完成后开始自主工作。"); + output.Should().Contain(" 目标: 优化启动性能", "StartAsync 返回的状态应被渲染"); + } + + [Fact] + public async Task Execute_EmptyArgs_WithCurrentGoal_ShowsStatusOnly() { + var engine = CreateEngine(currentState: Goal()); + var (cmd, context) = Arrange(arguments: "", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, + "已有目标时不应重复启动收集"); + clean.Should().Contain("◎ /goal 运行中"); + output.Should().Contain(" 目标: 优化启动性能"); + output.Should().Contain(" ID: goal-1"); + clean.Should().Contain(" 状态: 运行中"); + output.Should().Contain(" 轮数: 3"); + output.Should().Contain(" Token: 500 / 1000", "状态应渲染目标全貌"); + output.Should().Contain(" 约束: 不许改公共 API"); + } + + [Fact] + public async Task Execute_Pause_PausesGoal() { + var engine = CreateEngine(); + var (cmd, context) = Arrange(arguments: "pause", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.PauseAsync(It.IsAny()), Times.Once, "pause 应暂停目标"); + output.Should().Contain("◎ /goal 已暂停"); + } + + [Fact] + public async Task Execute_Resume_RehydratesThenResumes() { + var engine = CreateEngine(currentState: null); + var (cmd, context) = Arrange(arguments: "resume", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.RehydrateAsync(It.IsAny(), It.IsAny()), Times.Once, + "无当前目标时 resume 应先恢复持久化状态"); + engine.Verify(e => e.ResumeAsync(It.IsAny()), Times.Once, "resume 应恢复目标"); + output.Should().Contain("◎ /goal 已恢复"); + } + + [Theory] + [InlineData("clear")] + [InlineData("stop")] + [InlineData("off")] + [InlineData("reset")] + [InlineData("cancel")] + public async Task Execute_ClearFamily_ClearsGoal(string arguments) { + var engine = CreateEngine(); + var (cmd, context) = Arrange(arguments: arguments, engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.ClearAsync(It.IsAny()), Times.Once, + $"{arguments} 应清除目标(语义等价别名族)"); + output.Should().Contain("目标已清除"); + } + + [Fact] + public async Task Execute_WithObjective_ConstraintAndBudget_ParsedIntoStart() { + var engine = CreateEngine(); + var (cmd, context) = Arrange( + arguments: "--constraint '不许改配置' --budget 5000 优化性能", + engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + engine.Verify(e => e.StartAsync( + It.Is(p => p.Contains("优化性能")), + It.IsAny?>(), + It.Is(b => b == 5000), + It.IsAny(), + It.IsAny()), Times.Once, "--budget 应解析传入启动参数"); + output.Should().Contain(" 初始提示: 优化性能"); + output.Should().Contain(" 预填约束: 不许改配置", "引号约束应被剥离引号解析"); + output.Should().Contain(" 预算: 5000 Token"); + } + + [Fact] + public async Task Execute_StartFailure_ConvergesToErrorHint() { + var engine = CreateEngine(); + engine.Setup(e => e.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("goal already running")); + var (cmd, context) = Arrange(arguments: "新目标", engine: engine); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("StartAsync 异常应收敛为错误提示而非向上抛出"); + output.Should().Contain("目标执行失败: goal already running"); + } + + [Fact] + public async Task Execute_CronGoal_RegistersCronTask() { + var engine = CreateEngine(); + var cronStore = new Mock(); + cronStore.Setup(s => s.AddTaskAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new CronTask { + Id = "cron-1", + CronExpression = "*/5 * * * *", + Prompt = "每五分钟检查构建", + CreatedAt = 1000, + IsRecurring = true, + IsDurable = true, + }); + var (cmd, context) = Arrange( + arguments: "--cron */5 * * * * 每五分钟检查构建", + engine: engine, + cronStore: cronStore); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + cronStore.Verify(s => s.AddTaskAsync( + It.Is(r => + r.CronExpression == "*/5 * * * *" + && r.Prompt == "每五分钟检查构建" + && r.IsRecurring + && r.IsDurable), + It.IsAny()), Times.Once, "cron 模式应注册重复且持久的定时任务"); + output.Should().Contain("定时目标已注册"); + output.Should().Contain(" Cron: */5 * * * *"); + output.Should().Contain(" 目标: 每五分钟检查构建"); + output.Should().Contain(" 任务ID: cron-1"); + } + + [Fact] + public async Task Execute_CronGoal_WithoutStore_ShowsError() { + var engine = CreateEngine(); + var (cmd, context) = Arrange( + arguments: "--cron */5 * * * * 描述", + engine: engine, + cronStore: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("错误: Cron 任务存储未注册", "存储缺失应明确报错"); + } + + [Fact] + public async Task Execute_CronGoal_IncompleteExpression_ShowsError() { + var engine = CreateEngine(); + var cronStore = new Mock(); + var (cmd, context) = Arrange( + arguments: "--cron */5 * *", + engine: engine, + cronStore: cronStore); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("错误: 定时模式需要指定 Cron 表达式", + "不足 5 字段的 cron 表达式应提示错误"); + cronStore.Verify(s => s.AddTaskAsync(It.IsAny(), It.IsAny()), Times.Never, + "表达式不完整时不应注册任务"); + } + + [Fact] + public async Task Execute_RegistryPreferred_EmptyGoals_StartsCollectionViaRegistry() { + var registry = new Mock(); + registry.Setup(r => r.ListActiveGoalsAsync(It.IsAny())) + .ReturnsAsync(new List()); + registry.Setup(r => r.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Goal()); + var engine = CreateEngine(); + var (cmd, context) = Arrange(arguments: "", engine: engine, registry: registry); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + registry.Verify(r => r.RehydrateAllAsync(It.IsAny()), Times.Once, + "registry 模式应先恢复全部持久化目标"); + registry.Verify(r => r.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny()), Times.Once, + "无活跃目标应经 registry 启动 GoalSpec 收集"); + engine.Verify(e => e.StartAsync(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, + "registry 存在时应优先走 registry 路径"); + output.Should().Contain("◎ /goal active — GoalSpec 收集模式"); + } + + [Fact] + public async Task Execute_RegistryPreferred_ActiveGoals_ShowsGoalsList() { + var registry = new Mock(); + registry.Setup(r => r.ListActiveGoalsAsync(It.IsAny())) + .ReturnsAsync(new List { + Goal(id: "goal-1", objective: "重构模块A"), + Goal(id: "goal-2", objective: "修复内存泄漏", status: GoalStatus.Paused), + }); + var (cmd, context) = Arrange(arguments: "", registry: registry); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var clean = StripAnsi(output); + + result.ShouldContinue.Should().BeTrue(); + clean.Should().Contain("◎ 活跃目标列表 (2)"); + clean.Should().Contain(" [goal-1] 重构模块A — 运行中"); + clean.Should().Contain(" [goal-2] 修复内存泄漏 — 已暂停", "列表应渲染每个目标的运行状态"); + } + + [Fact] + public async Task Execute_RegistryPause_PausesViaRegistry() { + var registry = new Mock(); + registry.Setup(r => r.PauseAsync(It.IsAny())).Returns(Task.CompletedTask); + var (cmd, context) = Arrange(arguments: "pause", registry: registry); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + registry.Verify(r => r.PauseAsync(It.IsAny()), Times.Once, + "registry 模式 pause 应委托注册表"); + output.Should().Contain("◎ /goal 已暂停"); + } + + /// 生产 bug 登记(TASK065 brain 域): usage 提示 "/goal [初始提示] [--constraint '约束'] [--budget N]" + /// 的文档形态(目标在前、选项在后)不被 ParseGoalArgs 支持 — 选项剥离仅匹配 remaining 开头, + /// 目标在前时整个字符串(含 --constraint/--budget 原文)作为 objective 传入 GoalSpecPromptBuilder。 + /// 本测试按当前实现以"选项在前"形态验证解析正路径;修复为任意位置剥离后应补充目标在前的用例。 + [Theory] + [InlineData("--cron */5 * * * * 每五分钟检查", true, "*/5 * * * *", "每五分钟检查")] + [InlineData("-c 0 9 * * 1 每周一早九点", true, "0 9 * * 1", "每周一早九点")] + [InlineData("--cron */5 * *", true, null, "")] + [InlineData("--constraint '不许改配置' --constraint 必须有测试 优化性能", false, null, "优化性能")] + [InlineData("--budget 5000 优化性能", false, null, "优化性能")] + [InlineData("--budget abc 优化性能", false, null, "优化性能")] + [InlineData("纯目标文本", false, null, "纯目标文本")] + public void ParseGoalArgs_ParsesCronConstraintsAndBudget( + string args, bool expectCron, string? expectCronExpr, string expectObjective) { + var parsed = GoalCommand.ParseGoalArgs(args); + + parsed.IsCron.Should().Be(expectCron, $"'{args}' 的 cron 判定应为 {expectCron}"); + if (expectCron) { + parsed.CronExpression.Should().Be(expectCronExpr); + } else { + parsed.Objective.Should().Be(expectObjective); + } + } + + [Fact] + public void ParseGoalArgs_ExtractsQuotedConstraintsAndBudget() { + var parsed = GoalCommand.ParseGoalArgs("--constraint '引号约束' --constraint 单词约束 --budget 100 目标描述"); + + parsed.Objective.Should().Be("目标描述"); + parsed.Constraints.Should().Equal("引号约束", "单词约束"); + parsed.TokenBudget.Should().Be(100); + parsed.IsCron.Should().BeFalse(); + } + + [Fact] + public void ParseGoalArgs_InvalidBudget_Ignored() { + var parsed = GoalCommand.ParseGoalArgs("--budget abc 目标"); + + parsed.TokenBudget.Should().BeNull("非数字预算应被忽略而非崩溃"); + parsed.Objective.Should().Be("目标"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new GoalCommand(); + cmd.Name.Should().Be("goal"); + cmd.Description.Should().Contain("目标自主循环"); + cmd.Usage.Should().StartWith("/goal [初始提示]"); + cmd.Usage.Should().Contain("pause").And.Contain("resume").And.Contain("clear").And.Contain("--cron"); + } +} diff --git a/modules/composition/composition.tests/commands/HeapdumpCommandTest.cs b/modules/composition/composition.tests/commands/HeapdumpCommandTest.cs new file mode 100644 index 0000000000..70c6b45663 --- /dev/null +++ b/modules/composition/composition.tests/commands/HeapdumpCommandTest.cs @@ -0,0 +1,53 @@ +namespace Composition.Tests.Commands; + +/// +/// /heapdump 命令单元测试 — 覆盖运行时诊断信息渲染(GC 内存/集合代数/线程池/进程内存/句柄) +/// 命令零服务依赖,纯 Console 输出;GC/线程池为运行时真实值只断言结构不变量 +/// +[Collection("command-output")] +public sealed class HeapdumpCommandTest { + [Fact] + public async Task Execute_RendersRuntimeDiagnosticsSections() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new HeapdumpCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("堆转储 / 运行时诊断:"); + output.Should().Contain("当前托管内存:"); + output.Should().Contain("压缩后内存:"); + output.Should().Contain("GC 最大代数:"); + output.Should().Contain("GC 集合次数:"); + output.Should().Contain("0 代:"); + output.Should().Contain("1 代:"); + output.Should().Contain("2 代:"); + output.Should().Contain("线程池:"); + output.Should().Contain("工作线程:"); + output.Should().Contain("I/O 线程:"); + output.Should().Contain("进程内存:"); + output.Should().Contain("句柄数:"); + } + + [Fact] + public async Task Execute_IgnoresArguments_AlwaysRendersDiagnostics() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: "anything"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new HeapdumpCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("多余参数不影响诊断输出"); + output.Should().Contain("堆转储 / 运行时诊断:"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new HeapdumpCommand(); + cmd.Name.Should().Be("heapdump"); + cmd.Usage.Should().Be("/heapdump"); + cmd.IsHidden.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/HistoryCommandTest.cs b/modules/composition/composition.tests/commands/HistoryCommandTest.cs new file mode 100644 index 0000000000..16957b69b4 --- /dev/null +++ b/modules/composition/composition.tests/commands/HistoryCommandTest.cs @@ -0,0 +1,86 @@ +namespace Composition.Tests.Commands; + +/// +/// /history 命令单元测试 — 覆盖空历史(null/空列表)提示/多角色消息渲染/元数据一致性 +/// ChatService 为 CommandServices 必填 init 字段,用 Moq 桩自建服务包注入(对齐 MemoryCommandTest 形态) +/// +[Collection("command-output")] +public sealed class HistoryCommandTest { + private static (HistoryCommand Command, ChatCommandContext Context) Arrange( + Mock chatService, + string arguments = "") { + var commandServices = new CommandServices { + ChatService = chatService.Object, + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + var context = new ChatCommandContext { + Arguments = arguments, + SessionId = "test-session", + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + return (new HistoryCommand(), context); + } + + [Fact] + public async Task Execute_EmptyHistory_ShowsPlaceholder() { + var chat = new Mock(); + chat.Setup(c => c.GetMessageListAsync(It.IsAny())) + .ReturnsAsync(new List()); + var (cmd, context) = Arrange(chat); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("=== 聊天历史 ==="); + output.Should().Contain("(暂无聊天历史)", "空列表应显示占位提示而非空输出"); + } + + [Fact] + public async Task Execute_NullHistory_ShowsPlaceholder() { + var chat = new Mock(); + chat.Setup(c => c.GetMessageListAsync(It.IsAny())) +#pragma warning disable JCC11003 // 边界用例:生产代码显式处理 null 历史(is null 分支),mock 需注入 null 验证该防御路径 + .Returns(Task.FromResult>(null!)); +#pragma warning restore JCC11003 + var (cmd, context) = Arrange(chat); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("null 历史应降级为占位提示而非崩溃"); + output.Should().Contain("=== 聊天历史 ==="); + output.Should().Contain("(暂无聊天历史)"); + } + + [Fact] + public async Task Execute_WithMessages_RendersRoleAndContent() { + var chat = new Mock(); + chat.Setup(c => c.GetMessageListAsync(It.IsAny())) + .ReturnsAsync(new List { + new() { Role = "user", Content = "你好,帮我写个冒泡排序" }, + new() { Role = "assistant", Content = "好的,这是冒泡排序实现" }, + }); + var (cmd, context) = Arrange(chat); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + chat.Verify(c => c.GetMessageListAsync(It.IsAny()), Times.Once, + "应查询一次消息列表"); + output.Should().Contain("[user]: 你好,帮我写个冒泡排序"); + output.Should().Contain("[assistant]: 好的,这是冒泡排序实现", "消息应按角色: 内容格式渲染"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new HistoryCommand(); + cmd.Name.Should().Be("history"); + cmd.Usage.Should().Be("/history"); + cmd.Description.Should().Contain("聊天历史"); + cmd.Aliases.Should().Contain("hist", "hist 为 /history 的别名"); + cmd.ExposeToMcp.Should().BeTrue("history 暴露给 MCP 供 AI 调用"); + } +} diff --git a/modules/composition/composition.tests/commands/InsightsCommandTest.cs b/modules/composition/composition.tests/commands/InsightsCommandTest.cs new file mode 100644 index 0000000000..774e81c20e --- /dev/null +++ b/modules/composition/composition.tests/commands/InsightsCommandTest.cs @@ -0,0 +1,245 @@ +namespace Composition.Tests.Commands; + +/// +/// /insights 命令单元测试 — 覆盖 stats 模式(扫描器缺失回退当前会话/空会话/有会话聚合渲染/扫描异常回退)/ +/// deep 与 report 模式(扫描器缺失降级)/AI 基础洞察(发送 prompt 到 ChatService)/ +/// s/d/r 短别名等价/元数据一致性 +/// InsightSessionScanner 与 FacetCacheService 用 Moq 桩经 DI 注入; +/// ChatService 为 CommandServices 必填字段,自建服务包注入 mock(对齐 MemoryCommandTest 形态) +/// +[Collection("command-output")] +public sealed class InsightsCommandTest { + private static (InsightsCommand Command, ChatCommandContext Context, Mock Chat) Arrange( + string arguments = "", + Mock? scanner = null, + Mock? facetCache = null, + Mock? chat = null) { + var chatService = chat ?? new Mock(); + chatService.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(string.Empty); + var di = new ServiceCollection(); + if (scanner is not null) di.AddSingleton(scanner.Object); + if (facetCache is not null) di.AddSingleton(facetCache.Object); + var commandServices = new CommandServices { + ChatService = chatService.Object, + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = di.BuildServiceProvider(), + }; + var context = new ChatCommandContext { + Arguments = arguments, + SessionId = "test-session", + CancellationToken = CancellationToken.None, + Services = new CommandServiceProvider(commandServices), + }; + return (new InsightsCommand(), context, chatService); + } + + private static Mock ScannerWithSessions(params InsightSessionMeta[] sessions) { + var scanner = new Mock(); + scanner.Setup(s => s.ScanAllSessionsAsync(It.IsAny())) + .ReturnsAsync(sessions.ToList()); + return scanner; + } + + private static InsightSessionMeta Session(string id = "session-1") => new() { + SessionId = id, + ProjectPath = "D:\\project\\w2", + StartTime = DateTime.UtcNow.AddDays(-1), + DurationMinutes = 42, + UserMessageCount = 10, + AssistantMessageCount = 12, + InputTokens = 1000, + OutputTokens = 2000, + FirstPrompt = "写一个单元测试", + }; + + [Fact] + public async Task Execute_Stats_WithoutScanner_FallsBackToCurrentSession() { + var (cmd, context, _) = Arrange(arguments: "stats", scanner: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("当前会话统计:"); + output.Should().Contain(" 会话时长:"); + output.Should().Contain(" 会话ID: test-session"); + output.Should().Contain(" (成本追踪器不可用)", "CostTracker 缺失应降级提示"); + output.Should().Contain("使用 /insights (不带stats) 获取AI生成的洞察分析", + "回退模式应引导用户使用 AI 洞察"); + } + + [Fact] + public async Task Execute_Stats_EmptySessions_ShowsNoRecordsHint() { + var scanner = ScannerWithSessions(); + var (cmd, context, _) = Arrange(arguments: "stats", scanner: scanner); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("正在扫描会话文件..."); + output.Should().Contain("未找到会话记录。"); + output.Should().Contain("使用 /insights (不带stats) 获取AI生成的洞察分析"); + } + + [Fact] + public async Task Execute_Stats_WithSessions_RendersAggregatedReport() { + var scanner = ScannerWithSessions(Session(), Session("session-2")); + var (cmd, context, _) = Arrange(arguments: "stats", scanner: scanner); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("正在扫描会话文件..."); + output.Should().Contain("会话统计"); + output.Should().Contain(" 总会话数: 2", "应渲染聚合后的会话总数"); + output.Should().Contain(" 总消息数: 44", "消息数 = (10+12)*2"); + output.Should().Contain(" 输入 Token: 2,000"); + output.Should().Contain(" 输出 Token: 4,000"); + output.Should().Contain(" 活跃天数: 1"); + } + + [Fact] + public async Task Execute_Stats_ScanFailure_FallsBackToCurrentSession() { + var scanner = new Mock(); + scanner.Setup(s => s.ScanAllSessionsAsync(It.IsAny())) + .ThrowsAsync(new IOException("disk locked")); + var (cmd, context, _) = Arrange(arguments: "stats", scanner: scanner); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("扫描异常应收敛回退而非向上抛出"); + output.Should().Contain("扫描会话失败: disk locked"); + output.Should().Contain("当前会话统计:", "扫描失败应回退当前会话统计"); + } + + [Theory] + [InlineData("deep")] + [InlineData("d")] + public async Task Execute_Deep_WithoutScanner_ShowsUnavailable(string arguments) { + var (cmd, context, _) = Arrange(arguments: arguments, scanner: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("会话扫描服务不可用,无法生成深度洞察。"); + output.Should().Contain("使用 /insights (不带deep) 获取基础AI洞察分析", + "降级提示应引导用户回退基础洞察"); + } + + [Fact] + public async Task Execute_Deep_EmptySessions_ShowsNoRecords() { + var scanner = ScannerWithSessions(); + var (cmd, context, chat) = Arrange(arguments: "deep", scanner: scanner); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("正在扫描会话文件..."); + output.Should().Contain("未找到会话记录。"); + chat.Verify(c => c.SendMessageAsync(It.IsAny(), It.IsAny()), Times.Never, + "无会话时不应调用 LLM"); + } + + [Fact] + public async Task Execute_Deep_WithSessions_SendsDeepPromptToChat() { + var scanner = ScannerWithSessions(Session()); + var (cmd, context, chat) = Arrange(arguments: "deep", scanner: scanner); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("找到 1 个会话"); + output.Should().Contain("正在提取会话 Facet..."); + output.Should().Contain("正在生成深度洞察..."); + chat.Verify(c => c.SendMessageAsync(It.IsAny(), It.IsAny()), Times.AtLeastOnce, + "深度洞察应把数据上下文发给 ChatService 生成"); + } + + [Theory] + [InlineData("report")] + [InlineData("r")] + public async Task Execute_Report_WithoutScanner_ShowsUnavailable(string arguments) { + var (cmd, context, _) = Arrange(arguments: arguments, scanner: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("会话扫描服务不可用,无法生成报告。"); + } + + [Fact] + public async Task Execute_Report_WithSessions_GeneratesHtmlAndStats() { + var scanner = ScannerWithSessions(Session()); + var (cmd, context, _) = Arrange(arguments: "report", scanner: scanner); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("找到 1 个会话"); + output.Should().Contain("正在生成洞察文本..."); + output.Should().Contain("正在生成 HTML 报告..."); + output.Should().Contain("HTML 报告已生成:"); + output.Should().Contain("report.html", "报告应落盘到 usage-data/report.html"); + output.Should().Contain("会话统计", "报告完成后应同时输出终端统计"); + } + + [Fact] + public async Task Execute_DefaultMode_SendsCurrentSessionPromptToChat() { + var (cmd, context, chat) = Arrange(arguments: "", scanner: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("正在生成会话洞察..."); + chat.Verify(c => c.SendMessageAsync(It.IsAny(), It.IsAny()), Times.Once, + "基础 AI 洞察应发送 prompt 给 ChatService"); + } + + [Fact] + public async Task Execute_DefaultMode_WithSessions_SendsCrossSessionPrompt() { + var scanner = ScannerWithSessions(Session()); + var (cmd, context, chat) = Arrange(arguments: "", scanner: scanner); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("正在分析会话数据..."); + output.Should().Contain("正在生成会话洞察..."); + chat.Verify(c => c.SendMessageAsync( + It.Is(p => p.Contains("Sessions: 1") && p.Contains("Total messages: 22")), + It.IsAny()), Times.Once, + "有会话时应以跨会话聚合数据作为 prompt 上下文"); + } + + [Fact] + public async Task Execute_SAlias_EquivalentToStats() { + var (cmd, context, _) = Arrange(arguments: "s", scanner: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("当前会话统计:", "s 为 stats 的短别名"); + } + + [Fact] + public async Task Execute_UnrecognizedMode_FallsBackToAiInsights() { + var (cmd, context, chat) = Arrange(arguments: "bogus", scanner: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("正在生成会话洞察...", "未识别参数应回退基础 AI 洞察模式"); + chat.Verify(c => c.SendMessageAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new InsightsCommand(); + cmd.Name.Should().Be("insights"); + cmd.Usage.Should().Be("/insights [stats|deep|report]"); + cmd.Description.Should().Contain("洞察"); + cmd.ArgumentHint.Should().Be("[stats|deep|report]"); + } +} diff --git a/modules/composition/composition.tests/commands/MemoryCommandTest.cs b/modules/composition/composition.tests/commands/MemoryCommandTest.cs index 230726c619..ed016f8a33 100644 --- a/modules/composition/composition.tests/commands/MemoryCommandTest.cs +++ b/modules/composition/composition.tests/commands/MemoryCommandTest.cs @@ -137,8 +137,8 @@ public async Task Execute_Add_SimilarMemoryAboveThreshold_ShowsWarningWithoutAdd /// 生产 bug 登记(TASK065 ai 域): --type 选项小写输入(如 feedback)时 /// AddMemoryAsync 的 content.Replace($"--type {type}", "") 用解析后的枚举名(Feedback) /// 做大小写敏感匹配,无法剥离小写原文,选项文本残留进记忆内容与查重 query。 - /// 本测试按 usage 提示的规范大写形态(Feedback)验证剥离正路径;修复 Replace 为 - /// 忽略大小写后应补充小写形态用例。 + /// 本测试按 usage 提示的规范大写形态(Feedback)验证剥离正路径;小写形态用例见 + /// 。 [Fact] public async Task Execute_Add_WithTagsAndType_ParsesOptions() { var memoryService = CreateMemoryService(); @@ -153,6 +153,26 @@ public async Task Execute_Add_WithTagsAndType_ParsesOptions() { output.Should().Contain("标签:rust, perf"); } + /// 生产 bug 修复验证(TASK065 ai 域): FromValue 反查表按 OrdinalIgnoreCase 解析, + /// 小写 --type feedback 解析为 Feedback 成功,但剥离 Replace 用枚举名(Feedback)做 + /// 大小写敏感匹配导致选项文本残留进记忆内容与查重 query。修复后剥离改为 + /// OrdinalIgnoreCase 定位,小写形态应与大写形态行为一致:干净剥离 + 查重 query 无残留。 + [Fact] + public async Task Execute_Add_WithLowercaseType_StripsOptionCaseInsensitively() { + var memoryService = CreateMemoryService(); + var (cmd, context) = Arrange("add 关键结论 --type feedback --tags rust,perf", memoryService: memoryService); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + memoryService.Verify(s => s.ScanMemoriesAsync("关键结论", "Feedback", 1, It.IsAny()), Times.Once, + "查重 query 应使用剥离后的干净内容,不得残留 --type 选项文本"); + memoryService.Verify(s => s.AddMemoryAsync("关键结论", MemoryType.Feedback, null, new List { "rust", "perf" }, null, It.IsAny()), Times.Once, + "小写 --type feedback 应解析为 Feedback 且干净剥离,选项文本不得残留进记忆内容"); + output.Should().Contain("类型:Feedback"); + output.Should().Contain("内容:关键结论"); + } + [Theory] [InlineData("search keyword")] [InlineData("db")] diff --git a/modules/composition/composition.tests/commands/OauthRefreshCommandTest.cs b/modules/composition/composition.tests/commands/OauthRefreshCommandTest.cs new file mode 100644 index 0000000000..f6bd75dc2a --- /dev/null +++ b/modules/composition/composition.tests/commands/OauthRefreshCommandTest.cs @@ -0,0 +1,205 @@ +namespace Composition.Tests.Commands; + +/// +/// /oauth-refresh 命令单元测试 — 覆盖三级前置降级(Token存储/客户端/配置缺失)/ +/// 空参无存储 Provider 引导登录/多 Provider 默认刷新第一个/指定 Provider 无 Token/ +/// 无 Refresh Token/刷新成功保存新 Token/刷新异常降级提示 +/// ITokenStorage 经 CommandServices 字段注入,IOAuthClient 与 IOptions<OAuthOptions> 经 DI 注入 +/// +[Collection("command-output")] +public sealed class OauthRefreshCommandTest { + private static OAuthToken CreateToken(string? refreshToken = "refresh-secret") => new() { + AccessToken = "access-old", + RefreshToken = refreshToken, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + }; + + private static (OauthRefreshCommand Command, Mock Storage, Mock Client, ChatCommandContext Context) Arrange( + string arguments = "", + Mock? storageOverride = null, + bool withClient = true, + bool withOptions = true, + IReadOnlyList? storedProviders = null, + OAuthToken? storedToken = null, + OAuthToken? refreshedToken = null, + Exception? refreshError = null) { + var storage = storageOverride ?? new Mock(); + storage.Setup(s => s.GetStoredProvidersAsync(It.IsAny())) + .ReturnsAsync(storedProviders ?? []); + if (storedToken is not null) { + storage.Setup(s => s.LoadTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(storedToken); + } + + var client = new Mock(); + if (refreshError is not null) { + client.Setup(c => c.RefreshTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(refreshError); + } else { + client.Setup(c => c.RefreshTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(refreshedToken ?? new OAuthToken { + AccessToken = "access-new", + RefreshToken = "refresh-new", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(2), + }); + } + + var services = CommandTestHarness.CreateServices( + tokenStorage: storage.Object, + configureDi: di => { + if (withClient) di.AddSingleton(client.Object); + if (withOptions) di.AddSingleton(Microsoft.Extensions.Options.Options.Create(new OAuthOptions())); + }); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new OauthRefreshCommand(), storage, client, context); + } + + [Fact] + public async Task Execute_WithoutTokenStorage_ShowsStorageUnavailable() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: "prov-x"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new OauthRefreshCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("OAuth Token 存储不可用"); + } + + [Fact] + public async Task Execute_WithoutOAuthClient_ShowsClientUnavailable() { + var (cmd, storage, _, context) = Arrange(arguments: "prov-x", withClient: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("OAuth 客户端不可用"); + storage.Verify(s => s.LoadTokenAsync(It.IsAny(), It.IsAny()), Times.Never, + "客户端缺失应在加载 Token 前终止"); + } + + [Fact] + public async Task Execute_WithoutOptions_ShowsConfigUnavailable() { + var (cmd, storage, _, context) = Arrange(arguments: "prov-x", withOptions: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("OAuth 配置不可用"); + storage.Verify(s => s.LoadTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_EmptyArgs_NoStoredProviders_ShowsLoginHint() { + var (cmd, storage, client, context) = Arrange(storedProviders: []); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("无已存储的 OAuth Token"); + output.Should().Contain("/login --oauth"); + client.Verify(c => c.RefreshTokenAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "无 Provider 时不应发起刷新"); + } + + [Fact] + public async Task Execute_EmptyArgs_MultipleProviders_PicksFirstAndRefreshes() { + var (cmd, storage, client, context) = Arrange( + storedProviders: ["prov-a", "prov-b"], + storedToken: CreateToken()); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("发现 2 个 Provider"); + output.Should().Contain("默认刷新: prov-a"); + storage.Verify(s => s.LoadTokenAsync("prov-a", It.IsAny()), Times.Once, + "多 Provider 时应默认刷新第一个"); + client.Verify(c => c.RefreshTokenAsync( + It.Is(cfg => cfg.Provider == "prov-a"), + "refresh-secret", + It.IsAny()), Times.Once); + storage.Verify(s => s.SaveTokenAsync("prov-a", It.IsAny(), It.IsAny()), Times.Once, + "刷新成功后应保存新 Token"); + output.Should().Contain("Token 刷新成功"); + } + + [Fact] + public async Task Execute_SingleProvider_LoadsItDirectly() { + var (cmd, _, _, context) = Arrange(storedProviders: ["only-prov"], storedToken: CreateToken()); + + var (_, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + output.Should().NotContain("发现", "单个 Provider 不应输出多 Provider 提示"); + output.Should().Contain("Token 刷新成功"); + } + + [Fact] + public async Task Execute_ProviderWithoutStoredToken_ShowsHint() { + var (cmd, _, client, context) = Arrange(arguments: "prov-x", storedToken: null); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("Provider 'prov-x' 无已存储的 Token"); + client.Verify(c => c.RefreshTokenAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Execute_TokenWithoutRefreshToken_ShowsReloginHint() { + var (cmd, _, client, context) = Arrange(arguments: "prov-x", storedToken: CreateToken(refreshToken: null)); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("Provider 'prov-x' 无 Refresh Token"); + output.Should().Contain("/login --oauth"); + client.Verify(c => c.RefreshTokenAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "无 Refresh Token 不应发起刷新请求"); + } + + [Fact] + public async Task Execute_RefreshSuccess_ShowsProviderAndExpiresAt() { + var newToken = new OAuthToken { + AccessToken = "access-new", + RefreshToken = "refresh-new", + ExpiresAt = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero), + }; + var (cmd, storage, _, context) = Arrange(arguments: "prov-x", storedToken: CreateToken(), refreshedToken: newToken); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("Token 刷新成功"); + output.Should().Contain("Provider: prov-x"); + output.Should().Contain("2026-01-01 12:00:00 UTC", "过期时间应按 yyyy-MM-dd HH:mm:ss UTC 格式渲染"); + storage.Verify(s => s.SaveTokenAsync("prov-x", newToken, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Execute_RefreshThrows_ShowsErrorAndReloginHint() { + var (cmd, storage, _, context) = Arrange( + arguments: "prov-x", + storedToken: CreateToken(), + refreshError: new InvalidOperationException("network down")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("Token刷新失败"); + output.Should().Contain("network down"); + output.Should().Contain("/login --oauth 重新登录", "异常降级应引导用户重新登录"); + storage.Verify(s => s.SaveTokenAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "刷新失败不应覆盖已有 Token"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new OauthRefreshCommand(); + cmd.Name.Should().Be("oauth-refresh"); + cmd.Usage.Should().Be("/oauth-refresh [provider]"); + cmd.IsHidden.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/PeersCommandTest.cs b/modules/composition/composition.tests/commands/PeersCommandTest.cs new file mode 100644 index 0000000000..668ae9f7e7 --- /dev/null +++ b/modules/composition/composition.tests/commands/PeersCommandTest.cs @@ -0,0 +1,76 @@ +namespace Composition.Tests.Commands; + +/// +/// /peers 命令单元测试 — 覆盖服务缺失静默/空节点列表提示/已连接节点渲染(名称+ID+连接时间) +/// IPeerDiscoveryService 经 DI 注入 +/// +[Collection("command-output")] +public sealed class PeersCommandTest { + private static PeerInfo CreatePeer(string id = "peer-1", string name = "node-a") => new() { + Id = id, + Name = name, + ConnectedAt = new DateTime(2026, 1, 1, 9, 30, 0), + }; + + private static (PeersCommand Command, Mock PeerService, ChatCommandContext Context) Arrange( + PeerInfo[]? peers = null, + bool withService = true) { + var peerService = new Mock(); + peerService.Setup(p => p.GetConnectedPeers()).Returns(peers ?? []); + + var services = CommandTestHarness.CreateServices( + configureDi: di => { + if (withService) di.AddSingleton(peerService.Object); + }); + var context = CommandTestHarness.CreateContext(services); + return (new PeersCommand(), peerService, context); + } + + [Fact] + public async Task Execute_WithoutService_NonInteractive_ReturnsContinueSilently() { + var (cmd, peerService, context) = Arrange(withService: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().NotContain("对等节点列表", "非交互环境服务缺失不应输出节点列表"); + peerService.Verify(p => p.GetConnectedPeers(), Times.Never); + } + + [Fact] + public async Task Execute_NoConnectedPeers_ShowsEmptyHint() { + var (cmd, peerService, context) = Arrange(); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + peerService.Verify(p => p.GetConnectedPeers(), Times.Once); + output.Should().Contain("对等节点列表:"); + output.Should().Contain("(暂无已连接的对等节点)"); + } + + [Fact] + public async Task Execute_WithConnectedPeers_RendersPeerNameIdAndTime() { + var (cmd, _, context) = Arrange(peers: [ + CreatePeer(id: "peer-1", name: "node-a"), + CreatePeer(id: "peer-2", name: "node-b")]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("node-a (peer-1)"); + output.Should().Contain("node-b (peer-2)"); + output.Should().Contain("连接时间: 09:30:00"); + output.Should().NotContain("暂无已连接的对等节点"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new PeersCommand(); + cmd.Name.Should().Be("peers"); + cmd.Usage.Should().Be("/peers"); + cmd.Aliases.Should().Contain("remote"); + cmd.IsHidden.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/ProactiveCommandTest.cs b/modules/composition/composition.tests/commands/ProactiveCommandTest.cs new file mode 100644 index 0000000000..b1c3e6713d --- /dev/null +++ b/modules/composition/composition.tests/commands/ProactiveCommandTest.cs @@ -0,0 +1,151 @@ +namespace Composition.Tests.Commands; + +/// +/// /proactive 命令单元测试 — 覆盖 on(含别名)激活/off(含别名)停用/pause/resume(含短别名)/ +/// status 状态查询/未识别参数引导/服务缺失静默/元数据一致性 +/// ProactiveStateService 用 Moq 桩经 DI 注入 +/// +[Collection("command-output")] +public sealed class ProactiveCommandTest { + private static Mock Service( + bool active = false, + bool paused = false, + bool contextBlocked = false) { + var mock = new Mock(); + mock.Setup(s => s.IsActive).Returns(active); + mock.Setup(s => s.IsPaused).Returns(paused); + mock.Setup(s => s.IsContextBlocked).Returns(contextBlocked); + return mock; + } + + private static (ProactiveCommand Command, ChatCommandContext Context, Mock Service) Arrange( + string arguments = "", + Mock? service = null) { + var proactiveService = service ?? Service(); + var services = CommandTestHarness.CreateServices( + configureDi: di => di.AddSingleton(proactiveService.Object)); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new ProactiveCommand(), context, proactiveService); + } + + [Theory] + [InlineData("on")] + [InlineData("activate")] + [InlineData("1")] + public async Task Execute_On_ActivatesProactiveMode(string arguments) { + var service = Service(); + var (cmd, context, _) = Arrange(arguments: arguments, service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + service.Verify(s => s.Activate("user-command"), Times.Once, + $"{arguments} 应以 user-command 来源激活主动模式"); + output.Should().Contain("主动模式已激活"); + output.Should().Contain("主动执行模式:", "执行尾部应回显状态"); + } + + [Theory] + [InlineData("off")] + [InlineData("deactivate")] + [InlineData("0")] + public async Task Execute_Off_DeactivatesProactiveMode(string arguments) { + var service = Service(active: true); + var (cmd, context, _) = Arrange(arguments: arguments, service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + service.Verify(s => s.Deactivate(), Times.Once, $"{arguments} 应停用主动模式"); + output.Should().Contain("主动模式已停用"); + } + + [Theory] + [InlineData("pause")] + [InlineData("p")] + public async Task Execute_Pause_PausesProactiveMode(string arguments) { + var service = Service(active: true); + var (cmd, context, _) = Arrange(arguments: arguments, service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + service.Verify(s => s.Pause(), Times.Once, $"{arguments} 应暂停主动模式"); + service.Verify(s => s.Activate(It.IsAny()), Times.Never, "pause 不应触发激活"); + output.Should().Contain("主动模式已暂停"); + } + + [Theory] + [InlineData("resume")] + [InlineData("r")] + public async Task Execute_Resume_ResumesProactiveMode(string arguments) { + var service = Service(active: true, paused: true); + var (cmd, context, _) = Arrange(arguments: arguments, service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + service.Verify(s => s.Resume(), Times.Once, $"{arguments} 应恢复主动模式"); + output.Should().Contain("主动模式已恢复"); + } + + [Theory] + [InlineData("status")] + [InlineData("s")] + [InlineData("")] + public async Task Execute_Status_RendersFullState(string arguments) { + var service = Service(active: true, paused: true, contextBlocked: false); + var (cmd, context, _) = Arrange(arguments: arguments, service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("主动执行模式:"); + output.Should().Contain(" 激活: 是"); + output.Should().Contain(" 暂停: 是"); + output.Should().Contain(" 上下文阻塞: 否", "状态应覆盖激活/暂停/上下文阻塞三维度"); + service.Verify(s => s.Activate(It.IsAny()), Times.Never); + service.Verify(s => s.Deactivate(), Times.Never); + service.Verify(s => s.Pause(), Times.Never, "status 仅查询不修改状态"); + } + + [Fact] + public async Task Execute_UnrecognizedArg_ShowsErrorAndUsage() { + var service = Service(); + var (cmd, context, _) = Arrange(arguments: "bogus", service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("未知参数: bogus"); + output.Should().Contain("用法: /proactive [on|off|pause|resume|status]", "未识别参数应引导正确用法"); + service.Verify(s => s.Activate(It.IsAny()), Times.Never, "未识别参数不应触发任何状态变更"); + } + + /// 生产 bug 登记(TASK065 brain 域): IProactiveStateService 缺失时 OnEnabledAsync 的 + /// "主动模式已激活" 提示在 proactiveService?.Activate() 安全调用之后仍无条件打印 — + /// 服务未注册却向用户宣称激活成功,输出与实际状态不符。本测试按当前实现固化行为 + /// (提示输出 + Activate 未调用);修复后应改为断言提示不输出或改为服务不可用提示。 + [Fact] + public async Task Execute_WithoutService_ContinuesWithMisleadingActivationNotice() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: "on"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new ProactiveCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("服务缺失时应静默降级(proactiveService?. 安全访问)而非崩溃"); + output.Should().Contain("主动模式已激活", + "当前实现: 服务缺失时激活提示仍输出(bug 已登记,未擅自修复)"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new ProactiveCommand(); + cmd.Name.Should().Be("proactive"); + cmd.Usage.Should().Be("/proactive [on|off|pause|resume|status]"); + cmd.Description.Should().Contain("主动执行模式"); + cmd.IsHidden.Should().BeTrue("proactive 为隐藏命令"); + cmd.ArgumentHint.Should().Be("[on|off|pause|resume|status]"); + } +} diff --git a/modules/composition/composition.tests/commands/RateLimitCommandTest.cs b/modules/composition/composition.tests/commands/RateLimitCommandTest.cs new file mode 100644 index 0000000000..a6fafe06b6 --- /dev/null +++ b/modules/composition/composition.tests/commands/RateLimitCommandTest.cs @@ -0,0 +1,124 @@ +namespace Composition.Tests.Commands; + +/// +/// /rate-limit-options 命令单元测试 — 覆盖空参/show 快照渲染(N0/日期格式/null 降级)/ +/// 未知操作引导/追踪器缺失降级/大小写不敏感/元数据一致性 +/// RateLimitTracker 用 Moq 桩经 CommandServices.RateLimitTracker 可选字段注入 +/// +[Collection("command-output")] +public sealed class RateLimitCommandTest { + private static (RateLimitCommand Command, ChatCommandContext Context, Mock Tracker) Arrange( + string arguments = "", + Mock? tracker = null) { + var rateLimitTracker = tracker ?? new Mock(); + var services = CommandTestHarness.CreateServices(rateLimitTracker: rateLimitTracker.Object); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new RateLimitCommand(), context, rateLimitTracker); + } + + private static RateLimitSnapshot CreateSnapshot() => new() { + RequestLimit = 1000, + RequestRemaining = 999, + RequestResetsAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + TokenLimit = 2000000, + TokenRemaining = 1234567, + TokenResetsAt = new DateTime(2026, 1, 1, 12, 30, 0, DateTimeKind.Utc), + CapturedAt = new DateTime(2026, 1, 1, 8, 15, 30, DateTimeKind.Utc), + }; + + [Fact] + public async Task Execute_NoSnapshot_ShowsEmptyHint() { + var tracker = new Mock(); + tracker.Setup(t => t.GetLatestSnapshot()).Returns((RateLimitSnapshot?)null); + var (cmd, context, _) = Arrange(tracker: tracker); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("速率限制:"); + output.Should().Contain(" 暂无速率限制数据"); + output.Should().Contain(" 数据将在下次 API 请求后更新", "空快照应引导用户等待下次请求"); + } + + [Fact] + public async Task Execute_WithSnapshot_RendersLimitsAndResets() { + var tracker = new Mock(); + tracker.Setup(t => t.GetLatestSnapshot()).Returns(CreateSnapshot()); + var (cmd, context, _) = Arrange(arguments: "show", tracker: tracker); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain(" 请求限制: 1,000", "限制数值应 N0 千分位格式化"); + output.Should().Contain(" 请求剩余: 999"); + output.Should().Contain(" 请求重置: 2026-01-01 00:00:00", "重置时间应 yyyy-MM-dd HH:mm:ss 格式化"); + output.Should().Contain(" Token 限制: 2,000,000"); + output.Should().Contain(" Token 剩余: 1,234,567"); + output.Should().Contain(" Token 重置: 2026-01-01 12:30:00"); + output.Should().Contain(" 捕获时间: 08:15:30", "捕获时间应 HH:mm:ss 短格式"); + } + + [Fact] + public async Task Execute_NullFields_RenderFallbackLabels() { + var tracker = new Mock(); + tracker.Setup(t => t.GetLatestSnapshot()).Returns(new RateLimitSnapshot()); + var (cmd, context, _) = Arrange(tracker: tracker); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("请求限制: 无限制", "null 限制应显示无限制而非空值"); + output.Should().Contain("请求重置: 未知", "null 重置时间应显示未知"); + output.Should().Contain("Token 限制: 无限制"); + output.Should().Contain("Token 重置: 未知"); + } + + [Theory] + [InlineData("")] + [InlineData("show")] + [InlineData("SHOW")] + public async Task Execute_ShowVariants_AcceptedCaseInsensitively(string arguments) { + var tracker = new Mock(); + tracker.Setup(t => t.GetLatestSnapshot()).Returns((RateLimitSnapshot?)null); + var (cmd, context, _) = Arrange(arguments: arguments, tracker: tracker); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("速率限制:", "空参/show/SHOW 应等价显示快照(大小写不敏感)"); + } + + [Fact] + public async Task Execute_UnknownAction_ShowsErrorAndSupportedAction() { + var (cmd, context, _) = Arrange(arguments: "bogus"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("未知操作: bogus"); + output.Should().Contain("支持: show", "错误提示应列出支持的操作引导用户"); + } + + [Fact] + public async Task Execute_WithoutTracker_ShowsUnavailable() { + var services = CommandTestHarness.CreateServices(rateLimitTracker: null); + var context = CommandTestHarness.CreateContext(services); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new RateLimitCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("追踪器缺失应降级提示而非崩溃"); + output.Should().Contain("速率限制:"); + output.Should().Contain(" 速率限制追踪器未初始化"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new RateLimitCommand(); + cmd.Name.Should().Be("rate-limit-options"); + cmd.Usage.Should().Be("/rate-limit-options [show]"); + cmd.Description.Should().Contain("速率限制"); + cmd.Aliases.Should().Contain("rate-limit", "rate-limit 为别名"); + cmd.IsHidden.Should().BeTrue("rate-limit-options 为隐藏命令"); + } +} diff --git a/modules/composition/composition.tests/commands/ReleaseNotesCommandTest.cs b/modules/composition/composition.tests/commands/ReleaseNotesCommandTest.cs new file mode 100644 index 0000000000..d70abee5b5 --- /dev/null +++ b/modules/composition/composition.tests/commands/ReleaseNotesCommandTest.cs @@ -0,0 +1,113 @@ +namespace Composition.Tests.Commands; + +/// +/// /release-notes 命令单元测试 — 覆盖服务缺失降级(带版本/无版本)/空数据提示/ +/// 多版本多行 Notes 渲染/服务异常收敛/元数据一致性 +/// ReleaseNotesService 用 Moq 桩经 DI 注入(GetService 解析) +/// +[Collection("command-output")] +public sealed class ReleaseNotesCommandTest { + private static (ReleaseNotesCommand Command, ChatCommandContext Context, Mock Service) Arrange( + string arguments = "", + Mock? service = null) { + var releaseNotesService = service ?? new Mock(); + var services = CommandTestHarness.CreateServices( + configureDi: di => di.AddSingleton(releaseNotesService.Object)); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new ReleaseNotesCommand(), context, releaseNotesService); + } + + private static ReleaseInfo Release(string version, string notes, DateTime? publishedAt = null) => new() { + Version = version, + Notes = notes, + PublishedAt = publishedAt ?? new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), + }; + + [Fact] + public async Task Execute_WithoutService_ShowsGitHubFallbackHint() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new ReleaseNotesCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("服务缺失应降级提示而非崩溃"); + output.Should().Contain("发布说明 (当前版本:"); + output.Should().Contain("请访问 GitHub Releases 页面查看完整发布说明", + "服务缺失应引导用户去 GitHub 查看而非空输出"); + } + + [Fact] + public async Task Execute_WithoutService_WithVersion_ShowsVersionEcho() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: "1.2.3"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync( + () => new ReleaseNotesCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("查看版本 1.2.3 的发布说明", "指定版本但服务缺失时应回显版本"); + output.Should().Contain("请访问 GitHub Releases 页面查看完整发布说明"); + } + + [Fact] + public async Task Execute_EmptyReleases_ShowsNoDataHint() { + var service = new Mock(); + service.Setup(s => s.GetRecentReleasesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var (cmd, context, _) = Arrange(service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("暂无发布说明数据"); + output.Should().Contain("请访问 GitHub Releases 页面查看完整发布说明"); + service.Verify(s => s.GetRecentReleasesAsync(5, It.IsAny()), Times.Once, + "应请求最近 5 个版本"); + } + + [Fact] + public async Task Execute_WithReleases_RendersVersionNotesAndDate() { + var service = new Mock(); + service.Setup(s => s.GetRecentReleasesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { + Release("1.1.0", "新增 /falv 命令\n修复启动崩溃"), + Release("1.0.0", "首个稳定版本"), + }); + var (cmd, context, _) = Arrange(service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("Version 1.1.0:"); + output.Should().Contain(" · 新增 /falv 命令", "多行 Notes 应去空行逐条以 · 前缀渲染"); + output.Should().Contain(" · 修复启动崩溃"); + output.Should().Contain(" 发布于: 2026-01-15", "发布时间应 yyyy-MM-dd 格式化"); + output.Should().Contain("Version 1.0.0:"); + output.Should().Contain(" · 首个稳定版本"); + } + + [Fact] + public async Task Execute_ServiceFailure_ConvergesToErrorHint() { + var service = new Mock(); + service.Setup(s => s.GetRecentReleasesAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new HttpRequestException("network down")); + var (cmd, context, _) = Arrange(service: service); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("服务异常应收敛为错误提示而非向上抛出"); + output.Should().Contain("获取发布说明失败: network down"); + output.Should().Contain("请访问 GitHub Releases 页面查看完整发布说明", "异常后仍应引导 GitHub 回退"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new ReleaseNotesCommand(); + cmd.Name.Should().Be("release-notes"); + cmd.Usage.Should().Be("/release-notes [version]"); + cmd.Description.Should().Contain("发布说明"); + cmd.ArgumentHint.Should().Be("[version]"); + cmd.IsHidden.Should().BeTrue("release-notes 为隐藏命令"); + } +} diff --git a/modules/composition/composition.tests/commands/ReviewCommandTest.cs b/modules/composition/composition.tests/commands/ReviewCommandTest.cs new file mode 100644 index 0000000000..96f728e517 --- /dev/null +++ b/modules/composition/composition.tests/commands/ReviewCommandTest.cs @@ -0,0 +1,116 @@ +namespace Composition.Tests.Commands; + +/// +/// /review 命令单元测试 — 覆盖空参非交互直跑(gh pr list 模板)/PR 号模板(gh pr view+diff)/ +/// 流式输出主路径/LLM 流异常降级/流取消提示 +/// ChatService 用 Moq 桩经本地构造 CommandServices 注入(对齐 CompactCommandTest 先例), +/// SendMessageStreamAsync 捕获 prompt 断言模板不变量;测试环境非交互跳过 Dialog 选择器 +/// +[Collection("command-output")] +public sealed class ReviewCommandTest { + private static async IAsyncEnumerable StreamOf(params string[] chunks) { + foreach (var chunk in chunks) yield return chunk; + } + + private static (ReviewCommand Command, Mock Chat, ChatCommandContext Context) Arrange( + string arguments = "", + string[]? streamChunks = null, + Exception? streamError = null) { + var chat = new Mock(); + if (streamError is not null) { + chat.Setup(c => c.SendMessageStreamAsync(It.IsAny(), It.IsAny())) + .Throws(streamError); + } else { + chat.Setup(c => c.SendMessageStreamAsync(It.IsAny(), It.IsAny())) + .Returns((string _, CancellationToken _) => StreamOf(streamChunks ?? ["审查意见 A"])); + } + + var commandServices = new CommandServices { + ChatService = chat.Object, + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + var context = CommandTestHarness.CreateContext(commandServices, arguments: arguments); + return (new ReviewCommand(), chat, context); + } + + [Fact] + public async Task Execute_EmptyArgs_NonInteractive_UsesPrListPrompt() { + var (cmd, chat, context) = Arrange(arguments: ""); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("正在审查..."); + chat.Verify(c => c.SendMessageStreamAsync( + It.Is(p => p.Contains("gh pr list")), It.IsAny()), Times.Once, + "无 PR 号模板应指示 LLM 先 gh pr list 列出 PR"); + } + + [Fact] + public async Task Execute_WithPrNumber_UsesPrViewAndDiffPrompt() { + var (cmd, chat, context) = Arrange(arguments: "123"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("正在审查..."); + chat.Verify(c => c.SendMessageStreamAsync( + It.Is(p => p.Contains("gh pr view 123") && p.Contains("gh pr diff 123")), + It.IsAny()), Times.Once, "PR 号模板应指示 LLM 查看 PR 详情与 diff"); + } + + [Fact] + public async Task Execute_NumericArg_StillTreatedAsPrNumber() { + var (cmd, chat, context) = Arrange(arguments: " 42 "); + + var (_, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + chat.Verify(c => c.SendMessageStreamAsync( + It.Is(p => p.Contains("gh pr view 42")), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Execute_StreamEmitsChunks_PassesPromptToChatService() { + var (cmd, chat, context) = Arrange(arguments: "7", streamChunks: ["第一段", "第二段"]); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + chat.Verify(c => c.SendMessageStreamAsync(It.IsAny(), It.IsAny()), Times.Once); + output.Should().Contain("正在审查...", "流式输出由 ChatService 内部处理,命令侧只输出审查中提示"); + } + + [Fact] + public async Task Execute_StreamThrows_ShowsErrorWithExceptionMessage() { + var (cmd, chat, context) = Arrange(arguments: "9", streamError: new InvalidOperationException("llm down")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("审查异常不应中断会话"); + output.Should().Contain("审查失败"); + output.Should().Contain("llm down"); + } + + [Fact] + public async Task Execute_StreamCancelled_ShowsCancelledMessage() { + var (cmd, chat, context) = Arrange(arguments: "9", streamError: new OperationCanceledException()); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("审查已取消。"); + output.Should().NotContain("审查失败", "取消应走独立分支而非通用错误分支"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new ReviewCommand(); + cmd.Name.Should().Be("review"); + cmd.Usage.Should().Be("/review [pr-number]"); + cmd.ExposeToMcp.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/SamplingCommandTest.cs b/modules/composition/composition.tests/commands/SamplingCommandTest.cs new file mode 100644 index 0000000000..26dbba11a5 --- /dev/null +++ b/modules/composition/composition.tests/commands/SamplingCommandTest.cs @@ -0,0 +1,156 @@ +namespace Composition.Tests.Commands; + +/// +/// /sampling 命令单元测试 — 覆盖提供者缺失降级/无参查询(有值+默认值)/unset 重置/ +/// 温度+最大Token 更新/仅温度(保持最大)/无效温度(非数字/超范围)/无效最大Token +/// IExecutionSettingsProvider 用 Moq SetupProperty 桩经 harness 注入,断言写入的实际值 +/// +[Collection("command-output")] +public sealed class SamplingCommandTest { + private static Mock CreateProvider(float? temperature = null, int? maxTokens = null) { + var provider = new Mock(); + provider.SetupProperty(p => p.Temperature, temperature); + provider.SetupProperty(p => p.MaxTokens, maxTokens); + return provider; + } + + private static (SamplingCommand Command, Mock Provider, ChatCommandContext Context) Arrange( + string arguments = "", + bool withProvider = true, + float? temperature = null, + int? maxTokens = null) { + var provider = CreateProvider(temperature, maxTokens); + var services = CommandTestHarness.CreateServices( + executionSettingsProvider: withProvider ? provider.Object : null); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new SamplingCommand(), provider, context); + } + + [Fact] + public async Task Execute_WithoutProvider_ShowsEngineNotReadyError() { + var (cmd, provider, context) = Arrange(arguments: "0.5", withProvider: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("采样参数失败"); + output.Should().Contain("引擎未就绪"); + provider.VerifySet(p => p.Temperature = It.IsAny(), Times.Never, + "提供者缺失时不应写任何设置"); + } + + [Fact] + public async Task Execute_EmptyArgs_ShowsCurrentValues() { + var (cmd, _, context) = Arrange(arguments: "", temperature: 0.7f, maxTokens: 2048); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("采样参数: 温度 0.70, 最大 2048 tokens"); + } + + [Fact] + public async Task Execute_EmptyArgs_Defaults_ShowsDefaultLabels() { + var (cmd, _, context) = Arrange(arguments: ""); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("采样参数: 温度 默认, 最大 默认 tokens"); + } + + [Fact] + public async Task Execute_Unset_ClearsBothOverrides() { + var (cmd, provider, context) = Arrange(arguments: "unset", temperature: 0.9f, maxTokens: 512); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + provider.Object.Temperature.Should().BeNull("unset 应清除温度覆盖"); + provider.Object.MaxTokens.Should().BeNull("unset 应清除最大 Token 覆盖"); + output.Should().Contain("采样参数已重置为引擎默认值"); + } + + [Fact] + public async Task Execute_UnsetArg_IsCaseInsensitive() { + var (cmd, provider, context) = Arrange(arguments: "UNSET", temperature: 0.9f); + + await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + provider.Object.Temperature.Should().BeNull("unset 匹配应 OrdinalIgnoreCase"); + } + + [Fact] + public async Task Execute_TemperatureAndMaxTokens_UpdatesBoth() { + var (cmd, provider, context) = Arrange(arguments: "1.5 4096"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + provider.Object.Temperature.Should().Be(1.5f); + provider.Object.MaxTokens.Should().Be(4096); + output.Should().Contain("采样参数已更新: 温度 1.50, 最大 4096 tokens"); + } + + [Fact] + public async Task Execute_TemperatureOnly_KeepsExistingMaxTokens() { + var (cmd, provider, context) = Arrange(arguments: "0.8", maxTokens: 1024); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + provider.Object.Temperature.Should().Be(0.8f); + provider.Object.MaxTokens.Should().Be(1024, "只给温度时最大 Token 应保持原值(对齐 GUI 单滑块语义)"); + output.Should().Contain("采样参数已更新: 温度 0.80, 最大 1024 tokens"); + } + + [Theory] + [InlineData("abc")] + [InlineData("3")] + [InlineData("-0.5")] + public async Task Execute_InvalidTemperature_ShowsRangeErrorWithoutUpdate(string badTemp) { + var (cmd, provider, context) = Arrange(arguments: badTemp, temperature: 0.4f); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain($"无效温度: {badTemp}"); + output.Should().Contain("有效范围 0-2"); + provider.Object.Temperature.Should().Be(0.4f, "无效温度不应覆盖原值"); + } + + [Theory] + [InlineData("0.7 0")] + [InlineData("0.7 -5")] + [InlineData("0.7 abc")] + public async Task Execute_InvalidMaxTokens_ShowsErrorWithoutMaxUpdate(string args) { + var (cmd, provider, context) = Arrange(arguments: args, maxTokens: 256); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("无效最大 Token 数"); + provider.Object.MaxTokens.Should().Be(256, "无效最大 Token 不应覆盖原值"); + } + + [Fact] + public async Task Execute_BoundaryTemperatures_AreValid() { + var (cmd, provider, context) = Arrange(arguments: "0 100"); + var (cmd2, provider2, context2) = Arrange(arguments: "2 100"); + + var (_, output1) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + var (_, output2) = await CommandTestHarness.CaptureOutputAsync(() => cmd2.ExecuteAsync(context2)); + + provider.Object.Temperature.Should().Be(0f, "下界 0 应合法"); + provider2.Object.Temperature.Should().Be(2f, "上界 2 应合法"); + output1.Should().Contain("采样参数已更新: 温度 0.00, 最大 100 tokens"); + output2.Should().Contain("采样参数已更新: 温度 2.00, 最大 100 tokens"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new SamplingCommand(); + cmd.Name.Should().Be("sampling"); + cmd.Usage.Should().Be("/sampling [温度] [最大Token|unset]"); + } +} diff --git a/modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs b/modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs new file mode 100644 index 0000000000..44c61793ad --- /dev/null +++ b/modules/composition/composition.tests/commands/SandboxToggleCommandTest.cs @@ -0,0 +1,266 @@ +namespace Composition.Tests.Commands; + +/// +/// /sandbox-toggle 命令单元测试 — 覆盖 status 默认分支(禁用态/活动沙箱详情)/on+off 非交互确认拒绝降级/ +/// switch(缺类型用法/无效类型/有效类型切换/管理器缺失)/exclude(缺参列表/路径不存在/新增持久化/ +/// 重复路径幂等/配置服务缺失)/管理器缺失静默降级 +/// ISandboxManager 与 IConfigurationService 经 DI 注入;确认走静态 Confirmation, +/// 测试环境非交互恒拒绝 — 断言以硬编码中文文案+参数不变量为主 +/// +[Collection("command-output")] +public sealed class SandboxToggleCommandTest { + private static SandboxInfo CreateInfo(string rootPath = @"C:\sandbox-root") => new() { + Type = SandboxType.Soft, + SandboxId = "sb-1", + RootPath = rootPath, + EnteredAt = new DateTime(2026, 1, 1, 10, 0, 0), + IsRestricted = true, + }; + + private static (SandboxToggleCommand Command, Mock Manager, Mock Config, ChatCommandContext Context, IFileSystem Fs) Arrange( + string arguments = "", + Mock? managerOverride = null, + Mock? configOverride = null, + bool withManager = true, + bool withConfig = true, + SandboxInfo? currentSandbox = null, + bool isInSandbox = false) { + var manager = managerOverride ?? new Mock(); + manager.Setup(m => m.GetAvailableTypes()).Returns([SandboxType.Soft, SandboxType.Docker]); + manager.Setup(m => m.IsInSandbox).Returns(isInSandbox); + manager.Setup(m => m.CurrentSandbox).Returns(currentSandbox); + manager.Setup(m => m.ActiveSandboxType).Returns(SandboxType.Soft); + + var config = configOverride ?? new Mock(); + var fs = TestFileSystem.Current; + var services = CommandTestHarness.CreateServices( + fileSystem: fs, + configureDi: di => { + if (withManager) di.AddSingleton(manager.Object); + if (withConfig) di.AddSingleton(config.Object); + }); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new SandboxToggleCommand(), manager, config, context, fs); + } + + [Fact] + public async Task Execute_EmptyArgs_ShowsStatusWithDisabledState() { + var (cmd, manager, _, context, _) = Arrange(); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("沙箱模式"); + output.Should().Contain("已禁用"); + output.Should().Contain("可用沙箱类型: soft, docker"); + output.Should().Contain("/sandbox-toggle on"); + output.Should().Contain("/sandbox-toggle exclude "); + output.Should().Contain("/sandbox-toggle switch "); + } + + [Fact] + public async Task Execute_StatusArg_SameAsDefault() { + var (cmd, manager, _, context, _) = Arrange(arguments: "status"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("可用沙箱类型: soft, docker"); + manager.Verify(m => m.EnterSandboxAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_StatusWithActiveSandbox_ShowsSandboxDetails() { + var (cmd, manager, _, context, _) = Arrange(arguments: "status", currentSandbox: CreateInfo(), isInSandbox: true); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("已启用"); + output.Should().Contain(@"C:\sandbox-root", "活动沙箱应显示根路径"); + output.Should().Contain("10:00:00", "活动沙箱应显示进入时间"); + } + + [Fact] + public async Task Execute_On_NonInteractiveConfirmationDenied_SkipsEnter() { + var (cmd, manager, _, context, _) = Arrange(arguments: "on"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + manager.Verify(m => m.EnterSandboxAsync(It.IsAny(), It.IsAny()), + Times.Never, "测试环境确认恒拒绝,不应进入沙箱"); + output.Should().NotBeEmpty("确认取消应有提示输出"); + } + + [Fact] + public async Task Execute_EnableAlias_SameAsOn() { + var (cmd, manager, _, context, _) = Arrange(arguments: "enable"); + + var (result, _) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + manager.Verify(m => m.EnterSandboxAsync(It.IsAny(), It.IsAny()), Times.Never, + "enable 别名应与 on 同样受确认门控"); + } + + [Fact] + public async Task Execute_Off_NonInteractiveConfirmationDenied_SkipsExit() { + var (cmd, manager, _, context, _) = Arrange(arguments: "off"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + manager.Verify(m => m.ExitSandboxAsync(It.IsAny()), Times.Never, + "测试环境确认恒拒绝,不应退出沙箱"); + output.Should().NotBeEmpty(); + } + + [Fact] + public async Task Execute_WithoutManager_NonInteractive_SilentContinue() { + var (cmd, manager, _, context, _) = Arrange(arguments: "", withManager: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().NotContain("沙箱模式", "非交互环境管理器缺失不应输出状态面板"); + manager.Verify(m => m.EnterSandboxAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_Switch_WithoutType_ShowsCurrentTypeAndUsage() { + var (cmd, manager, _, context, _) = Arrange(arguments: "switch"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("当前沙箱类型: soft"); + output.Should().Contain("可用类型: soft, docker"); + output.Should().Contain("用法: /sandbox-toggle switch "); + manager.Verify(m => m.SwitchProviderAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_Switch_InvalidType_ShowsErrorWithAvailableTypes() { + var (cmd, manager, _, context, _) = Arrange(arguments: "switch bogus"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("未知沙箱类型: bogus"); + output.Should().Contain("可用类型: soft, docker"); + manager.Verify(m => m.SwitchProviderAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_Switch_ValidType_SwitchesProvider() { + var (cmd, manager, _, context, _) = Arrange(arguments: "switch docker"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + manager.Verify(m => m.SwitchProviderAsync(SandboxType.Docker, It.IsAny()), Times.Once, + "switch docker 应调用 SwitchProviderAsync(Docker)"); + output.Should().Contain("沙箱已切换到: docker"); + } + + [Fact] + public async Task Execute_Switch_WithoutManager_ShowsUninitialized() { + var (cmd, manager, _, context, _) = Arrange(arguments: "switch docker", withManager: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("沙箱服务未初始化"); + manager.Verify(m => m.SwitchProviderAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_Exclude_WithoutPath_ShowsCurrentExclusions() { + var config = new Mock(); + config.Setup(c => c.GetAsync("sandbox.excludedPaths", It.IsAny())) + .ReturnsAsync(@"C:\path-a"); + var (cmd, _, _, context, _) = Arrange(arguments: "exclude", configOverride: config); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + config.Verify(c => c.GetAsync("sandbox.excludedPaths", It.IsAny()), Times.Once, + "exclude 无参时应读取现有排除路径"); + output.Should().Contain(@"C:\path-a", "现有排除路径应渲染"); + output.Should().Contain("/sandbox-toggle exclude "); + } + + [Fact] + public async Task Execute_Exclude_MissingPath_ShowsPathNotFound() { + var config = new Mock(); + // 命令侧 GetNormalizedArgs().ToLowerInvariant() 会把路径参数一并小写化 — 用小写路径构造避免大小写歧义 + var (cmd, _, configMock, context, _) = Arrange( + arguments: @"exclude c:\no-such-dir", configOverride: config); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain(@"c:\no-such-dir", "路径不存在提示应含原始路径"); + configMock.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "路径不存在时不应写配置"); + } + + [Fact] + public async Task Execute_Exclude_ExistingDirectory_PersistsFullPath() { + var dir = Path.Combine(Path.GetTempPath(), "sb-exclude-new").ToLowerInvariant(); + var config = new Mock(); + config.Setup(c => c.GetAsync("sandbox.excludedPaths", It.IsAny())) + .ReturnsAsync((string?)null); + var (cmd, _, _, context, fs) = Arrange(arguments: $"exclude {dir}", configOverride: config); + fs.CreateDirectory(dir); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + var fullPath = Path.GetFullPath(dir); + config.Verify(c => c.SetAsync("sandbox.excludedPaths", fullPath, It.IsAny()), Times.Once, + "新增排除路径应持久化 Path.GetFullPath 归一后的路径"); + output.Should().Contain(fullPath); + } + + [Fact] + public async Task Execute_Exclude_AlreadyExcluded_IsIdempotent() { + var dir = Path.Combine(Path.GetTempPath(), "sb-exclude-dup").ToLowerInvariant(); + var fullPath = Path.GetFullPath(dir); + var config = new Mock(); + config.Setup(c => c.GetAsync("sandbox.excludedPaths", It.IsAny())) + .ReturnsAsync(fullPath); + var (cmd, _, _, context, fs) = Arrange(arguments: $"exclude {dir}", configOverride: config); + fs.CreateDirectory(dir); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + config.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "路径已在排除列表时不应重复写入"); + output.Should().Contain(fullPath, "重复排除提示应含路径"); + } + + [Fact] + public async Task Execute_Exclude_WithoutConfigService_DoesNotThrow() { + var dir = Path.Combine(Path.GetTempPath(), "sb-exclude-noconfig"); + var (cmd, _, config, context, fs) = Arrange(arguments: $"exclude {dir}", withConfig: false); + fs.CreateDirectory(dir); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("配置服务缺失应优雅降级而非抛异常"); + config.Verify(c => c.SetAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new SandboxToggleCommand(); + cmd.Name.Should().Be("sandbox-toggle"); + cmd.Usage.Should().Be("/sandbox-toggle [on|off|status|exclude|switch]"); + cmd.Aliases.Should().Contain("sandbox"); + cmd.IsHidden.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/SecurityReviewCommandTest.cs b/modules/composition/composition.tests/commands/SecurityReviewCommandTest.cs new file mode 100644 index 0000000000..e91f4566e3 --- /dev/null +++ b/modules/composition/composition.tests/commands/SecurityReviewCommandTest.cs @@ -0,0 +1,135 @@ +namespace Composition.Tests.Commands; + +/// +/// /security-review 命令单元测试 — 覆盖 Git 执行器未注册降级/无变更提示/ +/// 有变更主路径(git 四连查询+prompt 上下文段+LLM 结果渲染)/LLM 异常降级/ +/// unstaged 为空时回退 staged diff +/// IGitCommandRunner 经 DI 注入按参数桩化;ChatService 用 Moq 桩经本地构造 CommandServices 注入 +/// +[Collection("command-output")] +public sealed class SecurityReviewCommandTest { + private static Mock CreateGitRunner( + string status = "M src/a.cs", + string diffNames = "src/a.cs", + string log = "abc123 fix", + string diff = "@@ -1 +1 @@\n+changed", + string stagedDiff = "") { + var runner = new Mock(); + runner.Setup(r => r.ExecuteAsync("status --porcelain", It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitCommandResult { Success = true, Output = status }); + runner.Setup(r => r.ExecuteAsync("diff --name-only", It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitCommandResult { Success = true, Output = diffNames }); + runner.Setup(r => r.ExecuteAsync("log --oneline -10", It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitCommandResult { Success = true, Output = log }); + runner.Setup(r => r.ExecuteAsync("diff", It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitCommandResult { Success = true, Output = diff }); + runner.Setup(r => r.ExecuteAsync("diff --cached", It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitCommandResult { Success = true, Output = stagedDiff }); + return runner; + } + + private static (SecurityReviewCommand Command, Mock Runner, Mock Chat, ChatCommandContext Context) Arrange( + Mock? runner = null, + bool withRunner = true, + string? llmResult = "SEC-1: 注入风险", + Exception? llmError = null) { + var chat = new Mock(); + if (llmError is not null) { + chat.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(llmError); + } else { + chat.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(llmResult ?? string.Empty); + } + + var effectiveRunner = runner ?? CreateGitRunner(); + var services = new ServiceCollection(); + if (withRunner) services.AddSingleton(effectiveRunner.Object); + var commandServices = new CommandServices { + ChatService = chat.Object, + CodeService = Mock.Of(), + PlanService = Mock.Of(), + FileSystem = TestFileSystem.Current, + ServiceProvider = services.BuildServiceProvider(), + }; + var context = CommandTestHarness.CreateContext(commandServices); + return (new SecurityReviewCommand(), effectiveRunner, chat, context); + } + + [Fact] + public async Task Execute_WithoutGitRunner_ShowsNotRegistered() { + var (cmd, runner, chat, context) = Arrange(withRunner: false); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("Git 命令执行器未注册,无法执行 /security-review"); + chat.Verify(c => c.SendMessageAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_NoChanges_ShowsNothingToReview() { + var (cmd, runner, chat, context) = Arrange(runner: CreateGitRunner( + status: "", diffNames: "", log: "", diff: "", stagedDiff: "")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("没有要审查的变更"); + chat.Verify(c => c.SendMessageAsync(It.IsAny(), It.IsAny()), Times.Never, + "无变更时不应调用 LLM"); + } + + [Fact] + public async Task Execute_WithChanges_RunsGitQueriesAndRendersLlmResult() { + var (cmd, runner, chat, context) = Arrange(); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + runner.Verify(r => r.ExecuteAsync("status --porcelain", It.IsAny(), It.IsAny()), Times.Once); + runner.Verify(r => r.ExecuteAsync("diff --name-only", It.IsAny(), It.IsAny()), Times.Once); + runner.Verify(r => r.ExecuteAsync("log --oneline -10", It.IsAny(), It.IsAny()), Times.Once); + runner.Verify(r => r.ExecuteAsync("diff", It.IsAny(), It.IsAny()), Times.Once); + output.Should().Contain("正在执行安全审查..."); + output.Should().Contain("SEC-1: 注入风险", "LLM 审查结果应直接渲染到终端"); + chat.Verify(c => c.SendMessageAsync( + It.Is(p => p.Contains("GIT STATUS") && p.Contains("src/a.cs") && p.Contains("abc123 fix")), + It.IsAny()), Times.Once, "prompt 应包含 git 状态/文件/提交上下文"); + } + + [Fact] + public async Task Execute_EmptyUnstagedDiff_FallsBackToStagedDiff() { + var runner = CreateGitRunner(status: "M src/b.cs", diff: "", stagedDiff: "@@ staged @@"); + var (cmd, runnerMock, chat, context) = Arrange(runner: runner); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + runnerMock.Verify(r => r.ExecuteAsync("diff --cached", It.IsAny(), It.IsAny()), + Times.Once, "unstaged diff 为空时应回退查询 staged diff"); + chat.Verify(c => c.SendMessageAsync( + It.Is(p => p.Contains("@@ staged @@")), It.IsAny()), Times.Once, + "prompt 的 DIFF CONTENT 应使用 staged diff"); + } + + [Fact] + public async Task Execute_LlmThrows_ShowsErrorWithExceptionMessage() { + var (cmd, _, chat, context) = Arrange(llmError: new InvalidOperationException("model timeout")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue("LLM 异常不应中断会话"); + output.Should().Contain("安全审查失败"); + output.Should().Contain("model timeout"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new SecurityReviewCommand(); + cmd.Name.Should().Be("security-review"); + cmd.Usage.Should().Be("/security-review"); + cmd.ExposeToMcp.Should().BeTrue(); + } +} diff --git a/modules/composition/composition.tests/commands/SkillsCommandTest.cs b/modules/composition/composition.tests/commands/SkillsCommandTest.cs new file mode 100644 index 0000000000..866da97bde --- /dev/null +++ b/modules/composition/composition.tests/commands/SkillsCommandTest.cs @@ -0,0 +1,157 @@ +namespace Composition.Tests.Commands; + +/// +/// /skills 命令单元测试 — 覆盖空参列表(空技能引导创建/多技能按来源分组渲染)/ +/// info 详情(按全名/短名匹配)/info 未知名/info 缺参用法/注册表缺失降级 +/// ChatCommandRegistry 真实例 + CustomChatCommand 包装 CustomCommand 经 harness 注入; +/// 测试环境非交互走纯文本列表回退分支 +/// +[Collection("command-output")] +public sealed class SkillsCommandTest { + private static CustomCommand CreateSkill( + string name, + string content = "技能内容 $ARGUMENTS", + string description = "示例技能", + string? ns = null, + string sourcePath = @"C:\proj\.trae\commands\demo.md") => new() { + Name = name, + Content = content, + Description = description, + SourcePath = sourcePath, + Namespace = ns, + }; + + private static (SkillsCommand Command, ChatCommandContext Context) Arrange( + string arguments = "", + params CustomCommand[] skills) { + var registry = new ChatCommandRegistry(); + foreach (var skill in skills) { + registry.Register(new CustomChatCommand(skill)); + } + var services = CommandTestHarness.CreateServices(commandRegistry: registry); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new SkillsCommand(), context); + } + + [Fact] + public async Task Execute_EmptyArgs_NoSkills_ShowsCreationGuide() { + var (cmd, context) = Arrange(arguments: ""); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + output.Should().Contain("当前无自定义技能"); + output.Should().Contain("项目级: .trae/commands/.md"); + output.Should().Contain("用户级: ~/.jcc/commands/.md"); + } + + [Fact] + public async Task Execute_EmptyArgs_WithSkills_ListsGroupedCommands() { + var (cmd, context) = Arrange( + arguments: "", + CreateSkill("demo", description: "演示技能"), + CreateSkill("deploy", description: "部署技能", ns: "ops", sourcePath: @"C:\proj\.trae\commands\ops\deploy.md")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("=== 可用技能 ==="); + output.Should().Contain("/demo - 演示技能"); + output.Should().Contain("/ops:deploy - 部署技能"); + output.Should().Contain("使用 /skills info 查看详细信息"); + } + + [Fact] + public async Task Execute_Info_WithoutName_ShowsUsage() { + var (cmd, context) = Arrange(arguments: "info"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("用法: /skills info "); + } + + [Fact] + public async Task Execute_Info_WithFullName_ShowsSkillDetail() { + var (cmd, context) = Arrange( + arguments: "info ops:deploy", + CreateSkill("deploy", content: "部署步骤内容", ns: "ops")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("名称: ops:deploy"); + output.Should().Contain("来源: "); + output.Should().Contain("内容预览:"); + output.Should().Contain("部署步骤内容"); + } + + [Fact] + public async Task Execute_Info_WithShortName_MatchesByNameToo() { + var (cmd, context) = Arrange( + arguments: "info deploy", + CreateSkill("deploy", ns: "ops")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("名称: ops:deploy", "短名也应能命中带命名空间的技能"); + } + + [Fact] + public async Task Execute_Info_NameIsCaseInsensitive() { + var (cmd, context) = Arrange( + arguments: "info DEMO", + CreateSkill("demo")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("名称: demo"); + } + + [Fact] + public async Task Execute_Info_UnknownSkill_ShowsUnknownHint() { + var (cmd, context) = Arrange( + arguments: "info nope", + CreateSkill("demo")); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("未知技能: nope"); + } + + [Fact] + public async Task Execute_LongContent_IsTruncatedInPreview() { + var longContent = new string('x', 300); + var (cmd, context) = Arrange( + arguments: "info demo", + CreateSkill("demo", content: longContent)); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain(new string('x', 200) + "...", "超过 200 字符的内容预览应截断加省略号"); + } + + [Fact] + public async Task Execute_WithoutRegistry_ShowsEmptyGuide() { + var services = CommandTestHarness.CreateServices(); + var context = CommandTestHarness.CreateContext(services, arguments: ""); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => new SkillsCommand().ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + output.Should().Contain("当前无自定义技能", "注册表缺失视为无技能,输出创建引导"); + } + + [Fact] + public void NameAndUsage_MetadataShouldBeConsistent() { + var cmd = new SkillsCommand(); + cmd.Name.Should().Be("skills"); + cmd.Usage.Should().Be("/skills [info ]"); + cmd.Aliases.Should().Contain("skill"); + } +} diff --git a/modules/composition/composition.tests/commands/StatuslineCommandTest.cs b/modules/composition/composition.tests/commands/StatuslineCommandTest.cs new file mode 100644 index 0000000000..ac4662d4be --- /dev/null +++ b/modules/composition/composition.tests/commands/StatuslineCommandTest.cs @@ -0,0 +1,154 @@ +namespace Composition.Tests.Commands; + +/// +/// /statusline 命令单元测试 — 覆盖 on/enable 启用写入配置/off/disable 禁用写入配置/ +/// format 查询当前模板/format 设置模板持久化/空参状态汇总(配置读取+默认值回退)/配置服务缺失降级 +/// IConfigurationService 经 DI 注入;断言 SetAsync 键值对与输出不变量 +/// +[Collection("command-output")] +public sealed class StatuslineCommandTest { + private static (StatuslineCommand Command, Mock Config, ChatCommandContext Context) Arrange( + string arguments = "", + bool withConfig = true, + string? enabledValue = null, + string? formatValue = null) { + var config = new Mock(); + config.Setup(c => c.GetAsync("statusline.enabled", It.IsAny())) + .ReturnsAsync(enabledValue); + config.Setup(c => c.GetAsync("statusline.format", It.IsAny())) + .ReturnsAsync(formatValue); + + var services = CommandTestHarness.CreateServices( + configureDi: di => { + if (withConfig) di.AddSingleton(config.Object); + }); + var context = CommandTestHarness.CreateContext(services, arguments: arguments); + return (new StatuslineCommand(), config, context); + } + + [Fact] + public async Task Execute_On_WritesEnabledTrue() { + var (cmd, config, context) = Arrange(arguments: "on"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + result.IsHandled.Should().BeTrue(); + config.Verify(c => c.SetAsync("statusline.enabled", "true", It.IsAny()), Times.Once, + "on 应写入 statusline.enabled=true"); + output.Should().Contain("状态栏: 已启用"); + } + + [Fact] + public async Task Execute_EnableAlias_SameAsOn() { + var (cmd, config, context) = Arrange(arguments: "enable"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + config.Verify(c => c.SetAsync("statusline.enabled", "true", It.IsAny()), Times.Once); + output.Should().Contain("状态栏: 已启用"); + } + + [Fact] + public async Task Execute_Off_WritesEnabledFalse() { + var (cmd, config, context) = Arrange(arguments: "off"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + config.Verify(c => c.SetAsync("statusline.enabled", "false", It.IsAny()), Times.Once, + "off 应写入 statusline.enabled=false"); + output.Should().Contain("状态栏: 已禁用"); + } + + [Fact] + public async Task Execute_DisableAlias_SameAsOff() { + var (cmd, config, context) = Arrange(arguments: "disable"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + config.Verify(c => c.SetAsync("statusline.enabled", "false", It.IsAny()), Times.Once); + output.Should().Contain("状态栏: 已禁用"); + } + + [Fact] + public async Task Execute_Format_WithoutTemplate_ShowsCurrentFormatAndUsage() { + var (cmd, config, context) = Arrange(arguments: "format", formatValue: "{model} | {tokens}"); + + var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context)); + + result.ShouldContinue.Should().BeTrue(); + config.Verify(c => c.GetAsync("statusline.format", It.IsAny()), Times.Once, + "format 查询应读取当前模板"); + output.Should().Contain("当前状态栏格式: {model} | {tokens}"); + output.Should().Contain("用法: /statusline format