diff --git a/modules/abstractions/abstractions/Abstractions.csproj b/modules/abstractions/abstractions/Abstractions.csproj
index 3365d51fb5..c33ee7e78e 100644
--- a/modules/abstractions/abstractions/Abstractions.csproj
+++ b/modules/abstractions/abstractions/Abstractions.csproj
@@ -10,6 +10,7 @@
+
diff --git a/modules/apps/cli/core/interaction/QuestionTextSanitizer.cs b/modules/apps/cli/core/interaction/QuestionTextSanitizer.cs
new file mode 100644
index 0000000000..7af707c5de
--- /dev/null
+++ b/modules/apps/cli/core/interaction/QuestionTextSanitizer.cs
@@ -0,0 +1,186 @@
+namespace JoinCode.Cli.Interaction;
+
+///
+/// 问题文本规整器 — AI 生成文本(question/options/description)入终端前的统一清洗与折行:
+/// ①:行尾归一(CRLF/裸 CR/病态 \r 段 → 单 \n,AI 输出混入 CR 是超长换行根因)、
+/// ESC 剥除(防终端二次解释 ANSI)、NUL 等控制字符剥除、TAB 转空格(对齐稳定);
+/// ②:按显示宽度词边界折行(CJK 宽字符 2 列计)、悬挂缩进对齐、超宽词硬切防单行超屏。
+/// 纯函数零状态 — 终端交互与 TUI 选项渲染共用同一口径。
+///
+public static class QuestionTextSanitizer {
+ ///
+ /// 清洗文本 — 行尾归一(\r 段折叠单 \n)+ 剥控制字符(ESC/NUL 等 <0x20 除 \n)+ TAB 转空格。
+ /// 顺序扫描单遍完成(\r 段贪吃 + 段后无 \n 自补一个 — CR 语义即换行)。
+ ///
+ /// 原始文本(AI 生成,可能混入 CR/控制字符)。
+ /// 干净文本(行尾统一 \n,无 ESC/控制字符)。
+ public static string Sanitize(string text) {
+ if (text.Length == 0)
+ return text;
+
+ var builder = new StringBuilder(text.Length);
+ var index = 0;
+ while (index < text.Length) {
+ var ch = text[index];
+ if (ch == '\r') {
+ // \r 段贪吃(\r\r\n 等病态混合折叠为单换行;\n\r 混合防双换行 — 尾已是 \n 不补)
+ while (index < text.Length && text[index] == '\r') {
+ index++;
+ }
+
+ if (index < text.Length && text[index] == '\n') {
+ index++;
+ }
+
+ if (builder.Length == 0 || builder[^1] != '\n') {
+ builder.Append('\n');
+ }
+
+ continue;
+ }
+
+ if (ch == '\n') {
+ builder.Append('\n');
+ index++;
+ continue;
+ }
+
+ if (ch == '\t') {
+ builder.Append(' ');
+ index++;
+ continue;
+ }
+
+ if (ch < ' ') {
+ // C0 控制字符剥除(ESC 防终端二次解释 ANSI、NUL 等)
+ index++;
+ continue;
+ }
+
+ builder.Append(ch);
+ index++;
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ /// 按显示宽度折行 — 段落(\n)保留独立折行;空格分词词边界换行;CJK 宽字符按 2 列计;
+ /// 续行悬挂缩进 indent 空格(可用宽度 = width - indent);超宽词按可用宽度硬切。
+ /// 宽度钳制:width ≤ indent 或 ≤0 返回单行原文(防退化)。
+ ///
+ /// 已清洗文本(\n 行尾)。
+ /// 目标显示宽度(显示列)。
+ /// 续行悬挂缩进列数。
+ /// 折行结果(每元素 ≤ width 显示列;空文本单空行)。
+ public static IReadOnlyList Wrap(string text, int width, int indent) {
+ if (width <= indent || width <= 0) {
+ return [text];
+ }
+
+ var lines = new List();
+ foreach (var segment in text.Split('\n')) {
+ WrapSegment(segment, width, indent, lines);
+ }
+
+ return lines.Count > 0 ? lines : [string.Empty];
+ }
+
+ /// 单段折行 — 词序列逐词装行(首行与续行同内容宽 available,续行前置 indent 悬挂缩进);
+ /// 超宽词硬切后并入装行流程;段恰装满时尾空行不输出(空段保留单空行 — 段落边界语义)。
+ private static void WrapSegment(string segment, int width, int indent, List lines) {
+ var available = width - indent;
+ var current = new StringBuilder();
+ var currentWidth = 0;
+ var pendingIndent = false;
+ foreach (var word in SplitWords(segment)) {
+ if (DisplayWidth(word) > available) {
+ FlushCurrent();
+ AppendHardChunks(word, available, indent, lines, pendingIndent ? indent : 0);
+ pendingIndent = true;
+ continue;
+ }
+
+ var separatorWidth = currentWidth == 0 ? 0 : 1;
+ if (currentWidth + separatorWidth + DisplayWidth(word) > available) {
+ FlushCurrent();
+ }
+
+ if (currentWidth == 0 && pendingIndent) {
+ current.Append(' ', indent);
+ }
+
+ if (currentWidth > 0) {
+ current.Append(' ');
+ currentWidth++;
+ }
+
+ current.Append(word);
+ currentWidth += DisplayWidth(word);
+ }
+
+ if (current.Length > 0 || lines.Count == 0) {
+ lines.Add(current.ToString());
+ }
+
+ void FlushCurrent() {
+ if (current.Length > 0) {
+ lines.Add(current.ToString());
+ current.Clear();
+ currentWidth = 0;
+ pendingIndent = true;
+ }
+ }
+ }
+
+ /// 超宽词硬切 — 按可用宽度逐片切(首片前缀对齐前文形态;宽字符不跨片边界:
+ /// 片尾恰逢宽字符占满则该片提前收口)。
+ private static void AppendHardChunks(string word, int available, int indent, List lines, int firstPrefix) {
+ var index = 0;
+ var prefix = new string(' ', firstPrefix);
+ var continuationPrefix = new string(' ', indent);
+ while (index < word.Length) {
+ var chunkWidth = 0;
+ var end = index;
+ while (end < word.Length && chunkWidth + CharWidth(word[end]) <= available) {
+ chunkWidth += CharWidth(word[end]);
+ end++;
+ }
+
+ if (end == index) {
+ end = index + 1; // 单字符超宽防御(不可能路径 — 字符宽 ≤2 且 available ≥1)
+ }
+
+ lines.Add(prefix + word[index..end]);
+ index = end;
+ prefix = continuationPrefix;
+ }
+ }
+
+ /// 空格分词(多空格折叠单分隔 — 连续空格语义等价单空格)。
+ private static IEnumerable SplitWords(string segment) =>
+ segment.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+
+ /// 文本显示宽度(逐字符宽度和 — CJK 宽字符 2 列)。
+ private static int DisplayWidth(string text) {
+ var width = 0;
+ foreach (var ch in text) {
+ width += CharWidth(ch);
+ }
+
+ return width;
+ }
+
+ /// 单字符显示宽 — ASCII/半角 1 列、CJK 全角 2 列(通用表意文字/扩展/全角符号/韩文范围)。
+ private static int CharWidth(char ch) =>
+ ch >= 0x1100 && (ch <= 0x115F // Hangul Jamo
+ || ch >= 0x2E80 && ch <= 0xA4CF // CJK 部首/康熙/注音/中日韩符号/平假名/片假名/兼容
+ || ch >= 0xAC00 && ch <= 0xD7A3 // Hangul 音节
+ || ch >= 0xF900 && ch <= 0xFAFF // CJK 兼容表意
+ || ch >= 0xFE30 && ch <= 0xFE4F // CJK 兼容形式
+ || ch >= 0xFF00 && ch <= 0xFF60 // 全角形式
+ || ch >= 0xFFE0 && ch <= 0xFFE6 // 全角符号
+ || ch >= 0x4E00 && ch <= 0x9FFF // CJK 统一表意文字
+ || ch >= 0x3000 && ch <= 0x303F) // CJK 标点
+ ? 2 : 1;
+}
diff --git a/modules/apps/cli/core/interaction/TerminalInteractiveService.cs b/modules/apps/cli/core/interaction/TerminalInteractiveService.cs
index 98cb03882b..3efde2dba4 100644
--- a/modules/apps/cli/core/interaction/TerminalInteractiveService.cs
+++ b/modules/apps/cli/core/interaction/TerminalInteractiveService.cs
@@ -32,7 +32,7 @@ public Task AskUserQuestionAsync(
if (options is null || options.Count == 0) {
TerminalHelper.WriteLine();
- TerminalHelper.WriteLine($"{AnsiStyleEnumConstants.Bold}{question}{AnsiStyleEnumConstants.Reset}");
+ WriteWrapped(Sanitize(question), bold: true, indent: 0);
TerminalHelper.WriteRaw("> ");
var freeInput = TerminalHelper.ReadLine();
return Task.FromResult(string.IsNullOrWhiteSpace(freeInput)
@@ -80,15 +80,15 @@ public Task AskUserQuestionsAsync(
return Task.FromResult(AskUserQuestionResult.FailureResult("Non-interactive mode: cannot prompt for user input"));
TerminalHelper.WriteLine();
- TerminalHelper.WriteLine($"{AnsiStyleEnumConstants.Bold}{q.Header}{AnsiStyleEnumConstants.Reset}");
- TerminalHelper.WriteLine(q.Question);
+ WriteWrapped(Sanitize(q.Header), bold: true, indent: 0);
+ WriteWrapped(Sanitize(q.Question), bold: false, indent: 0);
TerminalHelper.WriteLine();
for (var i = 0; i < q.Options.Count; i++) {
var opt = q.Options[i];
- TerminalHelper.WriteLine($" {AnsiStyleEnumConstants.Bold}{i + 1}.{AnsiStyleEnumConstants.Reset} {opt.Label}");
+ WriteOptionLabel(i + 1, opt.Label);
if (!string.IsNullOrWhiteSpace(opt.Description))
- TerminalHelper.WriteLine($" {AnsiStyleEnumConstants.Dim}{opt.Description}{AnsiStyleEnumConstants.Reset}");
+ WriteOptionDescription(opt.Description);
}
TerminalHelper.WriteLine();
@@ -117,11 +117,11 @@ public Task AskUserQuestionsAsync(
///
private static List? DisplayQuestion(string question, List options, bool multiSelect, CancellationToken ct) {
TerminalHelper.WriteLine();
- TerminalHelper.WriteLine($"{AnsiStyleEnumConstants.Bold}{question}{AnsiStyleEnumConstants.Reset}");
+ WriteWrapped(Sanitize(question), bold: true, indent: 0);
TerminalHelper.WriteLine();
for (var i = 0; i < options.Count; i++) {
- TerminalHelper.WriteLine($" {AnsiStyleEnumConstants.Bold}{i + 1}.{AnsiStyleEnumConstants.Reset} {options[i]}");
+ WriteOptionLabel(i + 1, options[i]);
}
TerminalHelper.WriteLine();
@@ -185,4 +185,42 @@ private static List ReadMultiSelection(int maxOption, CancellationToken ct)
TerminalHelper.WriteRaw($"无效输入,请输入 1-{maxOption} (逗号分隔) 或 0 取消: ");
}
}
+
+ /// 清洗 — AI 生成文本行尾归一(CR 段折叠)+ 控制字符剥除(见 )。
+ private static string Sanitize(string text) => QuestionTextSanitizer.Sanitize(text);
+
+ /// 按终端宽度折行输出 — 首行 bold 全宽、续行 dim 悬挂缩进 2 列(ANSI 样式按行独立包夹,折行后转义序列不被终端在任意列截断)。
+ private static void WriteWrapped(string text, bool bold, int indent) {
+ var width = Math.Max(20, TerminalHelper.GetWidth());
+ foreach (var line in QuestionTextSanitizer.Wrap(text, width, indent: 2)) {
+ var style = bold ? AnsiStyleEnumConstants.Bold : AnsiStyleEnumConstants.Reset;
+ TerminalHelper.WriteLine($"{style}{line}{AnsiStyleEnumConstants.Reset}");
+ }
+ }
+
+ /// 选项标签输出 — ` N. label` 形态,超宽折行悬挂对齐标签内容列(可用宽 = 终端宽 - 缩进)。
+ private static void WriteOptionLabel(int number, string label) {
+ var width = Math.Max(20, TerminalHelper.GetWidth());
+ var lines = QuestionTextSanitizer.Wrap(Sanitize(label), width, indent: OptionIndent);
+ for (var i = 0; i < lines.Count; i++) {
+ var prefix = i == 0
+ ? $" {AnsiStyleEnumConstants.Bold}{number}.{AnsiStyleEnumConstants.Reset} "
+ : new string(' ', OptionIndent);
+ TerminalHelper.WriteLine(prefix + lines[i]);
+ }
+ }
+
+ /// 选项描述输出 — 5 列缩进 dim,超宽折行悬挂对齐。
+ private static void WriteOptionDescription(string description) {
+ var width = Math.Max(20, TerminalHelper.GetWidth());
+ foreach (var line in QuestionTextSanitizer.Wrap(Sanitize(description), width, indent: DescriptionIndent)) {
+ TerminalHelper.WriteLine($"{new string(' ', DescriptionIndent)}{AnsiStyleEnumConstants.Dim}{line}{AnsiStyleEnumConstants.Reset}");
+ }
+ }
+
+ /// 选项行缩进列(` N. ` = 5 列 — 续行对齐标签内容列)。
+ private const int OptionIndent = 5;
+
+ /// 描述行缩进列。
+ private const int DescriptionIndent = 5;
}
\ No newline at end of file
diff --git a/modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs b/modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs
index 4488e4919b..6e4b1a51ec 100644
--- a/modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs
+++ b/modules/async_primitives/async_lock.tests/actor/ActorBaseTest.cs
@@ -168,13 +168,18 @@ public async Task InputCount_UsesChannelCount_NotInterlocked() {
///
[Fact]
public async Task RetryWriteBack_Failed_TriggersSendFailed() {
- var bp = new ActorBackpressure(Capacity: 1, RetryQueueCapacity: 1);
+ var bp = new ActorBackpressure(Capacity: 1, RetryQueueCapacity: 2);
await using var actor = new TestActor(bp);
var sendFailedEvents = new List>();
actor.SendFailed += (_, e) => sendFailedEvents.Add(e);
- actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier", 1));
+ // 竞态根治(CI 慢机假红):RunLoop await foreach 取出首条后卡 Task.Delay — 取出即队列空,
+ // 断言前被抢跑则 TryWrite 成功假阳。占位 2 条 + 10s 背压信号拉长首条消费延迟
+ // (RunLoop 串行:首条卡 Delay 期间第二条稳占队列,TryRequeue 满判定确定性成立)
+ actor.ReceiveBackpressureSignal(TimeSpan.FromSeconds(10));
+ actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier1", 1));
+ actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier2", 1));
var result = actor.TryRequeueRetryEntry(new RetryEntry("D", 1));
@@ -205,14 +210,18 @@ public async Task RetryWriteBack_MaxRetriesExhausted_TriggersSendFailed() {
///
[Fact]
public async Task SendFailed_Multicast_FirstHandlerThrows_SecondStillCalled() {
- var bp = new ActorBackpressure(Capacity: 1, RetryQueueCapacity: 1);
+ var bp = new ActorBackpressure(Capacity: 1, RetryQueueCapacity: 2);
await using var actor = new TestActor(bp);
var handler2Called = false;
actor.SendFailed += (_, _) => throw new InvalidOperationException("handler1 crash");
actor.SendFailed += (_, _) => handler2Called = true;
- actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier", 1));
+ // 竞态根治同 RetryWriteBack_Failed_TriggersSendFailed:占位 2 条 + 10s 背压信号
+ // 锁住 RunLoop 首条消费(断言前占位被抢跑吃掉则 TryWrite 成功不触发 SendFailed 假阴)
+ actor.ReceiveBackpressureSignal(TimeSpan.FromSeconds(10));
+ actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier1", 1));
+ actor.RetryQueueInternal.Writer.TryWrite(new RetryEntry("occupier2", 1));
actor.TryRequeueRetryEntry(new RetryEntry("D", 1));
handler2Called.Should().BeTrue();
diff --git a/modules/plugins/plugins.tests/unit/PluginManagerTestHooksTest.cs b/modules/plugins/plugins.tests/unit/PluginManagerTestHooksTest.cs
index 68e9971824..0d3dc364e8 100644
--- a/modules/plugins/plugins.tests/unit/PluginManagerTestHooksTest.cs
+++ b/modules/plugins/plugins.tests/unit/PluginManagerTestHooksTest.cs
@@ -85,10 +85,11 @@ public async Task GetPluginResourceIdsForTest_NotRecorded_ReturnsEmpty() {
[Fact]
public async Task RecordPluginResourceIds_ContinuousIds_CompressesIntoSingleRange() {
await using var manager = CreateManager();
- // 连续的 SequenceId 应压缩为单区间
+ // 连续的 SequenceId 应压缩为单区间 — internal 查找键构造(确定性 seq,与进程级全局
+ // 原子计数器解耦:xUnit 并行时其他测试同类 new 交错会断链 — CI 实测 RangeCount=2 假红)
var ids = new List();
- for (var i = 0; i < 10; i++)
- ids.Add(new ObjectId(ObjectType.Resource));
+ for (var seq = 1000L; seq < 1010; seq++)
+ ids.Add(new ObjectId(ObjectType.Resource, seq));
manager.RecordPluginResourceIds("p", ids);
diff --git a/test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs b/test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs
index c16b92cc90..84a9ca1278 100644
--- a/test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs
+++ b/test/mock/mock_server.e2e.tests/core/E2eSettingsJsonHelper.cs
@@ -18,36 +18,36 @@ public static async Task WriteSettingsJsonToStateDirAsync(string stateDir) {
"protocol": "openai-compatible",
"apiKeyEnvVar": "OPENAI_API_KEY",
"models": [
- { "id": "gpt-4o", "displayName": "GPT-4o", "capabilities": { "modalities": ["text"] } },
- { "id": "gpt-4o-mini", "displayName": "GPT-4o Mini", "capabilities": { "modalities": ["text"] } }
+ { "id": "gpt-4o", "displayName": "GPT-4o", "contextWindow": 128000, "capabilities": { "modalities": ["text"] } },
+ { "id": "gpt-4o-mini", "displayName": "GPT-4o Mini", "contextWindow": 128000, "capabilities": { "modalities": ["text"] } }
]
},
"anthropic": {
"protocol": "anthropic",
"apiKeyEnvVar": "ANTHROPIC_API_KEY",
"models": [
- { "id": "claude-sonnet-4-20250514", "displayName": "Claude Sonnet 4", "capabilities": { "modalities": ["text"] } }
+ { "id": "claude-sonnet-4-20250514", "displayName": "Claude Sonnet 4", "contextWindow": 200000, "capabilities": { "modalities": ["text"] } }
]
},
"deepseek": {
"protocol": "openai-compatible",
"apiKeyEnvVar": "DEEPSEEK_API_KEY",
"models": [
- { "id": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", "capabilities": { "modalities": ["text"] } }
+ { "id": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", "contextWindow": 128000, "capabilities": { "modalities": ["text"] } }
]
},
"agnes": {
"protocol": "openai-compatible",
"apiKeyEnvVar": "AGNES_API_KEY",
"models": [
- { "id": "agnes-image-2.0-flash", "displayName": "Agnes Image 2.0 Flash", "capabilities": { "modalities": ["text", "readImage"] } }
+ { "id": "agnes-image-2.0-flash", "displayName": "Agnes Image 2.0 Flash", "contextWindow": 128000, "capabilities": { "modalities": ["text", "readImage"] } }
]
},
"sensenova": {
"protocol": "openai-compatible",
"apiKeyEnvVar": "SENSENOVA_API_KEY",
"models": [
- { "id": "sensenova-6.8-flash-lite", "displayName": "SenseNova 6.8 Flash Lite", "capabilities": { "modalities": ["text"] } }
+ { "id": "sensenova-6.8-flash-lite", "displayName": "SenseNova 6.8 Flash Lite", "contextWindow": 128000, "capabilities": { "modalities": ["text"] } }
]
}
}
diff --git a/test/unit/host.tests/interaction/QuestionTextSanitizerTests.cs b/test/unit/host.tests/interaction/QuestionTextSanitizerTests.cs
new file mode 100644
index 0000000000..fe51f663e7
--- /dev/null
+++ b/test/unit/host.tests/interaction/QuestionTextSanitizerTests.cs
@@ -0,0 +1,86 @@
+namespace Host.Tests.Interaction;
+
+///
+/// 问题文本规整测试 — AI 生成的 question/options/description 输入清理与宽度换行:
+/// ①清理:\r\n 归一 \n、裸 \r 归一 \n(Windows/混合行尾)、剥离其余 C0 控制字符(\t 保留转空格、ESC 剥除防终端解释);
+/// ②换行:按显示宽度词边界折行(CJK 宽字符按 2 列计)、悬挂缩进对齐、宽度钳制(超宽词硬切)。
+///
+public sealed class QuestionTextSanitizerTests {
+ [Theory]
+ [InlineData("a\r\nb", "a\nb")] // CRLF → LF
+ [InlineData("a\rb", "a\nb")] // 裸 CR → LF(AI 混入)
+ [InlineData("a\r\r\nb", "a\nb")] // \r\r\n 病态混合(question 工具实测)→ 单换行
+ [InlineData("a\n\rb", "a\nb")] // \n\r 混合 → 单换行
+ [InlineData("a\x1b[31mb", "a[31mb")] // ESC 剥除(防终端解释 ANSI)—— 方括号字面保留
+ [InlineData("a\u0000b", "ab")] // NUL 剥除(\u 定长转义 — \x00 变长陷阱:\x00b 会被吃成单字符 0x0B)
+ [InlineData("a\tb", "a b")] // TAB → 单空格(对齐语义稳定)
+ [InlineData("正常文本", "正常文本")] // 干净文本零变化
+ [InlineData("", "")] // 空串零变化
+ public void Sanitize_Normalizes_And_Strips(string input, string expected) {
+ Assert.Equal(expected, QuestionTextSanitizer.Sanitize(input));
+ }
+
+ [Fact]
+ public void Sanitize_CrLfRuns_Collapse() {
+ // 连续 \r 段(AI 生成的超长换行根因)→ 单 \n
+ Assert.Equal("行1\n行2", QuestionTextSanitizer.Sanitize("行1\r\r\r\r\n行2"));
+ }
+
+ [Fact]
+ public void Wrap_ShortLine_Unchanged() {
+ Assert.Equal(["短文本"], QuestionTextSanitizer.Wrap("短文本", width: 20, indent: 0));
+ }
+
+ [Fact]
+ public void Wrap_Words_Break_At_Width() {
+ // 10 ASCII 词,宽 11(容纳 2 词/行)→ 词边界折行
+ var text = "one two three four five six seven eight nine ten";
+ var lines = QuestionTextSanitizer.Wrap(text, width: 11, indent: 0);
+ Assert.Equal("one two", lines[0]);
+ Assert.Equal("three four", lines[1]);
+ Assert.Equal("five six", lines[2]);
+ }
+
+ [Fact]
+ public void Wrap_Cjk_Counted_As_Two_Columns() {
+ // 8 个汉字 = 16 列,宽 8 → 每行 4 字
+ var lines = QuestionTextSanitizer.Wrap("一二三四五六七八", width: 8, indent: 0);
+ Assert.Equal(["一二三四", "五六七八"], lines);
+ }
+
+ [Fact]
+ public void Wrap_Indent_Hangs_Aligned() {
+ // 悬挂缩进:续行前留 indent 空格,可用宽度 = width - indent
+ var lines2 = QuestionTextSanitizer.Wrap("aaaa bbbb cccc", width: 9, indent: 5);
+ Assert.Equal("aaaa", lines2[0]);
+ Assert.Equal(" bbbb", lines2[1]); // 5 空格缩进 + 词
+ }
+
+ [Fact]
+ public void Wrap_OverlongWord_Hard_Splits() {
+ // 超宽词(无空格长串)按可用宽度硬切防单行超屏
+ var lines = QuestionTextSanitizer.Wrap("abcdefghij", width: 4, indent: 0);
+ Assert.Equal(["abcd", "efgh", "ij"], lines);
+ }
+
+ [Fact]
+ public void Wrap_MultiParagraph_Kept() {
+ // 段落(\n)保留 — 每段独立折行
+ var lines = QuestionTextSanitizer.Wrap("one two\nthree four", width: 8, indent: 0);
+ Assert.Equal(["one two", "three", "four"], lines);
+ }
+
+ [Fact]
+ public void Wrap_ZeroWidth_Returns_Unfolded_Single() {
+ // 不可用宽度防退化 — 原文单行返回(信息不丢)
+ Assert.Equal(["any"], QuestionTextSanitizer.Wrap("any", width: 0, indent: 0));
+ }
+
+ [Fact]
+ public void SanitizeAndWrap_Pipeline() {
+ // 组合:清理后折行 — AI 实测形态(\r 混入 + 长中文)
+ var sanitized = QuestionTextSanitizer.Sanitize("问题一\r\r\r回答一");
+ var lines = QuestionTextSanitizer.Wrap(sanitized, width: 6, indent: 0);
+ Assert.Equal(["问题一", "回答一"], lines);
+ }
+}