// src/__tests__/component.test.ts
import { describe, expect, it } from "vitest";
import { AskUserComponent } from "../component";
import type { Question, Result } from "../types";
import {
BKSP,
DOWN,
ENTER,
ESC,
LEFT,
mockTui,
multiQ,
multiQWithComment,
RIGHT,
singleQ,
singleQMulti,
singleQWithComment,
stubTheme,
TAB,
UP,
} from "./fixtures";
// Helper: make component with mutable result holder
const make = (
questions: Question[],
): { c: AskUserComponent; result: { val: Result | null | undefined } } => {
const result = { val: undefined as Result | null | undefined };
const c = new AskUserComponent(questions, mockTui, stubTheme, (r) => (result.val = r));
return { c, result };
};
// ── 5a. 单问题(AC-2)──────────────────────────────────
describe("AskUserComponent — single question", () => {
it("C-1: renders question without tab bar", () => {
const { c } = make([singleQ]);
const lines = c.render(60);
expect(lines.some((l) => l.includes("Which DB?"))).toBe(true);
expect(lines.some((l) => l.includes("Submit"))).toBe(false);
});
it("C-2: confirms first option on Enter and resolves", () => {
const { c, result } = make([singleQ]);
c.handleInput(ENTER);
expect(result.val).not.toBeUndefined();
expect(result.val!.cancelled).toBe(false);
expect(result.val!.answers["Which DB?"]).toBe("Postgres");
});
it("C-3: moves cursor down then confirms second option", () => {
const { c, result } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(ENTER);
expect(result.val!.answers["Which DB?"]).toBe("SQLite");
});
it("C-4: Esc shows confirm overlay; second Esc cancels (single)", () => {
const { c, result } = make([singleQ]);
// 首个问题按 Esc → 进入确认取消覆盖层(不立即取消)
c.handleInput(ESC);
expect(result.val).toBeUndefined();
expect(c.render(60).some((l) => l.includes("Cancel all"))).toBe(true);
// 覆盖层内再按 Esc → 确认取消
c.handleInput(ESC);
expect(result.val).toBeNull();
});
it("C-5: Up at first option does not go below 0", () => {
const { c } = make([singleQ]);
c.render(60);
c.handleInput(UP);
// Render and verify cursor still on first (Postgres highlighted)
const lines = c.render(60);
expect(lines.some((l) => l.includes(">") && l.includes("Postgres"))).toBe(true);
});
it("C-6: Down does not go beyond Other (last option)", () => {
const { c } = make([singleQ]);
// 2 options + Other = 3 rows (indices 0,1,2). Press Down 5 times.
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(DOWN);
const lines = c.render(60);
// Cursor should be on Other (last), not beyond
expect(lines.some((l) => l.includes(">") && l.includes("Other"))).toBe(true);
});
});
// ── 5b. 多问题 Tab 导航(AC-3 / AC-16)─────────────────
describe("AskUserComponent — multi question tab nav", () => {
it("C-7: renders tab bar with headers + Submit", () => {
const { c } = make(multiQ);
const lines = c.render(80);
const t = lines.join("\n");
expect(t).toContain("First");
expect(t).toContain("Second");
expect(t).toContain("Third");
expect(t).toContain("Submit");
});
it("C-8: navigates tabs and submits all answers", () => {
const { c, result } = make(multiQ);
// Q1: select A (Enter)
c.handleInput(ENTER);
// Q2: toggle X (Space), confirm (Enter)
c.handleInput(" ");
c.handleInput(ENTER);
// Q3: select M (Enter)
c.handleInput(ENTER);
// Submit tab: Enter
c.handleInput(ENTER);
expect(result.val!.answers["Q1"]).toBe("A");
expect(result.val!.answers["Q2"]).toBe("X");
expect(result.val!.answers["Q3"]).toBe("M");
});
it("C-9: Submit tab blocks when not all confirmed", () => {
const { c, result } = make(multiQ);
c.handleInput(RIGHT); // -> Q2
c.handleInput(RIGHT); // -> Q3
c.handleInput(RIGHT); // -> Submit
c.handleInput(ENTER); // should NOT submit
expect(result.val).toBeUndefined();
});
// C-10 / C-11 已废弃:Tab/Shift+Tab 不再切问题 tab(C-E5/C-E6 覆盖 ←/→ 切问题 tab 行为)
it("C-16 (AC-16): can re-edit confirmed answer", () => {
const { c, result } = make(multiQ);
// Q1: select A
c.handleInput(ENTER); // → Q2
// Go back to Q1(问题 tab 间用 ← 回退)
c.handleInput(LEFT); // Q1
// Select B instead
c.handleInput(DOWN);
c.handleInput(ENTER); // → Q2
// Skip Q2, Q3 by confirming
c.handleInput(" ");
c.handleInput(ENTER); // Q2 → Q3
c.handleInput(ENTER); // Q3 → Submit
c.handleInput(ENTER); // Submit
expect(result.val!.answers["Q1"]).toBe("B");
});
it("C-15: leaving multi-select tab auto-confirms answered selection", () => {
// Use 2 questions: Q1 single, Q2 multi-select
const twoQ: Question[] = [
{ question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }] },
{ question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }], multiSelect: true },
];
const { c, result } = make(twoQ);
c.handleInput(ENTER); // Q1 select A → Q2
c.handleInput(" "); // Q2 toggle X (no Enter-confirm)
c.handleInput(RIGHT); // → Submit, should auto-confirm Q2
c.handleInput(ENTER); // Submit (all confirmed)
expect(result.val!.answers["Q1"]).toBe("A");
expect(result.val!.answers["Q2"]).toBe("X");
});
it("C-S1: multi-select Enter 同时选中光标项再确认(与单选 Enter 对称)", () => {
// S-1 锁定:多选模式下光标停在未选项上按 Enter,应把光标项加入选中再确认
const { c, result } = make([singleQMulti]);
// singleQMulti: [Auth, Search],光标初始在 Auth(0)
c.handleInput(DOWN); // 光标移到 Search(1),未 toggle
c.handleInput(ENTER); // Enter 应同时选中 Search + confirm + allowComment → comment
// 断言进入了评论模式(说明 Enter 确认了,而非 no-op)
const lines = c.render(60);
expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
c.handleInput(ENTER); // 跳过评论 → submit(单问题)
expect(result.val!.answers["Which features?"]).toBe("Search");
});
it("C-S3: auto-confirm(←/→ 切 tab)跳过评论输入行,仅 Enter 路径才进评论", () => {
// S-3 锁定:allowComment 的问题,←/→ 切走只 auto-confirm,不进评论模式
const twoQMulti: Question[] = [
{ question: "Q1", header: "First", options: [{ label: "A" }, { label: "B" }], multiSelect: true, allowComment: true },
{ question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
];
const { c, result } = make(twoQMulti);
c.handleInput(" "); // Q1 toggle A
c.handleInput(RIGHT); // → Q2,auto-confirm Q1,不进评论
// 验证:当前在 Q2(非 Q1 的评论模式)。Q2 选 X → Submit
c.handleInput(ENTER); // Q2 select X → Submit
c.handleInput(ENTER); // Submit
expect(result.val!.answers["Q1"]).toBe("A"); // auto-confirm 生效
expect(result.val!.answers["Q2"]).toBe("X");
});
it("C-REG-R6: Other 录入→重进清空→Submit 应回到未答(confirmed 不变式)", () => {
// 回归 MUST_FIX: freeform 空 Enter 清空 freeTextValue 后须重置 confirmed=false
const { c, result } = make(multiQ);
// Q1 (A/B + Other): 导航到 Other,录入 "custom"
c.handleInput(DOWN);
c.handleInput(DOWN); // → Other
c.handleInput(ENTER); // 打开 freeform
c.handleInput("c");
c.handleInput("u");
c.handleInput("s");
c.handleInput("t");
c.handleInput("o");
c.handleInput("m");
c.handleInput(ENTER); // 保存 freeText → confirmed=true → advance to Q2
// 切回 Q1,重进 Other 编辑器,清空后空 Enter(问题 tab 间用 ← 回退)
c.handleInput(LEFT); // → Q1
c.handleInput(DOWN); // idempotent: cursor stays on Other
c.handleInput(DOWN);
c.handleInput(ENTER); // 重开 freeform,editorText 预填 "custom"
c.handleInput(BKSP); // 清空 editorText
c.handleInput(BKSP);
c.handleInput(BKSP);
c.handleInput(BKSP);
c.handleInput(BKSP);
c.handleInput(BKSP);
c.handleInput(ENTER); // 空 Enter → freeTextValue 清空,confirmed 应重置 false
// 导航到 Submit 并尝试提交 → 应被阻塞(Q1 回到未答)
c.handleInput(RIGHT); // → Q2
c.handleInput(RIGHT); // → Q3
c.handleInput(RIGHT); // → Submit
c.handleInput(ENTER); // 应被阻塞
expect(result.val).toBeUndefined();
});
});
// ── 5c. 单选选择(FR-6)────────────────────────────────
describe("AskUserComponent — single-select", () => {
it("C-17: cursor movement does not record answer", () => {
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.render(60);
// No resolution yet (only Enter resolves single)
// We verify by checking the question is still interactive — render shows cursor on SQLite
const lines = c.render(60);
expect(lines.some((l) => l.includes(">") && l.includes("SQLite"))).toBe(true);
});
it("C-19: re-selecting a normal option clears freeText", () => {
const { c, result } = make([singleQ]);
// Go to Other (index 2), open editor, type text
c.handleInput(DOWN);
c.handleInput(DOWN); // Other
c.handleInput(ENTER); // open freeform
c.handleInput("c");
c.handleInput("u");
c.handleInput("s");
c.handleInput("t");
c.handleInput("o");
c.handleInput("m");
c.handleInput(ENTER); // save freeText → submit (single question)
expect(result.val!.answers["Which DB?"]).toBe("custom");
});
});
// ── 5d. 多选 toggle(FR-6 / AC-18)─────────────────────
describe("AskUserComponent — multi-select toggle", () => {
it("C-20: Space toggles index into selectedIndices", () => {
const { c } = make([singleQMulti]);
c.handleInput(" ");
const lines = c.render(60);
expect(lines.some((l) => l.includes("[✓]") && l.includes("Auth"))).toBe(true);
});
it("C-21: Space twice removes from selectedIndices", () => {
const { c } = make([singleQMulti]);
c.handleInput(" "); // add
c.handleInput(" "); // remove
const lines = c.render(60);
expect(lines.some((l) => l.includes("[ ]") && l.includes("Auth"))).toBe(true);
});
it("C-24 (AC-18): multi-select toggle does NOT trigger comment mode", () => {
// singleQMulti has allowComment:true + multiSelect:true
const { c } = make([singleQMulti]);
c.handleInput(" "); // toggle — should NOT enter comment mode
const lines = c.render(60);
// No comment prompt shown
expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(false);
});
});
// ── 5e. Other 自由文本(FR-4.5 / AC-5)─────────────────
describe("AskUserComponent — Other free-text editor", () => {
it("C-25: Enter on Other row opens freeform editor (in-place)", () => {
const { c } = make([singleQ]);
// Navigate to Other (index 2)
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER);
const lines = c.render(60);
// freeform 模式:光标行(█)出现,editor 已在 Other 行原地渲染
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(true);
// 不再独立 "Your answer" 提示块
expect(lines.some((l) => l.includes("Your answer"))).toBe(false);
});
it("C-26: Tab no longer opens freeform editor (Enter-only; Tab is tab-nav now)", () => {
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(TAB);
const lines = c.render(60);
// Tab 不再打开 Other 编辑器(仅 Enter);单问题下 Tab 是 no-op
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
});
it("C-27: editor accepts printable characters", () => {
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER); // open
c.handleInput("h");
c.handleInput("i");
const lines = c.render(60);
expect(lines.some((l) => l.includes("hi"))).toBe(true);
});
it("C-28: editor Backspace deletes last char", () => {
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER);
c.handleInput("a");
c.handleInput("b");
c.handleInput(BKSP); // delete "b"
const lines = c.render(60);
expect(lines.some((l) => l.includes("a") && !l.includes("ab"))).toBe(true);
});
it("C-31: editor Esc returns to options list", () => {
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER);
c.handleInput(ESC);
const lines = c.render(60);
// Back in options mode — no cursor block (freeform inactive)
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
});
it("C-29: editor Enter with text saves and submits (single)", () => {
const { c, result } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER);
c.handleInput("x");
c.handleInput(ENTER);
expect(result.val).not.toBeUndefined();
expect(result.val!.answers["Which DB?"]).toBe("x");
});
it("C-30: editor Enter empty clears freeText (single → stays in form)", () => {
const { c, result } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER); // open editor
c.handleInput(ENTER); // FR-6: empty Enter → clear freeText, close editor (NO confirm/submit)
// Not submitted; still in options list
expect(result.val).toBeUndefined();
// Back in options mode — no cursor block (freeform inactive)
const lines = c.render(60);
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
});
});
// ── 5e2. 多字符粘贴(M2/M7:完整保留,过滤控制字符,emoji 不丢)──
// 回归 MUST_FIX-M2:handleEditorInput 按 code point 迭代粘贴 chunk,去掉了
// `c.length === 1` 守卫(它误杀 BMP 之外的代理对,如 emoji)。
describe("AskUserComponent — multi-char paste in editor", () => {
it("C-PASTE-1: multi-char chunk pasted at once is fully captured", () => {
// 终端把整段粘贴文本作为一个 data chunk 投递。修复前只取首字符。
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER); // 打开 freeform 编辑器
c.handleInput("hello world"); // 一次粘贴多字符
const lines = c.render(60);
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
expect(editorLine).toBeDefined();
// 完整文本保留(非仅首字符 "h")
expect(editorLine).toContain("hello world");
});
it("C-PASTE-2: paste with emoji (surrogate pair) preserves emoji (M2)", () => {
// 修复前:`c.length === 1` 守卫把代理对(🐛 length===2)过滤掉,emoji 被静默丢弃。
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER);
c.handleInput("fix the 🐛 bug");
const lines = c.render(60);
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
expect(editorLine).toBeDefined();
// emoji 完整保留,不丢失
expect(editorLine).toContain("fix the 🐛 bug");
expect(editorLine).toContain("🐛");
});
it("C-PASTE-3: multi-char chunk with control chars filters control, keeps printable", () => {
// 粘贴 "ab\tcd":\t(U+0009)< 空格 → 被过滤;a/b/c/d 保留。
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER);
c.handleInput("ab\tcd");
const lines = c.render(60);
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
expect(editorLine).toBeDefined();
// 可打印字符保留,控制字符 \t 被过滤
expect(editorLine).toContain("abcd");
expect(editorLine).not.toContain("\t");
});
it("C-PASTE-4: empty string input is a no-op (no side effects)", () => {
const { c, result } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER); // 打开编辑器
const before = c.render(60);
c.handleInput(""); // 空字符串,无副作用
const after = c.render(60);
// 未解析、未崩溃、视图不变
expect(result.val).toBeUndefined();
expect(after).toEqual(before);
// 编辑器仍在(光标在),editorText 仍为空
expect(after.some((l) => l.includes("\x1b[7m"))).toBe(true);
});
it("C-PASTE-5: single printable char still works (backward-compat)", () => {
// 回归:修复去掉守卫后单字符行为不变(与 C-27 等价的逐字符输入)。
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER);
c.handleInput("x"); // 单字符
const lines = c.render(60);
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
expect(editorLine).toBeDefined();
expect(editorLine).toContain("x");
});
it("C-PASTE-6: bracketed paste 序列被剥离,不残留 [200~/[201~", () => {
// 回归:启用 bracketed paste mode 的终端粘贴时会把内容包裹在
// \x1b[200~ ... \x1b[201~ 中。ESC 被守卫过滤,但 [200~/[201~ 可见字符
// 会残留。修复:handleEditorInput 先 replace 剥离这两个序列。
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER); // 打开 freeform
c.handleInput("\x1b[200~hello\x1b[201~");
const lines = c.render(60);
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
expect(editorLine).toBeDefined();
expect(editorLine).toContain("hello");
expect(editorLine).not.toContain("[200~");
expect(editorLine).not.toContain("[201~");
});
it("C-PASTE-7: bracketed paste 跨 chunk 抵达时每个 chunk 独立剥离", () => {
// 边界:粘贴内容分多个 data chunk 抵达,每个 chunk 各自带始/末标记。
// 简单 replace 在此场景仍有效(每个 chunk 独立剥离自己的标记)。
const { c } = make([singleQ]);
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER);
c.handleInput("\x1b[200~foo ");
c.handleInput(" bar\x1b[201~");
const lines = c.render(60);
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
expect(editorLine).toContain("foo bar");
expect(editorLine).not.toContain("[200~");
expect(editorLine).not.toContain("[201~");
});
});
// ── 5f. 评论流程(FR-4.6 / FR-11 / AC-6/12/17)─────────
describe("AskUserComponent — comment flow", () => {
it("C-33: single-select + allowComment Enter enters comment mode", () => {
const { c } = make([singleQWithComment]);
c.handleInput(ENTER); // select Postgres
const lines = c.render(60);
expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
});
it("C-34 (AC-12): comment Enter empty skips and submits (single)", () => {
const { c, result } = make([singleQWithComment]);
c.handleInput(ENTER); // select → comment mode
c.handleInput(ENTER); // empty comment → skip → submit
expect(result.val).not.toBeUndefined();
expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
});
it("C-35: comment Enter with text saves comment", () => {
const { c, result } = make([singleQWithComment]);
c.handleInput(ENTER); // select → comment mode
c.handleInput("f");
c.handleInput("a");
c.handleInput("s");
c.handleInput("t");
c.handleInput(ENTER); // save comment → submit
expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — fast");
});
it("C-38: multi-select + allowComment: Enter after toggle enters comment", () => {
const { c } = make([singleQMulti]);
c.handleInput(" "); // toggle Auth
c.handleInput(ENTER); // confirm → comment mode
const lines = c.render(60);
expect(lines.some((l) => l.toLowerCase().includes("comment"))).toBe(true);
});
it("C-39: Other + allowComment: freeText then comment", () => {
const { c, result } = make([singleQWithComment]);
// Navigate to Other
c.handleInput(DOWN);
c.handleInput(DOWN);
c.handleInput(ENTER); // open freeform
c.handleInput("c");
c.handleInput("u");
c.handleInput("s");
c.handleInput("t");
c.handleInput("o");
c.handleInput("m");
c.handleInput(ENTER); // save freeText → allowComment → comment mode
c.handleInput(ENTER); // empty comment → skip → submit
expect(result.val!.answers["Which DB? (with comment)"]).toBe("custom");
});
it("C-36 (AC-17): comment Esc skips comment and advances (single)", () => {
const { c, result } = make([singleQWithComment]);
c.handleInput(ENTER); // select Postgres → comment mode
c.handleInput(ESC); // Esc in comment = skip comment → advance → submit
expect(result.val).not.toBeUndefined();
// commentValue stays null (no prior comment), answer is the selected option
expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
});
it("C-36b (AC-17): comment Esc advances to next tab (multi-question)", () => {
const { c, result } = make(multiQWithComment);
// Q1 (allowComment): select A → comment mode
c.handleInput(ENTER); // select A → comment mode
c.handleInput(ESC); // Esc in comment = skip → advance to Q2
// Q2: select X → Submit
c.handleInput(ENTER); // select X → Submit
c.handleInput(ENTER); // Submit
expect(result.val).not.toBeUndefined();
expect(result.val!.answers["Q1"]).toBe("A");
expect(result.val!.answers["Q2"]).toBe("X");
});
it("C-36c (AC-17): Esc-in-comment discards typed text (vs Enter which saves)", () => {
// Contrast: typing then Enter would save commentValue and append " — keep".
// Esc should discard the typed editor text and advance without attaching it.
const { c, result } = make([singleQWithComment]);
c.handleInput(ENTER); // select Postgres → comment mode
c.handleInput("k");
c.handleInput("e");
c.handleInput("e");
c.handleInput("p");
c.handleInput(ESC); // Esc in comment = discard typed text → advance → submit
expect(result.val).not.toBeUndefined();
// No " — keep" suffix: Esc did not commit the typed text
expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres");
});
it("C-37: answer + comment combined format 'label — note'", () => {
const { c, result } = make([singleQWithComment]);
c.handleInput(ENTER); // select Postgres → comment mode
c.handleInput("n");
c.handleInput("o");
c.handleInput("t");
c.handleInput("e");
c.handleInput(ENTER); // save comment → submit
expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — note");
});
});
// ── 5g. 防重入(FR-12)─────────────────────────────────
describe("AskUserComponent — re-entry guard", () => {
it("C-40: ignores input after resolution (submit)", () => {
const { c, result } = make([singleQ]);
c.handleInput(ENTER); // submit
const firstVal = result.val;
c.handleInput(ENTER); // ignored
expect(result.val).toBe(firstVal);
});
it("C-41: ignores input after cancel", () => {
const { c, result } = make([singleQ]);
c.handleInput(ESC); // → 确认取消覆盖层
c.handleInput(ESC); // → 确认取消
expect(result.val).toBeNull();
c.handleInput(ENTER); // ignored
expect(result.val).toBeNull();
});
// FR-12 竞态:signal abort 可能在用户已 submit 后才触发,此时 cancel() 必须是
// no-op,避免二次调 done(用户已拿到 submit 结果,不应被 abort 覆盖为 null)。
it("C-FR12 (FR-12): cancel() after submit does not re-invoke done", () => {
const { c, result } = make([singleQ]);
c.handleInput(ENTER); // submit → done(result), _resolved=true
expect(result.val).toBeDefined();
expect(result.val!.cancelled).toBe(false);
const submitVal = result.val;
// 模拟 signal abort 在 submit 后触发 → cancel() 应被 _resolved 守卫拦截
c.cancel();
expect(result.val).toBe(submitVal); // 不变 —— done 未被二次调用
});
it("C-FR12b (FR-12): cancel() after cancel does not re-invoke done", () => {
const { c, result } = make([singleQ]);
c.handleInput(ESC); // overlay
c.handleInput(ESC); // cancel → done(null), _resolved=true
expect(result.val).toBeNull();
// 再次 cancel(模拟 abort 触发)应 no-op
c.cancel();
expect(result.val).toBeNull(); // 仍为 null,未触发异常
});
});
// ── 5h. 渲染缓存 ───────────────────────────────────────
describe("AskUserComponent — render cache", () => {
it("C-42: same width returns same reference", () => {
const { c } = make([singleQ]);
const a = c.render(60);
const b = c.render(60);
expect(a).toBe(b);
});
it("C-43: input invalidates cache", () => {
const { c } = make([singleQ]);
const a = c.render(60);
c.handleInput(DOWN);
const b = c.render(60);
expect(a).not.toBe(b);
});
it("C-44: different width invalidates cache", () => {
const { c } = make([singleQ]);
const a = c.render(60);
const b = c.render(80);
expect(a).not.toBe(b);
});
});
// ── 5i. Submit tab 交互(FR-5)──────────────────────────
describe("AskUserComponent — Submit tab", () => {
it("C-47: Submit Esc backs to last question (no longer cancels)", () => {
const { c, result } = make(multiQ);
c.handleInput(RIGHT); // Q2
c.handleInput(RIGHT); // Q3
c.handleInput(RIGHT); // Submit
c.handleInput(ESC); // 回退到最后一个问题 Q3(不取消)
expect(result.val).toBeUndefined();
const lines = c.render(80);
// 回到 Q3:渲染 Q3 选项 M(非 Submit 视图)
expect(lines.some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(false);
expect(lines.some((l) => l.includes("M"))).toBe(true);
});
it("C-46: Submit Enter when all confirmed submits", () => {
const { c, result } = make(multiQWithComment);
// Q1 (allowComment): select A → comment mode → skip
c.handleInput(ENTER); // select A
c.handleInput(ENTER); // skip comment → Q2
// Q2: select X
c.handleInput(ENTER); // → Submit
c.handleInput(ENTER); // Submit
expect(result.val).not.toBeUndefined();
expect(result.val!.answers["Q1"]).toBe("A");
expect(result.val!.answers["Q2"]).toBe("X");
});
it("C-S12: Submit tab Left navigates to last question tab", () => {
// 锁定:Submit tab 上按 ← → activeTab = questions.length - 1(最后一个问题)
const { c } = make(multiQ); // 3 questions → tabs 0,1,2,3=Submit
// 导航到 Submit(问题 tab 间用 →)
c.handleInput(RIGHT); // Q1 → Q2
c.handleInput(RIGHT); // Q2 → Q3
c.handleInput(RIGHT); // Q3 → Submit
// 确认当前在 Submit(渲染 Submit 视图)
let lines = c.render(80);
expect(lines.some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(true);
// 在 Submit 上按 ← → 应回到最后一个问题 Q3(←/→ 在所有 tab 上都是导航)
c.handleInput(LEFT);
lines = c.render(80);
// Q3 不再是 Submit 视图(无 Ready/Unanswered),且渲染了 Q3 的选项 M
expect(lines.some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(false);
expect(lines.some((l) => l.includes("M"))).toBe(true); // Q3 选项 M
});
});
// ── 5j. 视觉边框 / 按钮栏 / tab 分割(视觉增强)─────────
describe("AskUserComponent — visual chrome", () => {
it("C-V1: multi-question render is wrapped in box border (┌┐│└┘)", () => {
const { c } = make(multiQ);
const lines = c.render(70);
const t = lines.join("\n");
expect(t).toContain("┌"); // 顶左角
expect(t).toContain("┐"); // 顶右角
expect(t).toContain("└"); // 底左角
expect(t).toContain("┘"); // 底右角
expect(lines.some((l) => l.startsWith("│") || l.includes("│"))).toBe(true); // 左右边框
});
it("C-V2: tab bar separates tabs with │", () => {
const { c } = make(multiQ);
const lines = c.render(80);
// tab 行应含竖线分隔符(First │ Second │ ... Submit)
expect(lines.some((l) => l.includes("First") && l.includes("│"))).toBe(true);
expect(lines.some((l) => l.includes("Submit") && l.includes("│"))).toBe(true);
});
it("C-V3: multi-question shows [ Submit ] [ Cancel ] button bar", () => {
const { c } = make(multiQ);
const lines = c.render(80);
const t = lines.join("\n");
expect(t).toContain("[");
expect(t).toContain("Submit");
expect(t).toContain("Cancel");
expect(t).toContain("]");
});
it("C-V4: single question has NO button bar", () => {
const { c } = make([singleQ]);
const lines = c.render(60);
// 单问题无 Submit/Cancel 按钮栏(Enter 直接提交)
expect(lines.some((l) => l.includes("Cancel"))).toBe(false);
});
it("C-V5: question view renders divider (─) between sections", () => {
const { c } = make([singleQ]);
const lines = c.render(60);
// 分割线:question 与 options 之间应有 ─ 行(非边框)
expect(lines.some((l) => l.includes("─"))).toBe(true);
});
});
// ── 5k. 已完成绿勾 / Esc 回退 / Tab 浏览(行为增强)─────
describe("AskUserComponent — confirm-checkmark, Esc-back, Tab browsing", () => {
it("C-E1: confirmed tab shows green ✓ marker", () => {
const { c } = make(multiQ);
c.handleInput(ENTER); // Q1 确认 → Q2
// 回到 Q1 看 tab 栏:Q1 已确认应有 ✓ 标识(问题 tab 间用 ← 回退)
c.handleInput(LEFT); // Q2 → Q1
const lines = c.render(80);
expect(lines.some((l) => l.includes("✓") && l.includes("First"))).toBe(true);
});
it("C-E2: Esc backs to previous question (multi)", () => {
const { c, result } = make(multiQ);
c.handleInput(ENTER); // Q1 → Q2
c.handleInput(ESC); // Q2 → 回退到 Q1(不取消)
expect(result.val).toBeUndefined();
const lines = c.render(80);
expect(lines.some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
});
it("C-E3: Esc on first question shows confirm overlay; Esc again cancels", () => {
const { c, result } = make(multiQ);
// 首个问题按 Esc → 确认取消覆盖层
c.handleInput(ESC);
expect(result.val).toBeUndefined();
expect(c.render(80).some((l) => l.includes("Cancel all"))).toBe(true);
// 覆盖层内再按 Esc → 确认取消
c.handleInput(ESC);
expect(result.val).toBeNull();
});
it("C-E4: confirm overlay dismissed by any non-Esc key (stays in form)", () => {
const { c, result } = make(multiQ);
c.handleInput(ESC); // 进入确认覆盖层
c.handleInput(ENTER); // 非 Esc → 退出覆盖层,留在表单
expect(result.val).toBeUndefined();
// 覆盖层已关闭:渲染回到正常 tab 视图
expect(c.render(80).some((l) => l.includes("Cancel all"))).toBe(false);
});
it("C-E5: ←/→ navigates question tabs (→ next, ← previous)", () => {
const { c } = make(multiQ);
c.handleInput(RIGHT); // Q1 → Q2
// 确认在 Q2:渲染含 Q2 的选项 X
expect(c.render(80).some((l) => l.includes("X"))).toBe(true);
// ← 回退到 Q1
c.handleInput(LEFT);
expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
});
it("C-E6: → at last question enters Submit; ← at first question stays (no wrap)", () => {
const { c } = make(multiQ);
// 到 Submit:Q1→Q2→Q3→Submit
c.handleInput(ENTER); // Q1→Q2
c.handleInput(RIGHT); // Q2→Q3
c.handleInput(RIGHT); // Q3→Submit
expect(c.render(80).some((l) => l.includes("Ready") || l.includes("Unanswered"))).toBe(true);
// Submit 上 ← → 回退到 Q3(←/→ 在所有 tab 上都是导航)
c.handleInput(LEFT);
expect(c.render(80).some((l) => l.includes("M"))).toBe(true);
// Q1 上 ← 不环绕(停在首个问题)
c.handleInput(LEFT); // Q3→Q2
c.handleInput(LEFT); // Q2→Q1
c.handleInput(LEFT); // Q1 上 ← → 停留 Q1
expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
});
});
// ── 5l. 新行为:←/→ 不切 tab、Other Enter 切 freeform 原生、Submit tab focus ──
describe("AskUserComponent — new behavior (post-refactor)", () => {
it("C-NEW-1: multi-select Other + Enter opens freeform; Other row turns into [ ] █ in-place", () => {
// singleQMulti: [Auth, Search],多选 + allowComment
const { c, result } = make([singleQMulti]);
// 1) Space toggle Auth
c.handleInput(" ");
// 2) ↓ 到 Other (cursor=2)
c.handleInput(DOWN);
c.handleInput(DOWN);
// 3) Enter 切 freeform(不再依赖 Space)
c.handleInput(ENTER);
// 验证:freeform 模式下,选项列表中应出现 [ ] █ 行(光标 block + 多选 box)
// 选中的 Auth 仍是 [✓](多选 toggle 未变),Other 行原地变 [ ]
const lines = c.render(60);
// 独立 "Your answer" 提示行已消失
expect(lines.some((l) => l.includes("Your answer"))).toBe(false);
// freeform cursor 出现
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(true);
// 依然能看见 "Auth" "Search"(普通选项不变)
expect(lines.some((l) => l.includes("Auth"))).toBe(true);
expect(lines.some((l) => l.includes("Search"))).toBe(true);
// [✓] 标记的 Auth 仍存在(toggle 状态保留)
expect(lines.some((l) => l.includes("[✓]") && l.includes("Auth"))).toBe(true);
// 4) 输 "redis" → Enter 保存 → allowComment → comment mode
c.handleInput("r");
c.handleInput("e");
c.handleInput("d");
c.handleInput("i");
c.handleInput("s");
c.handleInput(ENTER); // 保存 freeText → comment mode
c.handleInput(ENTER); // 跳过评论 → submit(单问题)
// 答案含多选 toggle 项 + Other 自定义
expect(result.val!.answers["Which features?"]).toBe("Auth, redis");
});
it("C-NEW-2: ←/→ on question tab switches tabs (→ next, ← previous; ← no wrap at first)", () => {
// multiQ: 3 questions → tabs 0,1,2,3=Submit
const { c } = make(multiQ);
c.render(80);
// 1) Q1 上按 Right → 应切到 Q2
c.handleInput(RIGHT);
expect(c.render(80).some((l) => l.includes("Q2") || l.includes("Second"))).toBe(true);
// 2) Left 回到 Q1
c.handleInput(LEFT);
expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
// 3) Q1 上 Left 不环绕(仍停 Q1)
c.handleInput(LEFT);
expect(c.render(80).some((l) => l.includes("Q1") || l.includes("First"))).toBe(true);
});
it("C-NEW-3: Submit tab Tab toggles submitTabFocus (Submit ↔ Cancel)", () => {
// multiQ: 3 questions → tabs 0,1,2,3=Submit
const { c } = make(multiQ);
// 导航到 Submit(问题 tab 间用 →)
c.handleInput(RIGHT); // Q1→Q2
c.handleInput(RIGHT); // Q2→Q3
c.handleInput(RIGHT); // Q3→Submit
// Submit tab 默认 focus=Submit。验证渲染中 Submit 高亮(accent)
let lines = c.render(80);
const focusedLineInitial = lines.find((l) => l.match(/[\[\(]\s*Submit\s*[\]\)]/));
expect(focusedLineInitial).toBeDefined();
// 按 Tab → focus 切到 Cancel(单键双向循环)
c.handleInput(TAB);
lines = c.render(80);
const focusedLineAfter = lines.find((l) => l.match(/[\[\(]\s*Cancel\s*[\]\)]/));
expect(focusedLineAfter).toBeDefined();
// 再按 Tab → focus 回 Submit
c.handleInput(TAB);
lines = c.render(80);
expect(lines.find((l) => l.match(/[\[\(]\s*Submit\s*[\]\)]/))).toBeDefined();
});
it("C-NEW-4: Submit tab Enter on Submit focus (all confirmed) submits", () => {
// multiQWithComment: Q1 allowComment, Q2 plain
const { c, result } = make(multiQWithComment);
// 答完 Q1 + Q2
c.handleInput(ENTER); // Q1 select A → comment mode
c.handleInput(ENTER); // skip comment → Q2
c.handleInput(ENTER); // Q2 select X → Submit tab(Q2 是最后一个问题,advance 到 Submit)
// 已经在 Submit tab,focus=Submit,按 Enter 提交
c.handleInput(ENTER);
expect(result.val).not.toBeUndefined();
expect(result.val!.answers["Q1"]).toBe("A");
expect(result.val!.answers["Q2"]).toBe("X");
});
it("C-NEW-5: Submit tab Enter on Cancel focus cancels (no confirm overlay)", () => {
// multiQ: 3 questions
const { c, result } = make(multiQ);
// 答完所有问题
c.handleInput(ENTER); // Q1 → Q2
c.handleInput(ENTER); // Q2 → Q3
c.handleInput(ENTER); // Q3 → Submit tab
// 切到 Submit 后,按 Tab 把 focus 切到 Cancel
c.handleInput(TAB);
// Enter → 直接 cancel()(Submit tab 上无二次确认)
c.handleInput(ENTER);
expect(result.val).toBeNull();
});
it("C-NEW-6: Submit tab Enter on Submit when not all confirmed is a no-op (blocks submit)", () => {
const { c, result } = make(multiQ);
// 只答 Q1
c.handleInput(ENTER); // Q1 → Q2
// 切到 Submit(Q2 还未答)
c.handleInput(RIGHT); // Q2 → Q3
c.handleInput(RIGHT); // Q3 → Submit
// focus=Submit(默认),按 Enter → 不提交(Q2/Q3 未答)
c.handleInput(ENTER);
expect(result.val).toBeUndefined();
});
it("C-TAB-NOLEAK: Tab in options mode with single question is no-op", () => {
const c = new AskUserComponent(
[{ question: "Pick one", options: [{ label: "A" }, { label: "B" }] }],
mockTui,
stubTheme,
() => {},
);
c.handleInput(DOWN);
c.handleInput(TAB);
// 不应 crash,选项仍可见
const lines = c.render(60);
expect(lines.some((l: string) => l.includes("A"))).toBe(true);
expect(lines.some((l: string) => l.includes("B"))).toBe(true);
});
});