Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,35 @@ public async Task Execute_Continue_Exhausted_RefillsAndContinues() {
engine.Verify(e => e.ContinueAsync(BudgetRefillMode.Both, It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<CancellationToken>()), Times.Once,
"--continue both 应以 Both 模式续费");
clean.Should().Contain("[预算耗尽] 轮次和Token预算均耗尽");
clean.Should().Contain("续费方式: Both", "续费方式回显解析后的枚举名");
clean.Should().Contain("续费方式: both", "续费方式回显 [EnumValue] 真值小写对齐输入形态");
clean.Should().Contain("继续推理中...");
}

/// <summary>生产 bug 修复(TASK065 law 域): --continue both 回显"续费方式: Both"(枚举 ToString
/// PascalCase)与用户小写输入形态不一致。修复后回显用 ToValue()([EnumValue] 真值小写)对齐输入。</summary>
[Theory]
[InlineData("both", "both")]
[InlineData("rounds", "rounds_only")]
[InlineData("tokens", "tokens_only")]
[InlineData("default", "default")]
[InlineData("", "default")]
public async Task Execute_Continue_RefillModeEcho_UsesEnumValueForm(string argument, string echoed) {
var engine = CreateEngine(budget: new BudgetStatus {
RoundsUsed = 5,
RoundsBudget = 5,
TokensUsed = 200,
TokensBudget = 200,
});
var (cmd, context, _) = Arrange(arguments: $"--continue {argument}".TrimEnd(), engine: engine);

var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));
var clean = StripAnsi(output);

result.ShouldContinue.Should().BeTrue();
clean.Should().Contain($"续费方式: {echoed}",
$"用户输入 '{argument}' 时回显应使用 [EnumValue] 真值 '{echoed}' 而非枚举 ToString");
}

[Theory]
[InlineData("--continue rounds", BudgetRefillMode.RoundsOnly)]
[InlineData("--continue tokens", BudgetRefillMode.TokensOnly)]
Expand Down
59 changes: 56 additions & 3 deletions modules/composition/composition.tests/commands/GoalCommandTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,30 @@ public async Task Execute_WithObjective_ConstraintAndBudget_ParsedIntoStart() {
output.Should().Contain(" 预算: 5000 Token");
}

/// <summary>生产 bug 修复(TASK065 第五波): usage 文档形态 "/goal [初始提示] [--constraint ...] [--budget N]"
/// 目标在前 — 选项解析应为位置无关剥离,objective 纯净不含选项原文,约束与预算正常提取传 StartAsync。</summary>
[Fact]
public async Task Execute_ObjectiveFirstDocForm_PassesCleanObjectiveToStart() {
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<string>(p => p.Contains("优化性能") && !p.Contains("--constraint") && !p.Contains("--budget")),
It.IsAny<List<string>?>(),
It.Is<int?>(b => b == 5000),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()), Times.Once,
"目标在前的文档形态应剥离选项后传纯净 objective");
output.Should().Contain(" 初始提示: 优化性能", "初始提示不应混入选项原文");
output.Should().Contain(" 预填约束: 不许改配置");
output.Should().Contain(" 预算: 5000 Token");
}

[Fact]
public async Task Execute_StartFailure_ConvergesToErrorHint() {
var engine = CreateEngine();
Expand Down Expand Up @@ -312,10 +336,10 @@ public async Task Execute_RegistryPause_PausesViaRegistry() {
output.Should().Contain("◎ /goal 已暂停");
}

/// <summary>生产 bug 登记(TASK065 brain 域): usage 提示 "/goal [初始提示] [--constraint '约束'] [--budget N]"
/// 的文档形态(目标在前、选项在后)不被 ParseGoalArgs 支持 — 选项剥离仅匹配 remaining 开头,
/// <summary>生产 bug 登记(TASK065 brain 域,第五波已修复): usage 提示 "/goal [初始提示] [--constraint '约束'] [--budget N]"
/// 的文档形态(目标在前、选项在后)曾不被 ParseGoalArgs 支持 — 选项剥离仅匹配 remaining 开头,
/// 目标在前时整个字符串(含 --constraint/--budget 原文)作为 objective 传入 GoalSpecPromptBuilder。
/// 本测试按当前实现以"选项在前"形态验证解析正路径;修复为任意位置剥离后应补充目标在前的用例。</summary>
/// 修复后选项解析为位置无关全串扫描;"选项在前"既有形态与目标在前的文档形态均正确剥离。</summary>
[Theory]
[InlineData("--cron */5 * * * * 每五分钟检查", true, "*/5 * * * *", "每五分钟检查")]
[InlineData("-c 0 9 * * 1 每周一早九点", true, "0 9 * * 1", "每周一早九点")]
Expand Down Expand Up @@ -354,6 +378,35 @@ public void ParseGoalArgs_InvalidBudget_Ignored() {
parsed.Objective.Should().Be("目标");
}

/// <summary>生产 bug 修复(TASK065 第五波): 目标在前的文档形态 — 选项剥离应位置无关,
/// 扫描全串提取 --constraint/--budget,objective 纯净不含选项原文。</summary>
[Theory]
[InlineData("优化性能 --constraint '不许改配置' --budget 5000", "优化性能", 5000, "不许改配置")]
[InlineData("优化性能 --budget 5000", "优化性能", 5000, "")]
[InlineData("优化性能 --constraint '不许改配置'", "优化性能", null, "不许改配置")]
[InlineData("优化性能 --Constraint '大写选项' --Budget 99", "优化性能", 99, "大写选项")]
[InlineData("优化性能 --constraint '步骤A' --constraint 步骤B --budget 100", "优化性能", 100, "步骤A|步骤B")]
public void ParseGoalArgs_ObjectiveFirstDocForm_StripsOptionsAnywhere(
string args, string expectObjective, int? expectBudget, string expectConstraints) {
var parsed = GoalCommand.ParseGoalArgs(args);

parsed.IsCron.Should().BeFalse($"'{args}' 不应误判为 cron 形态");
parsed.Objective.Should().Be(expectObjective, "目标在前时 objective 应纯净不含选项原文");
parsed.TokenBudget.Should().Be(expectBudget);
List<string> expectList = expectConstraints.Length == 0 ? [] : [.. expectConstraints.Split('|')];
parsed.Constraints.Should().Equal(expectList);
}

/// <summary>生产 bug 修复(TASK065 第五波): 选项夹在目标中间 — 剥离选项 token 后拼接剩余文本为 objective。</summary>
[Fact]
public void ParseGoalArgs_InlineOptions_JoinsRemainingObjectiveText() {
var parsed = GoalCommand.ParseGoalArgs("重构模块A --constraint '不改API' 完成后通知 --budget 2000");

parsed.Objective.Should().Be("重构模块A 完成后通知", "选项夹在目标中间应剥离并拼接剩余文本");
parsed.Constraints.Should().Equal("不改API");
parsed.TokenBudget.Should().Be(2000);
}

[Fact]
public void NameAndUsage_MetadataShouldBeConsistent() {
var cmd = new GoalCommand();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace Composition.Tests.Commands;

/// <summary>
/// /proactive 命令单元测试 — 覆盖 on(含别名)激活/off(含别名)停用/pause/resume(含短别名)/
/// status 状态查询/未识别参数引导/服务缺失静默/元数据一致性
/// status 状态查询/未识别参数引导/服务缺失降级提示不误导(TASK065 生产 bug 修复)/元数据一致性
/// <para>ProactiveStateService 用 Moq 桩经 DI 注入</para>
/// </summary>
[Collection("command-output")]
Expand Down Expand Up @@ -122,21 +122,36 @@ public async Task Execute_UnrecognizedArg_ShowsErrorAndUsage() {
service.Verify(s => s.Activate(It.IsAny<string?>()), Times.Never, "未识别参数不应触发任何状态变更");
}

/// <summary>生产 bug 登记(TASK065 brain 域): IProactiveStateService 缺失时 OnEnabledAsync 的
/// "主动模式已激活" 提示在 proactiveService?.Activate() 安全调用之后仍无条件打印 —
/// 服务未注册却向用户宣称激活成功,输出与实际状态不符。本测试按当前实现固化行为
/// (提示输出 + Activate 未调用);修复后应改为断言提示不输出或改为服务不可用提示。</summary>
/// <summary>生产 bug 修复(TASK065 brain 域): IProactiveStateService 缺失时 OnEnabledAsync 的
/// "主动模式已激活"提示在 proactiveService?.Activate() 安全调用之后仍无条件打印 —
/// 服务未注册却向用户宣称激活成功,输出与实际状态不符。修复后:服务缺失输出降级提示,
/// 激活提示仅在服务成功路径输出。</summary>
[Fact]
public async Task Execute_WithoutService_ContinuesWithMisleadingActivationNotice() {
public async Task Execute_WithoutService_ShowsDegradedNoticeInsteadOfActivation() {
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 已登记,未擅自修复)");
result.ShouldContinue.Should().BeTrue("服务缺失时应静默降级而非崩溃");
output.Should().NotContain("主动模式已激活", "服务缺失时不应宣称激活成功");
output.Should().Contain("主动模式状态服务不可用", "服务缺失应输出降级提示");
}

/// <summary>对称缺陷(TASK065 brain 域): OnDisabledAsync 的"主动模式已停用"提示与激活提示
/// 同样在服务缺失时无条件打印。修复后停用提示同样仅在服务成功路径输出。</summary>
[Fact]
public async Task Execute_WithoutService_Off_ShowsDegradedNoticeInsteadOfDeactivation() {
var services = CommandTestHarness.CreateServices();
var context = CommandTestHarness.CreateContext(services, arguments: "off");

var (result, output) = await CommandTestHarness.CaptureOutputAsync(
() => new ProactiveCommand().ExecuteAsync(context));

result.ShouldContinue.Should().BeTrue("服务缺失时应静默降级而非崩溃");
output.Should().NotContain("主动模式已停用", "服务缺失时不应宣称停用成功");
output.Should().Contain("主动模式状态服务不可用", "服务缺失应输出降级提示");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace Composition.Tests.Commands;
/// <summary>
/// /sandbox-toggle 命令单元测试 — 覆盖 status 默认分支(禁用态/活动沙箱详情)/on+off 非交互确认拒绝降级/
/// switch(缺类型用法/无效类型/有效类型切换/管理器缺失)/exclude(缺参列表/路径不存在/新增持久化/
/// 重复路径幂等/配置服务缺失)/管理器缺失静默降级
/// 重复路径幂等/配置服务缺失)/管理器缺失静默降级/混合大小写路径保留与大写子命令分派(TASK065 第五波修复)
/// <para>ISandboxManager 与 IConfigurationService 经 DI 注入;确认走静态 Confirmation,
/// 测试环境非交互恒拒绝 — 断言以硬编码中文文案+参数不变量为主</para>
/// </summary>
Expand Down Expand Up @@ -195,7 +195,6 @@ public async Task Execute_Exclude_WithoutPath_ShowsCurrentExclusions() {
[Fact]
public async Task Execute_Exclude_MissingPath_ShowsPathNotFound() {
var config = new Mock<IConfigurationService>();
// 命令侧 GetNormalizedArgs().ToLowerInvariant() 会把路径参数一并小写化 — 用小写路径构造避免大小写歧义
var (cmd, _, configMock, context, _) = Arrange(
arguments: @"exclude c:\no-such-dir", configOverride: config);

Expand Down Expand Up @@ -255,6 +254,56 @@ public async Task Execute_Exclude_WithoutConfigService_DoesNotThrow() {
config.Verify(c => c.SetAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}

/// <summary>生产 bug 修复(TASK065 第五波): 整串 ToLowerInvariant 导致路径被小写化 —
/// 修复后仅命令词小写化分派,路径不存在提示应保留用户输入的原始大小写。</summary>
[Fact]
public async Task Execute_Exclude_MixedCasePath_PreservesOriginalCaseInPrompt() {
var config = new Mock<IConfigurationService>();
var (cmd, _, _, 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", "路径不存在提示应保留用户输入的原始大小写");
output.Should().NotContain(@"c:\no-such-dir", "不应出现整串小写化后的路径");
}

/// <summary>生产 bug 修复(TASK065 第五波): 混合大小写目录的排除持久化 —
/// 写入配置的完整路径应保留用户输入的原始大小写,而非小写化形态。</summary>
[Fact]
public async Task Execute_Exclude_MixedCaseDir_PersistsFullPathWithOriginalCase() {
var dir = Path.Combine(Path.GetTempPath(), "SB-Case-Keep");
var config = new Mock<IConfigurationService>();
config.Setup(c => c.GetAsync("sandbox.excludedPaths", It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once,
"持久化路径应保留用户输入的原始大小写");
output.Should().Contain(fullPath, "排除成功提示应含原始大小写完整路径");
}

/// <summary>回归保障: 修复仅小写化命令词用于分派 — 大写子命令应照常分派到 exclude 列表分支。</summary>
[Fact]
public async Task Execute_UpperCaseSubCommand_StillDispatches() {
var config = new Mock<IConfigurationService>();
config.Setup(c => c.GetAsync("sandbox.excludedPaths", It.IsAny<CancellationToken>()))
.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();
output.Should().Contain(@"C:\path-a", "大写子命令应照常小写化分派到 exclude");
output.Should().Contain("/sandbox-toggle exclude <path>");
}

[Fact]
public void NameAndUsage_MetadataShouldBeConsistent() {
var cmd = new SandboxToggleCommand();
Expand Down
32 changes: 31 additions & 1 deletion modules/composition/composition.tests/commands/VimCommandTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ namespace Composition.Tests.Commands;

/// <summary>
/// /vim 命令单元测试 — 覆盖 on/enable 启用(VimEngine.Enable+SetMode(Vim)+按键提示)/
/// off/disable 禁用/无参 toggle(Normal→Vim / Vim→Normal 互斥切换)/服务缺失降级不抛/
/// off/disable 禁用/无参 toggle(Normal→Vim / Vim→Normal 互斥切换)/
/// status 查询当前模式不切换(TASK065 生产 bug 修复)/服务缺失降级不抛/
/// 大小写别名归一/命令元数据
/// <para>IVimEngine 与 IEditorModeService 经 DI 注入;继承 ToggleCommandBase 三分支骨架</para>
/// </summary>
Expand Down Expand Up @@ -129,6 +130,35 @@ public async Task Execute_Toggle_WithoutModeService_FallsBackToNormalDefault() {
output.Should().Contain("编辑模式: Vim");
}

/// <summary>生产 bug 修复(TASK065 guard 域): /vim status 经 FromValue 解析为 ToggleAction.Status
/// 落入基类 default 分支,因 NullAction 未覆写(默认 Toggle)误触发 OnToggleAsync 切换模式。
/// 修复后 status 仅显示当前编辑模式,不触发引擎启停与模式切换。</summary>
[Fact]
public async Task Execute_Status_ShowsCurrentModeWithoutToggling() {
var (cmd, engine, modeService, context) = Arrange(arguments: "status", currentMode: EditorMode.Vim);

var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));

result.ShouldContinue.Should().BeTrue();
engine.Verify(e => e.Enable(), Times.Never, "status 不应启用 Vim 引擎");
engine.Verify(e => e.Disable(), Times.Never, "status 不应停用 Vim 引擎");
modeService.Verify(m => m.SetMode(It.IsAny<EditorMode>()), Times.Never, "status 不应切换编辑模式");
output.Should().Contain("编辑模式: Vim", "status 应显示当前编辑模式");
}

/// <summary>status 参数大小写归一 — 与 on/off 一致按 ToLowerInvariant/OrdinalIgnoreCase 匹配。</summary>
[Fact]
public async Task Execute_StatusUppercase_AlsoShowsModeWithoutToggling() {
var (cmd, engine, modeService, context) = Arrange(arguments: "STATUS", currentMode: EditorMode.Normal);

var (result, output) = await CommandTestHarness.CaptureOutputAsync(() => cmd.ExecuteAsync(context));

result.ShouldContinue.Should().BeTrue();
engine.Verify(e => e.Enable(), Times.Never, "STATUS 不应启用 Vim 引擎");
modeService.Verify(m => m.SetMode(It.IsAny<EditorMode>()), Times.Never, "STATUS 不应切换编辑模式");
output.Should().Contain("编辑模式: Normal (标准 readline 键绑定)", "STATUS 应显示当前编辑模式");
}

[Fact]
public void NameAndUsage_MetadataShouldBeConsistent() {
var cmd = new VimCommand();
Expand Down
Loading