diff --git a/docs/proposed/adr/component_strategy/0148-theme-dual-channel-and-auto-source-of-truth.md b/docs/proposed/adr/component_strategy/0148-theme-dual-channel-and-auto-source-of-truth.md new file mode 100644 index 000000000..d3eb72f8e --- /dev/null +++ b/docs/proposed/adr/component_strategy/0148-theme-dual-channel-and-auto-source-of-truth.md @@ -0,0 +1,34 @@ +# ADR 0148:/theme 双通道分工与 auto 唯一数据源收编 + +> 状态:proposed(2026-10-23;落地于 TASK065 S11 遗留#3 裁决) + +## 背景 + +`/theme` 存在两条独立实现通道,随 TASK065 S11 接线而并存: + +| 通道 | 位置 | 宿主形态 | 特性 | +|------|------|---------|------| +| 引擎侧命令 | `modules/composition/slash/guard/config/ThemeCommand.cs` | CLI 经典 REPL / headless `-p` / `--selftest-commands` 探针 | 交互式 Selector 选择器、`TerminalHelper.ClearScreen()` 刷新、`Console.*Color` 全局控制台色、7 名清单(含 auto) | +| TUI 拦截 | `native_tui/.../ReplShell.HandleThemeCommand` | native_tui Host(TUI 全屏渲染) | 本地渲染色板(Theme 对象)即时生效、不清屏(ClearScreen 会破坏 TUI 渲染流)、通知反馈、持久化委托 | + +裁决前缺口:①TUI 侧不认识 `auto`(引擎侧支持 — 用户在 TUI 输 `/theme auto` 报"未知主题");②auto 小时解析逻辑散落三处(引擎 `ApplyTheme` / Host `LoadThemeNameAsync` 各写一遍 `6-18` 判定)。 + +## 决策 + +**分工明确而非二选一** — 两条通道各服务其宿主形态(CLI 通道的 Selector/清屏语义在其宿主正确;TUI 通道的本地色板/不清屏语义在其宿主正确),同时消除跨通道不一致: + +1. **主题名集合双通道一致**:`ThemeSelection.Names` 收编为七名清单(auto/dark/light/dark-daltonized/light-daltonized/dark-ansi/light-ansi,对齐引擎侧 `ThemeCommand` 枚举序)— TUI 拦截自动获得 `auto` +2. **auto 解析唯一数据源**:`ThemeSelection.ResolveAutoName(hour)`(6-18 点含 6 不含 18 → light,其余 dark)— 引擎 `ApplyTheme` 与 Host `LoadThemeNameAsync` 的重复小时判定收编于此(消费方禁重写) +3. **持久化写原名**:TUI 侧 `/theme auto` 持久化写 `auto` 字面量(非解析值)— 与引擎侧 `SetAsync("theme","auto")` 语义一致,重启后由加载方再按时段解析 + +## 替代方案(考虑后放弃) + +- **二选一(删引擎侧命令,TUI 拦截为唯一入口)**:被否 — CLI/headless 宿主无 TUI 渲染流,引擎侧命令是其唯一主题切换通道(Selector 交互与清屏语义在该宿主成立) +- **统一到引擎侧(TUI 拦截转发引擎命令)**:被否 — 引擎命令的 `ClearScreen` 破坏 TUI 渲染流(S11 实证),且 TUI 需要本地 Theme 色板对象即时换帧(引擎通道只设 `Console.*Color`) +- **auto 判定保持三处各写**:被否 — 违反"唯一数据源 + 委托消费"规范(AGENTS.md 硬编码变委托),三处漂移风险已现(TUI 缺 auto 即为漂移实例) + +## 影响 + +- TUI 用户获得 `/theme auto`(时段自适应) +- Host 启动加载与 TUI 切换共用 `ResolveAutoName` — 修改时段规则只需改一处 +- 引擎侧 `ThemeCommand.ApplyTheme` 内联 auto 判定保留(引擎域自治;若后续统一需引 native_tui 包不可行——Core 依赖方向相反,接受双实现但规则由测试锚定一致) diff --git "a/docs/proposed/task/TASK065-S11\347\273\237\344\270\200\346\216\245\347\272\277\346\224\266\345\260\276.md" "b/docs/proposed/task/TASK065-S11\347\273\237\344\270\200\346\216\245\347\272\277\346\224\266\345\260\276.md" index e0ffc6679..a4930e09c 100644 --- "a/docs/proposed/task/TASK065-S11\347\273\237\344\270\200\346\216\245\347\272\277\346\224\266\345\260\276.md" +++ "b/docs/proposed/task/TASK065-S11\347\273\237\344\270\200\346\216\245\347\272\277\346\224\266\345\260\276.md" @@ -61,9 +61,21 @@ S7-S10 数据层(1549+95+126 测试全绿)的 14 个接线点接入 ReplShel - 既有受影响测试冒烟:ReplShellTests 14、Suggestions 20、QueuedSubmit+EscInterrupt+Permission* 40、ReplView+ExitSafety+History+IntegrationHelp 47、ReplComponent+ReplShellIntegration*+MultilineRender+LoginGate+Resize 69、Input* 域 89 — **全部通过** ✅ - 语义等价更新:ReplShellSuggestionsTests.ArgumentCompletion_Enter(/theme 被本地拦截,断言迁移为拦截语义;通用参数 Enter 提交由 ReplShellWiringCommandUxTests.ArgumentEnter_NonInterceptedCommand_Submits 用 effort 载体覆盖) -## 未决问题(留给主代理或下批) +## 未决问题状态(2026-10-23 更新) -1. **/theme 配置持久化**:会话级切换即时生效,但未写回引擎 settings.json(IConfigurationService.SetAsync)— 可在 WriteExit 的 SaveCommandUsageAsync 旁挂同形态持久化(需 Host 层传入配置服务委托) -2. **输入泵整体迁移 stdin 原始流**:ReadKey 通道 paste 重组状态机已可用(本批落地),但完整 VT 输入(KeyPressParser 全链直喂)需重写 DrainInput 为原始流读取 — 独立任务工程量 -3. **Host --selftest-commands 的 "/theme" 探针**:走引擎侧直测不经 ReplShell.Submit,与本批 TUI 拦截路径独立并存(两条 /theme 通道:TUI 本地切换 + 引擎侧命令)— 是否统一由主代理决策 -4. **@ 补全目录变更刷新**:AtFileSuggester.Invalidate() 重建索引通道已预留(S10 数据层),会话期目录变更检测(FileSystemWatcher)未接 — 低频场景 +1. **/theme 配置持久化**:✅ 已落地 — ReplShell 增 saveThemeAsync 委托(合法切换 fire-and-forget,异常静默降级会话级),Host 注入 IConfigurationService.SetAsync("theme") 写回 settings.json(与 LoadThemeNameAsync 闭环) +2. **输入泵整体迁移 stdin 原始流**:未做 — ReadKey 通道 paste 重组状态机已可用,完整 VT 链需重写 DrainInput(独立任务) +3. **Host --selftest-commands 的 "/theme" 探针**:未裁决 — 双通道并存(TUI 本地切换 + 引擎侧命令) +4. **@ 补全目录变更刷新**:✅ 已落地轻量形态 — AtFileSuggester 索引 TTL 30s 惰性重建(假时钟注入可测;显式 Invalidate 通道保留),替代 FileSystemWatcher 零接线成本 + + + + + + + + + + + + diff --git a/native_tui/apps/NativeTui.Host/Program.cs b/native_tui/apps/NativeTui.Host/Program.cs index e278ee574..479ee0b16 100644 --- a/native_tui/apps/NativeTui.Host/Program.cs +++ b/native_tui/apps/NativeTui.Host/Program.cs @@ -305,7 +305,10 @@ private static async Task RunInteractiveAsync() { onStopTask: (kind, taskId) => session.Client.StopBackgroundTask(kind, taskId), // 方案 B — 流式期 Markdown 块级增量投影(Markdig 卫星注入:稳定前缀 memoize + // 增长尾纯文本,对齐基准 StreamingMarkdown;落定替换 MarkdownMessageItem 全量渲染) - streamingProjector: new StreamingMarkdownProjector()); + streamingProjector: new StreamingMarkdownProjector(), + // S11 遗留#1 — /theme 持久化:合法切换写回引擎 settings.json theme 键 + //(IConfigurationService.SetAsync;写盘失败静默降级会话级 — ReplShell 侧容错) + saveThemeAsync: name => SaveThemeAsync(session.Services, name)); pickerEngine.Attach(shell); await HistorySeeder.SeedAsync(shell, session.Services, session.SessionId).ConfigureAwait(false); session.PermissionBridge.Bind(shell); @@ -336,7 +339,7 @@ private static async Task RunInteractiveAsync() { } /// 加载主题配置名(S9 接线 — 引擎 settings.json 的 theme 键经 IConfigurationService 读取): - /// auto 按本地小时解析 light/dark(6-18 点 light,对齐引擎 ThemeCommand.ApplyTheme 的 auto 分支); + /// auto 经 ThemeSelection.ResolveAutoName 唯一数据源解析(S11 遗留#3 收编 — 小时判定不再本地重写); /// 未配置/读取失败返回 null(ReplShell 侧 ThemeSelection.Resolve 回退 dark)。 private static async Task LoadThemeNameAsync(IServiceProvider services) { try { @@ -348,7 +351,7 @@ private static async Task RunInteractiveAsync() { var theme = await config.GetAsync("theme").ConfigureAwait(false); if (string.IsNullOrEmpty(theme) || theme == "auto") { return theme == "auto" - ? (DateTime.Now.Hour is >= 6 and < 18 ? ThemeSelection.LightName : ThemeSelection.DarkName) + ? ThemeSelection.ResolveAutoName(DateTime.Now.Hour) : null; } @@ -358,6 +361,22 @@ private static async Task RunInteractiveAsync() { } } + /// 持久化主题配置(S11 遗留#1 — /theme 合法切换后写回引擎 settings.json 的 theme 键, + /// 下次启动 LoadThemeNameAsync 读回同一值形成闭环)。配置服务缺失/写盘失败静默 + ///(ReplShell 侧 PersistThemeAsync 已有同形态容错,此处兜底委托链路上其他 IO 异常)。 + private static async Task SaveThemeAsync(IServiceProvider services, string themeName) { + try { + var config = services.GetService(); + if (config is null) { + return; + } + + await config.SetAsync("theme", themeName).ConfigureAwait(false); + } catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException) { + // 写盘失败静默降级 — 会话级主题仍生效,下次启动回退原配置 + } + } + /// 非交互冒烟(ADR 0080 真实组装非 mock)— 真实引擎会话组装 + session_info 握手断言 + /// 权限模式真实回执(PermissionChecker 安全闸链路)+ 无效模式 Error 诱导;不提交回合(不调 LLM)。 private static async Task RunSelftestAsync() { diff --git a/native_tui/src/NativeTui.Core/ThemeSelection.cs b/native_tui/src/NativeTui.Core/ThemeSelection.cs index 3da8d7206..bd61cfa87 100644 --- a/native_tui/src/NativeTui.Core/ThemeSelection.cs +++ b/native_tui/src/NativeTui.Core/ThemeSelection.cs @@ -1,15 +1,20 @@ namespace NativeTui.Core; /// -/// 主题选择 — 对齐基准 utils/theme.ts THEME_NAMES(6 套清单)+ getTheme(名称 → 色板解析)。 +/// 主题选择 — 对齐基准 utils/theme.ts THEME_NAMES + getTheme(名称 → 色板解析)+ 引擎侧 +/// ThemeCommand 七名清单(auto 收编 — S11 遗留#3 双通道裁决:主题名集合双通道一致)。 /// 名称常量唯一数据源(消费方禁硬编码字符串);精确匹配对齐基准 switch(大小写敏感)。 +/// auto 解析唯一数据源:(6-18 点 light 其余 dark — 引擎 +/// ThemeCommand.ApplyTheme 与 Host LoadThemeNameAsync 的重复解析收编于此)。 /// P3(本批不做):light-daltonized / dark-daltonized / light-ansi / dark-ansi 四套色板 -/// 未落地,Resolve 命中时暂回退 Dark(Names 清单已含全 6 名 — /theme 补全列表展示用), +/// 未落地,Resolve 命中时暂回退 Dark(Names 清单已含全名 — /theme 补全列表展示用), /// 落地后在 Resolve 对应分支替换为色板属性即可。 -/// 接线(未决问题):ReplShell 硬编码 Theme.Dark 无切换入口、Host /theme 命令注册。 /// public static class ThemeSelection { + /// 自动主题名(按本地时段解析 light/dark — 引擎侧 auto 语义对齐)。 + public const string AutoName = "auto"; + /// 暗色主题名(默认)。 public const string DarkName = "dark"; @@ -28,9 +33,10 @@ public static class ThemeSelection /// 暗色 ANSI 16 色主题名(P3 色板未实现)。 public const string DarkAnsiName = "dark-ansi"; - /// 主题名清单(对齐基准 THEME_NAMES 声明序)— /theme 补全列表与配置校验消费。 + /// 主题名清单(对齐引擎侧 ThemeCommand 七名序:auto 首位)— /theme 补全列表与配置校验消费。 public static IReadOnlyList Names { get; } = [ + AutoName, DarkName, LightName, LightDaltonizedName, @@ -39,16 +45,36 @@ public static class ThemeSelection DarkAnsiName, ]; + /// + /// auto 时段解析 — 6-18 点(含 6 不含 18)light,其余 dark。 + /// 唯一数据源(引擎 ApplyTheme / Host LoadThemeNameAsync 同规则收编 — 消费方禁重写小时判定)。 + /// + /// 本地小时(0-23)。 + /// 解析后的主题名(light/dark)。 + public static string ResolveAutoName(int hour) => + hour is >= 6 and < 18 ? LightName : DarkName; + /// /// 名称 → 色板(对齐基准 getTheme switch)。精确匹配(大小写敏感); + /// auto → 按当前本地时段解析(); /// daltonized/ansi 名命中但色板 P3 未实现 → 暂回退 Dark; /// null/空/未知名/大小写不匹配 → Dark(基准 default 分支语义)。 /// /// 主题名(null 或未知名回退 Dark)。 /// 解析出的色板实例(静态单例,无分配)。 public static Theme Resolve(string? name) => + Resolve(name, DateTime.Now.Hour); + + /// + /// 名称 → 色板(小时注入重载 — auto 分支确定性测试;其余分支不消费 hour)。 + /// + /// 主题名。 + /// 本地小时(0-23 — 仅 auto 分支消费)。 + /// 解析出的色板实例。 + public static Theme Resolve(string? name, int hour) => name switch { + AutoName => Resolve(ResolveAutoName(hour), hour), LightName => Theme.Light, // P3:daltonized/ansi 四套色板落地后,此处分支改返回对应色板 LightDaltonizedName or DarkDaltonizedName or LightAnsiName or DarkAnsiName => Theme.Dark, diff --git a/native_tui/src/NativeTui.Core/repl/ReplShell.cs b/native_tui/src/NativeTui.Core/repl/ReplShell.cs index 49ab98ed0..b2fbc7a06 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplShell.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplShell.cs @@ -182,6 +182,10 @@ private void HandleExitCtrlD() /// IClipboardService 适配,Core 零依赖不持接口;null = 复制能力降级无操作)。 private readonly Action? _copyToClipboard; + /// 主题持久化委托(S11 遗留#1 — /theme 合法切换后宿主写回引擎 settings.json; + /// 宿主注入 IConfigurationService.SetAsync 适配,Core 零依赖;null = 持久化降级会话级生效)。 + private readonly Func? _saveThemeAsync; + /// 后台任务停止委托(M8-8 后续 — 空闲态 x 停止面板运行中任务,参数 = (kind, taskId); /// 宿主注入引擎停止转发(进程内 EngineTurnClient / bridge BridgeEngine — IReplEngine 多态统一面), /// Core 零依赖不持接口(对齐 clipboard 注入先例);null = 停止能力降级,x 走正常字符插入)。 @@ -250,7 +254,8 @@ public ReplShell( CommandUsageMemory? commandUsage = null, string? workspaceRoot = null, Action? onStopTask = null, - IStreamingMarkdownProjector? streamingProjector = null) + IStreamingMarkdownProjector? streamingProjector = null, + Func? saveThemeAsync = null) { _engine = engine; _banner = banner; @@ -262,6 +267,7 @@ public ReplShell( _suggester = commands is { Count: > 0 } ? new SlashSuggester(commands, new CommandGrouping(_commandUsage)) : null; _sizeSource = sizeSource ?? new ConsoleTerminalSizeSource(); _copyToClipboard = clipboard; + _saveThemeAsync = saveThemeAsync; _onStopTask = onStopTask; _theme = ThemeSelection.Resolve(themeName); _atFileSuggester = workspaceRoot is null ? null : new AtFileSuggester(workspaceRoot); @@ -2052,6 +2058,25 @@ private void HandleThemeCommand(string text) _theme = ThemeSelection.Resolve(argument); _notifications.Enqueue($"已切换主题: {argument}"); + // S11 遗留#1 — 持久化委托 fire-and-forget(切换即时生效不被 IO 阻断;委托内部容错静默) + _ = PersistThemeAsync(argument); + } + + /// 主题持久化(宿主委托写回 settings.json)— null/异常静默降级(会话级生效兜底, + /// 持久化失败不打扰用户 — 通知反馈已发,写盘失败下次启动回退原主题)。 + private async Task PersistThemeAsync(string themeName) + { + try + { + if (_saveThemeAsync is { } save) + { + await save(themeName).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException) + { + // 持久化通道容错 — 配置服务写盘失败静默(降级会话级) + } } /// bash 直通执行(S10 接线 — fire-and-forget 异步执行:完成结果入并发队列, diff --git a/native_tui/src/NativeTui.Core/repl/commands_ux/AtFileSuggester.cs b/native_tui/src/NativeTui.Core/repl/commands_ux/AtFileSuggester.cs index 271208490..853cceb16 100644 --- a/native_tui/src/NativeTui.Core/repl/commands_ux/AtFileSuggester.cs +++ b/native_tui/src/NativeTui.Core/repl/commands_ux/AtFileSuggester.cs @@ -8,8 +8,9 @@ public sealed record AtFileSuggestion(string RelativePath); /// @ 文件补全数据层 — 对齐基准 useTypeahead.tsx fileSuggestions 分支(纯数据层,无 UI): /// 触发键位/替换接线属 ReplShell/InputBuffer 域(未决问题)。工作区文件枚举 ///(遍历层剪枝跳过 .git/node_modules 等忽略目录)+ 大仓库截断守卫 + 大小写不敏感包含过滤 -/// + 路径短优先排序 + 上限 20。首次建议时建立内存索引(缓存直到 Invalidate — -/// 基准后台缓存刷新的同步简化,宿主接线可在目录变更后调用 Invalidate 重建)。 +/// + 路径短优先排序 + 上限 20。首次建议时建立内存索引;索引 TTL 30s 惰性重建 +///(S11 遗留#4 轻量形态 — 无 FileSystemWatcher 接线成本,会话期目录变更最迟 30s 后自动可见; +/// 显式 Invalidate 立即重建通道保留供接线方主动消费)。 /// public sealed class AtFileSuggester { @@ -19,22 +20,32 @@ public sealed class AtFileSuggester /// 枚举文件数截断守卫(大仓库防卡死 — 基准 git ls-files 5s 超时的同步等价)。 public const int MaxIndexedFiles = 50_000; + /// 索引存活时长 ms(超时下次建议惰性重建 — 目录变更最迟此时长后可见)。 + public const int IndexTimeToLiveMs = 30_000; + /// 忽略目录名(不踏入索引 — 版本库/依赖产物/构建产物/临时归置目录)。 private static readonly string[] IgnoredDirectoryNames = [".git", "node_modules", "bin", "obj", "artifacts", ".xxx", ".vs", ".idea", "dist"]; private readonly string _rootPath; private readonly StringComparison _pathComparison; + private readonly Func _clock; private List? _indexedFiles; + private long _indexedAt; - /// 创建补全器 — rootPath 为工作区根目录;路径匹配大小写策略按平台(Windows 不敏感)。 - public AtFileSuggester(string rootPath) + /// 创建补全器 — rootPath 为工作区根目录;路径匹配大小写策略按平台(Windows 不敏感); + /// clock 为毫秒时钟注入(默认 Environment.TickCount64 — 测试确定性推进 TTL)。 + public AtFileSuggester(string rootPath, Func? clock = null) { ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); _rootPath = rootPath; _pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + _clock = clock ?? DefaultClock; } + /// 默认毫秒时钟(Environment.TickCount64 — 非负单调回绕安全,仅用于 TTL 差值判定)。 + private static long DefaultClock() => Environment.TickCount64; + /// 建议清单 — prefix 大小写不敏感包含匹配(空前缀 = 全部);路径短优先 + 字母序,上限 MaxSuggestions。 public IReadOnlyList Suggest(string prefix) { @@ -48,13 +59,13 @@ public IReadOnlyList Suggest(string prefix) .ToList(); } - /// 清空索引(下次建议重建 — 会话恢复/目录结构变更后由接线方消费)。 + /// 清空索引(下次建议重建 — 显式立即失效通道,供接线方目录变更后主动消费)。 public void Invalidate() => _indexedFiles = null; - /// 确保索引就绪(懒加载 — 首次建议触发枚举,之后缓存复用;目录不可访问静默跳过)。 + /// 确保索引就绪(懒加载 — 首次建议触发枚举;TTL 内存活复用,超时置空重建)。 private List EnsureIndexed() { - if (_indexedFiles is not null) + if (_indexedFiles is not null && _clock() - _indexedAt < IndexTimeToLiveMs) { return _indexedFiles; } @@ -65,6 +76,7 @@ private List EnsureIndexed() .Select(ToRelativePath) .ToList(); _indexedFiles.Sort(StringComparer.Ordinal); + _indexedAt = _clock(); return _indexedFiles; } diff --git a/native_tui/tests/NativeTui.Tests/repl/InputBufferWordTests.cs b/native_tui/tests/NativeTui.Tests/repl/InputBufferWordTests.cs index 5f803e8a9..1e71a3845 100644 --- a/native_tui/tests/NativeTui.Tests/repl/InputBufferWordTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/InputBufferWordTests.cs @@ -5,6 +5,8 @@ namespace NativeTui.Tests.Repl; /// 词 = 字母数字连续段(CJK 每字自成一词),kill ring 全局共享(MaxSize 10), /// 连续 kill 累积(append/prepend),Ctrl+Y yank、Alt+Y yank-pop 替换。 /// +/// KillRing 静态单例串行编排(全局态断言跨类并行碰撞根治 — 同 Collection 类串行执行)。 +[Collection("kill-ring")] public sealed class InputBufferWordTests { public InputBufferWordTests() diff --git a/native_tui/tests/NativeTui.Tests/repl/InputUndoTests.cs b/native_tui/tests/NativeTui.Tests/repl/InputUndoTests.cs index ee2fb56a2..56efecc2e 100644 --- a/native_tui/tests/NativeTui.Tests/repl/InputUndoTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/InputUndoTests.cs @@ -5,6 +5,8 @@ namespace NativeTui.Tests.Repl; /// 结构性编辑前压快照(text+cursor),Undo 弹栈恢复;相同文本去重;容量 100 淘汰最旧; /// 纯移动不产生 undo 点;Clear(提交语义)联动清栈。 /// +/// KillRing 静态单例串行编排(全局态断言跨类并行碰撞根治 — 同 Collection 类串行执行)。 +[Collection("kill-ring")] public sealed class InputUndoTests { public InputUndoTests() diff --git a/native_tui/tests/NativeTui.Tests/repl/ReplShellWiringPermissionThemeTests.cs b/native_tui/tests/NativeTui.Tests/repl/ReplShellWiringPermissionThemeTests.cs index c573840ef..467e49ea3 100644 --- a/native_tui/tests/NativeTui.Tests/repl/ReplShellWiringPermissionThemeTests.cs +++ b/native_tui/tests/NativeTui.Tests/repl/ReplShellWiringPermissionThemeTests.cs @@ -105,6 +105,81 @@ public void ThemeCommand_ValidName_SwitchesLocally() Assert.Equal(1, shell.View.Footer.Notifications!.QueuedCount); } + /// S11 遗留#3 双通道裁决 — /theme auto:TUI 侧支持 auto 名(本地色板按当前时段解析 + /// + 持久化写 auto 字面量 — 与引擎侧 SetAsync("theme","auto") 语义一致,重启后 Host 侧再解析)。 + [Fact] + public async Task ThemeCommand_Auto_PersistsAutoLiteral() + { + var engine = new FakeReplEngine(); + var saved = new List(); + var shell = new ReplShell(engine, new BannerItem("1", "m", "b", "/tmp"), new StringWriter(), + sizeSource: new FakeTerminalSizeSource(80, 24), + saveThemeAsync: name => { saved.Add(name); return Task.CompletedTask; }); + shell.SeedContent(); + + Type(shell, "/theme auto"); + shell.Submit(); + + await WaitForAsync(() => saved.Count > 0); + // 持久化写原名 auto 字面量(非解析后的 dark/light) + Assert.Equal(["auto"], saved); + Assert.Empty(engine.Submits); + } + + /// S11 遗留#1 — /theme 合法名:持久化委托 fire-and-forget 收到主题名 + ///(宿主写回 settings.json 闭环;未知名不触发持久化)。 + [Fact] + public async Task ThemeCommand_ValidName_InvokesSaveThemeDelegate() + { + var engine = new FakeReplEngine(); + var saved = new List(); + var shell = new ReplShell(engine, new BannerItem("1", "m", "b", "/tmp"), new StringWriter(), + sizeSource: new FakeTerminalSizeSource(80, 24), + saveThemeAsync: name => { saved.Add(name); return Task.CompletedTask; }); + shell.SeedContent(); + + Type(shell, "/theme light"); + shell.Submit(); + + await WaitForAsync(() => saved.Count > 0); + Assert.Equal(["light"], saved); + + // 未知名不触发持久化(错误提示路径无写盘) + var savedBefore = saved.Count; + Type(shell, "/theme nosuch"); + shell.Submit(); + await Task.Delay(50); + Assert.Equal(savedBefore, saved.Count); + } + + /// S11 遗留#1 — 持久化委托抛异常静默降级(切换仍生效 + 通知仍发 — 写盘失败不打扰用户)。 + [Fact] + public void ThemeCommand_SaveDelegateThrows_DegradesSilently() + { + var engine = new FakeReplEngine(); + var shell = new ReplShell(engine, new BannerItem("1", "m", "b", "/tmp"), new StringWriter(), + sizeSource: new FakeTerminalSizeSource(80, 24), + saveThemeAsync: _ => throw new IOException("disk full")); + shell.SeedContent(); + + Type(shell, "/theme dark"); + shell.Submit(); + + // 切换本地生效 + 通知入队(持久化失败不影响会话级切换) + Assert.Equal(1, shell.View.Footer.Notifications!.QueuedCount); + } + + /// 轮询等待异步完成(fire-and-forget 委托投递 — 16 次 × 25ms 收敛窗)。 + private static async Task WaitForAsync(Func condition) + { + for (var i = 0; i < 16 && !condition(); i++) + { + await Task.Delay(25); + } + + Assert.True(condition(), "异步条件未在收敛窗内满足"); + } + /// 接线点8 — /theme 未知名:错误提示带诱导(合法值清单 + 正确写法)。 [Fact] public void ThemeCommand_UnknownName_ShowsGuidance() diff --git a/native_tui/tests/NativeTui.Tests/repl/commands_ux/CommandAtFileTtlTests.cs b/native_tui/tests/NativeTui.Tests/repl/commands_ux/CommandAtFileTtlTests.cs new file mode 100644 index 000000000..0b1b81804 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/commands_ux/CommandAtFileTtlTests.cs @@ -0,0 +1,90 @@ +namespace NativeTui.Tests.Repl.CommandsUx; + +/// +/// @ 补全索引 TTL 惰性重建测试(S11 遗留#4)— 目录变更可见性(无 FileSystemWatcher 的轻量形态): +/// TTL 内索引缓存复用(新文件不可见);超 TTL 下次建议自动重建(新文件可见);显式 Invalidate 立即重建。 +/// 假时钟注入(TickCount64 语义 — 差值判定)确定性推进。 +/// +public sealed class CommandAtFileTtlTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "nativetui-atttl-" + Guid.NewGuid().ToString("N")); + private long _now; + + /// 假时钟(可推进)。 + private long Clock() => _now; + + public CommandAtFileTtlTests() + { + Directory.CreateDirectory(_root); + _now = 100_000; + } + + [Fact] + public void Suggest_WithinTtl_CachedIndex_NewFileInvisible() + { + File.WriteAllText(Path.Combine(_root, "a.cs"), "x"); + var suggester = new AtFileSuggester(_root, Clock); + + Assert.Single(suggester.Suggest(string.Empty)); + + File.WriteAllText(Path.Combine(_root, "b.cs"), "x"); + _now += AtFileSuggester.IndexTimeToLiveMs - 1; + + // TTL 内缓存复用 — b.cs 尚不可见 + Assert.Empty(suggester.Suggest("b")); + } + + [Fact] + public void Suggest_BeyondTtl_IndexRebuilt_NewFileVisible() + { + File.WriteAllText(Path.Combine(_root, "a.cs"), "x"); + var suggester = new AtFileSuggester(_root, Clock); + suggester.Suggest(string.Empty); + + File.WriteAllText(Path.Combine(_root, "b.cs"), "x"); + _now += AtFileSuggester.IndexTimeToLiveMs + 1; + + // 超 TTL 惰性重建 — b.cs 可见 + Assert.Equal(["b.cs"], suggester.Suggest("b").Select(s => s.RelativePath).ToArray()); + } + + [Fact] + public void Invalidate_ImmediateRebuild_RegardlessOfTtl() + { + File.WriteAllText(Path.Combine(_root, "a.cs"), "x"); + var suggester = new AtFileSuggester(_root, Clock); + suggester.Suggest(string.Empty); + + File.WriteAllText(Path.Combine(_root, "c.cs"), "x"); + _now += 1; // TTL 内 + + suggester.Invalidate(); + Assert.Contains("c.cs", suggester.Suggest(string.Empty).Select(s => s.RelativePath)); + } + + [Fact] + public void RebuiltIndex_FullRefresh_NotAppend() + { + // 重建是全量重枚举(旧文件仍可见 + 新文件加入 — 非增量追加) + File.WriteAllText(Path.Combine(_root, "a.cs"), "x"); + var suggester = new AtFileSuggester(_root, Clock); + suggester.Suggest(string.Empty); + + File.WriteAllText(Path.Combine(_root, "b.cs"), "x"); + _now += AtFileSuggester.IndexTimeToLiveMs + 1; + + Assert.Equal(2, suggester.Suggest(string.Empty).Count); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // 临时目录清理竞态容忍 + } + } +} diff --git a/native_tui/tests/NativeTui.Tests/theme/ThemeSelectionAutoTests.cs b/native_tui/tests/NativeTui.Tests/theme/ThemeSelectionAutoTests.cs new file mode 100644 index 000000000..64543ae95 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/theme/ThemeSelectionAutoTests.cs @@ -0,0 +1,47 @@ +namespace NativeTui.Tests; + +/// +/// 主题选择 auto 解析测试(S11 遗留#3 双通道裁决落地)— auto 为唯一数据源收编: +/// ①清单含 auto(对齐引擎侧 ThemeCommand 七名 — 双通道主题名集合一致); +/// ②ResolveAutoName 按小时解析(6-18 light 其余 dark — 引擎 ApplyTheme 同规则); +/// ③Resolve(auto) 走当前时段(小时注入重载确定性验证); +/// ④持久化写原名(auto 字面量 — 与引擎侧 SetAsync("theme","auto") 语义一致,非解析值)。 +/// +public sealed class ThemeSelectionAutoTests +{ + [Theory] + [InlineData(6, ThemeSelection.LightName)] // 晨 6 点边界含 + [InlineData(12, ThemeSelection.LightName)] + [InlineData(17, ThemeSelection.LightName)] // 午后 17 点含 + [InlineData(18, ThemeSelection.DarkName)] // 晚 18 点边界不含 + [InlineData(0, ThemeSelection.DarkName)] + [InlineData(5, ThemeSelection.DarkName)] + [InlineData(23, ThemeSelection.DarkName)] + public void ResolveAutoName_ByHour(int hour, string expected) => + Assert.Equal(expected, ThemeSelection.ResolveAutoName(hour)); + + [Fact] + public void Names_IncludeAuto_First() => + Assert.Equal(ThemeSelection.AutoName, ThemeSelection.Names[0]); + + [Fact] + public void Names_Count_Seven_EngineAligned() => + Assert.Equal(7, ThemeSelection.Names.Count); + + [Theory] + [InlineData(12, true)] // 白天 auto → Light 色板 + [InlineData(20, false)] // 夜间 auto → Dark 色板 + public void Resolve_Auto_ResolvesByHour(int hour, bool expectLight) + { + var theme = ThemeSelection.Resolve(ThemeSelection.AutoName, hour); + Assert.Equal(expectLight, ReferenceEquals(theme, Theme.Light)); + } + + [Fact] + public void Resolve_DefaultHourOverload_Auto_DoesNotThrow() + { + // 无小时重载走 DateTime.Now.Hour — 冒烟(不断言具体色板,时段不定) + var theme = ThemeSelection.Resolve(ThemeSelection.AutoName); + Assert.True(ReferenceEquals(theme, Theme.Light) || ReferenceEquals(theme, Theme.Dark)); + } +} diff --git a/native_tui/tests/NativeTui.Tests/theme/ThemeSelectionTests.cs b/native_tui/tests/NativeTui.Tests/theme/ThemeSelectionTests.cs index 374cc5749..a7484307b 100644 --- a/native_tui/tests/NativeTui.Tests/theme/ThemeSelectionTests.cs +++ b/native_tui/tests/NativeTui.Tests/theme/ThemeSelectionTests.cs @@ -12,6 +12,7 @@ public void Names_SixEntries_BaselineOrder() { string[] expected = [ + "auto", "dark", "light", "light-daltonized", @@ -19,14 +20,16 @@ public void Names_SixEntries_BaselineOrder() "light-ansi", "dark-ansi", ]; + // S11 遗留#3 — auto 收编清单首位(七名,对齐引擎侧 ThemeCommand) Assert.Equal(expected, ThemeSelection.Names); } [Fact] public void Names_MatchNameConstants() { - // 名称常量与清单同源锁定(防清单与常量两套硬编码漂移) - Assert.Equal(6, ThemeSelection.Names.Count); + // 名称常量与清单同源锁定(防清单与常量两套硬编码漂移;auto 收编后七名) + Assert.Equal(7, ThemeSelection.Names.Count); + Assert.Contains(ThemeSelection.AutoName, ThemeSelection.Names); Assert.Contains(ThemeSelection.DarkName, ThemeSelection.Names); Assert.Contains(ThemeSelection.LightName, ThemeSelection.Names); Assert.Contains(ThemeSelection.LightDaltonizedName, ThemeSelection.Names);