359 lines
8.9 KiB
TypeScript
359 lines
8.9 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
import { createInitialState } from "../src/state/create.ts";
|
||
import { getRenderableOptions } from "../src/state/selectors.ts";
|
||
import {
|
||
applyNumberShortcut,
|
||
enterOptionNoteMode,
|
||
enterQuestionNoteMode,
|
||
} from "../src/state/transitions.ts";
|
||
import { renderQuestionScreen } from "../src/ui/render-question.ts";
|
||
|
||
function mockTheme(onColor?: (color: string, text: string) => void) {
|
||
return {
|
||
fg(color: string, text: string) {
|
||
onColor?.(color, text);
|
||
return onColor ? text : `<${color}>${text}</${color}>`;
|
||
},
|
||
bg(color: string, text: string) {
|
||
return onColor ? text : `{${color}}${text}{/${color}}`;
|
||
},
|
||
bold(text: string) {
|
||
return text;
|
||
},
|
||
} as never;
|
||
}
|
||
|
||
function mockEditor(text = "", renderedLines?: string[]) {
|
||
return {
|
||
getText() {
|
||
return text;
|
||
},
|
||
render() {
|
||
return renderedLines ?? [];
|
||
},
|
||
} as never;
|
||
}
|
||
|
||
test("custom option stays labeled before selection", () => {
|
||
const state = createInitialState({
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Pick one",
|
||
options: [{ value: "a", label: "A" }],
|
||
},
|
||
],
|
||
});
|
||
|
||
const lines: string[] = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor(),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state,
|
||
theme: mockTheme(),
|
||
width: 80,
|
||
});
|
||
|
||
assert(lines.some((line) => line.includes("Type your own")));
|
||
assert(!lines.some((line) => line.includes("Type your answer...")));
|
||
});
|
||
|
||
test("standard options show recommendation metadata without changing answers", () => {
|
||
let state = createInitialState({
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Pick one",
|
||
options: [
|
||
{
|
||
value: "a",
|
||
label: "Option A",
|
||
description: "Best fit for the stated constraints",
|
||
recommended: true,
|
||
},
|
||
{ value: "b", label: "Option B" },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
const lines: string[] = [];
|
||
const calls: Array<{ color: string; text: string }> = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor(),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state,
|
||
theme: mockTheme((color, text) => calls.push({ color, text })),
|
||
width: 80,
|
||
});
|
||
|
||
assert(lines.some((line) => line.includes("(recommended) | Best fit")));
|
||
assert(
|
||
calls.some(
|
||
(call) => call.color === "warning" && call.text === "(recommended)"
|
||
)
|
||
);
|
||
assert(
|
||
calls.some(
|
||
(call) =>
|
||
call.color === "muted" &&
|
||
call.text === " | Best fit for the stated constraints"
|
||
)
|
||
);
|
||
assert.equal(state.answers.q1, undefined);
|
||
|
||
state = applyNumberShortcut(state, 1);
|
||
assert.equal(state.answers.q1.selected[0]?.label, "Option A");
|
||
});
|
||
|
||
test("selected custom option keeps its label and renders editor below", () => {
|
||
let state = createInitialState({
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Pick one",
|
||
options: [{ value: "a", label: "A" }],
|
||
},
|
||
],
|
||
});
|
||
state = applyNumberShortcut(state, 2);
|
||
|
||
const lines: string[] = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state,
|
||
theme: mockTheme(),
|
||
width: 80,
|
||
});
|
||
|
||
assert(lines.some((line) => line.includes("Type your own")));
|
||
assert(lines.some((line) => line.includes("Type answer...")));
|
||
});
|
||
|
||
test("open question note renders inline label and editor", () => {
|
||
const state = enterQuestionNoteMode(
|
||
createInitialState({
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Pick any extra things to include.",
|
||
options: [{ value: "a", label: "A" }],
|
||
},
|
||
],
|
||
}),
|
||
"q1"
|
||
);
|
||
|
||
const lines: string[] = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state,
|
||
theme: mockTheme(),
|
||
width: 80,
|
||
});
|
||
|
||
const promptIndex = lines.findIndex((line) =>
|
||
line.includes("Pick any extra things to include.")
|
||
);
|
||
const inputIndex = lines.findIndex((line) => line.includes("Add a note..."));
|
||
|
||
assert.notEqual(promptIndex, -1);
|
||
assert.equal(inputIndex, promptIndex + 1);
|
||
assert(!lines.some((line) => line.includes("Note:")));
|
||
});
|
||
|
||
test("open option note renders flush below the option", () => {
|
||
const state = enterOptionNoteMode(
|
||
createInitialState({
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Pick one",
|
||
options: [{ value: "a", label: "Option A" }],
|
||
},
|
||
],
|
||
}),
|
||
"q1",
|
||
"a"
|
||
);
|
||
|
||
const lines: string[] = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state,
|
||
theme: mockTheme(),
|
||
width: 80,
|
||
});
|
||
|
||
const optionIndex = lines.findIndex((line) => line.includes("Option A"));
|
||
const inputIndex = lines.findIndex((line) => line.includes("Add a note..."));
|
||
|
||
assert.notEqual(optionIndex, -1);
|
||
assert.equal(inputIndex, optionIndex + 1);
|
||
});
|
||
|
||
test("selected multiline custom option renders full editor block", () => {
|
||
let state = createInitialState({
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Pick one",
|
||
options: [{ value: "a", label: "A" }],
|
||
},
|
||
],
|
||
});
|
||
state = applyNumberShortcut(state, 2);
|
||
|
||
const lines: string[] = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor("first line\nsecond line", [
|
||
"┌────┐",
|
||
"first line",
|
||
"second line",
|
||
"└────┘",
|
||
]),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state,
|
||
theme: mockTheme(),
|
||
width: 80,
|
||
});
|
||
|
||
assert(lines.some((line) => line.includes("Type your own")));
|
||
assert(lines.some((line) => line.includes("first line")));
|
||
assert(lines.some((line) => line.includes("second line")));
|
||
assert(!lines.some((line) => line.includes("first line second line")));
|
||
});
|
||
|
||
test("freeform-only question renders label without numbering or pointer and separates input", () => {
|
||
const state = createInitialState(
|
||
{
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Type one",
|
||
options: [
|
||
{ value: "freeform", label: "Type answer", freeform: true },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{ allowFreeform: true }
|
||
);
|
||
|
||
const lines: string[] = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state: { ...state, view: { kind: "input", questionId: "q1" } },
|
||
theme: mockTheme(),
|
||
width: 80,
|
||
});
|
||
|
||
const labelIndex = lines.findIndex((line) =>
|
||
line.includes("Type your answer:")
|
||
);
|
||
const inputIndex = lines.findIndex((line) => line.includes("Type answer..."));
|
||
|
||
assert.notEqual(labelIndex, -1);
|
||
assert.equal(inputIndex, labelIndex + 2);
|
||
assert(lines[labelIndex]?.startsWith(" "));
|
||
assert(lines[inputIndex]?.startsWith(" "));
|
||
assert(!lines[inputIndex]?.startsWith(" "));
|
||
assert(!lines.some((line) => line.includes("1. Type your answer:")));
|
||
assert(!lines.some((line) => line.includes("❯")));
|
||
});
|
||
|
||
test("preview questions show custom and recommended options", () => {
|
||
const state = createInitialState({
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Pick one",
|
||
type: "preview",
|
||
options: [
|
||
{
|
||
value: "a",
|
||
label: "A",
|
||
preview: "Preview A",
|
||
recommended: true,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
const lines: string[] = [];
|
||
const calls: Array<{ color: string; text: string }> = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor(),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state,
|
||
theme: mockTheme((color, text) => calls.push({ color, text })),
|
||
width: 80,
|
||
});
|
||
|
||
assert(lines.some((line) => line.includes("Type your own")));
|
||
assert(lines.some((line) => line.trim() === "(recommended)"));
|
||
assert(
|
||
calls.some(
|
||
(call) => call.color === "warning" && call.text === "(recommended)"
|
||
)
|
||
);
|
||
assert(
|
||
!calls.some((call) => call.color === "muted" && call.text.startsWith(" |"))
|
||
);
|
||
assert.equal(state.answers.q1, undefined);
|
||
});
|
||
|
||
test("preview custom option reuses the normal inline editor", () => {
|
||
let state = createInitialState({
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Pick one",
|
||
type: "preview",
|
||
options: [{ value: "a", label: "A", preview: "Preview A" }],
|
||
},
|
||
],
|
||
});
|
||
state = applyNumberShortcut(state, 2);
|
||
|
||
const lines: string[] = [];
|
||
renderQuestionScreen({
|
||
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
|
||
lines,
|
||
options: getRenderableOptions(state.questions[0]),
|
||
question: state.questions[0],
|
||
state,
|
||
theme: mockTheme(),
|
||
width: 80,
|
||
});
|
||
|
||
assert(lines.some((line) => line.includes("Type your own")));
|
||
assert(lines.some((line) => line.includes("Type answer...")));
|
||
const optionIndex = lines.findIndex((line) => line.includes("Type your own"));
|
||
const inputIndex = lines.findIndex((line) => line.includes("Type answer..."));
|
||
assert.equal(inputIndex, optionIndex + 1);
|
||
assert(!lines.some((line) => line.includes("Preview A")));
|
||
});
|