feat: add question type presentation controls
This commit is contained in:
@@ -19,7 +19,7 @@ test("config migration framework accepts the current schema version", () => {
|
||||
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
|
||||
});
|
||||
|
||||
test("config migration maps previous-version config files into v4 shape", () => {
|
||||
test("config migration maps previous-version config files into current shape", () => {
|
||||
const legacyKeymaps = {
|
||||
cancel: "q",
|
||||
confirm: "ctrl+k",
|
||||
@@ -77,6 +77,34 @@ test("config migration maps previous-version config files into v4 shape", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("config migration adds v5 defaults to v4 configs", () => {
|
||||
const v4MainKeymaps = Object.fromEntries(
|
||||
Object.entries(DEFAULT_ASK_CONFIG.keymaps.main).filter(
|
||||
([key]) => key !== "changeQuestionType"
|
||||
)
|
||||
);
|
||||
const result = migrateAskConfig({
|
||||
schemaVersion: 4,
|
||||
answer: DEFAULT_ASK_CONFIG.answer,
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
confirmDismissWhenDirty: true,
|
||||
doublePressReviewShortcuts: true,
|
||||
showFooterHints: false,
|
||||
},
|
||||
keymaps: {
|
||||
...DEFAULT_ASK_CONFIG.keymaps,
|
||||
main: v4MainKeymaps,
|
||||
},
|
||||
notifications: DEFAULT_ASK_CONFIG.notifications,
|
||||
});
|
||||
|
||||
assert.equal(result.migrated, true);
|
||||
assert.equal(result.config.behaviour.presentSingleAsMulti, false);
|
||||
assert.equal(result.config.behaviour.showFooterHints, false);
|
||||
assert.deepEqual(result.config.keymaps.main.changeQuestionType, ["t"]);
|
||||
});
|
||||
|
||||
test("config migration maps legacy flat keymaps into context keymaps", () => {
|
||||
const result = migrateAskConfig({
|
||||
schemaVersion: 3,
|
||||
|
||||
@@ -13,7 +13,7 @@ function expectedConfigFile(
|
||||
overrides: { behaviour?: typeof DEFAULT_ASK_CONFIG.behaviour } = {}
|
||||
) {
|
||||
return {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
answer: DEFAULT_ASK_CONFIG.answer,
|
||||
behaviour: overrides.behaviour ?? DEFAULT_ASK_CONFIG.behaviour,
|
||||
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||
@@ -48,9 +48,8 @@ test("config store writes full normalized config on save", async () => {
|
||||
|
||||
await store.save({
|
||||
behaviour: {
|
||||
...DEFAULT_ASK_CONFIG.behaviour,
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
confirmDismissWhenDirty: true,
|
||||
doublePressReviewShortcuts: true,
|
||||
showFooterHints: false,
|
||||
},
|
||||
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||
@@ -61,9 +60,8 @@ test("config store writes full normalized config on save", async () => {
|
||||
JSON.parse(content),
|
||||
expectedConfigFile({
|
||||
behaviour: {
|
||||
...DEFAULT_ASK_CONFIG.behaviour,
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
confirmDismissWhenDirty: true,
|
||||
doublePressReviewShortcuts: true,
|
||||
showFooterHints: false,
|
||||
},
|
||||
})
|
||||
@@ -158,9 +156,8 @@ test("config store migrates the legacy root config path into extensions", async
|
||||
JSON.parse(await readFile(path, "utf-8")),
|
||||
expectedConfigFile({
|
||||
behaviour: {
|
||||
...DEFAULT_ASK_CONFIG.behaviour,
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
confirmDismissWhenDirty: true,
|
||||
doublePressReviewShortcuts: true,
|
||||
showFooterHints: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -190,6 +190,35 @@ test("question mark opens ask settings outside non-empty editors", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("question type shortcut uses configured main keymap", () => {
|
||||
const state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Question?",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const config = {
|
||||
...DEFAULT_ASK_CONFIG,
|
||||
keymaps: {
|
||||
...DEFAULT_ASK_CONFIG.keymaps,
|
||||
main: {
|
||||
...DEFAULT_ASK_CONFIG.keymaps.main,
|
||||
changeQuestionType: ["ctrl+t"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "t"), {
|
||||
kind: "changeQuestionType",
|
||||
});
|
||||
assert.deepEqual(getInputCommand(state, config, "\u0014"), {
|
||||
kind: "changeQuestionType",
|
||||
});
|
||||
});
|
||||
|
||||
test("note shortcuts use n for option notes and Shift+N for question notes", () => {
|
||||
const navigation = createInitialState({
|
||||
questions: [
|
||||
|
||||
@@ -225,8 +225,9 @@ test("footer hints wrap into exact lines on narrow screens", () => {
|
||||
editor: mockEditor(),
|
||||
});
|
||||
|
||||
assert.deepEqual(lines.slice(-5, -1), [
|
||||
assert.deepEqual(lines.slice(-6, -1), [
|
||||
"Space toggle",
|
||||
"t question type",
|
||||
"Enter continue",
|
||||
"N/Shift+N note",
|
||||
"Esc dismiss · ? settings",
|
||||
@@ -253,7 +254,8 @@ test("footer keeps earlier hint chunk on the first wrapped line", () => {
|
||||
editor: mockEditor(),
|
||||
});
|
||||
|
||||
assert.equal(lines.at(-6), " ⇆ tab · ↑↓ select");
|
||||
assert.equal(lines.at(-7), " ⇆ tab · ↑↓ select");
|
||||
assert.equal(lines.at(-6), "t question type");
|
||||
assert.deepEqual(lines.slice(-5, -1), [
|
||||
"Enter confirm",
|
||||
"N/Shift+N note",
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
applyNumberShortcut,
|
||||
enterOptionNoteMode,
|
||||
enterQuestionNoteMode,
|
||||
moveOption,
|
||||
moveTab,
|
||||
saveNote,
|
||||
submitCustomAnswer,
|
||||
toggleCurrentMultiOption,
|
||||
} from "../src/state/transitions.ts";
|
||||
|
||||
test("summarizeResult formats selected answers", () => {
|
||||
@@ -34,6 +36,104 @@ test("summarizeResult formats selected answers", () => {
|
||||
assert.equal(renderResultText(result), "✓ Style: Rich");
|
||||
});
|
||||
|
||||
test("single questions presented as multi preserve requested type in results", () => {
|
||||
let state = createInitialState(
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
id: "scope",
|
||||
label: "Scope",
|
||||
prompt: "What scope?",
|
||||
options: [
|
||||
{ value: "docs", label: "Docs" },
|
||||
{ value: "tests", label: "Tests" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ presentSingleAsMulti: true }
|
||||
);
|
||||
|
||||
state = toggleCurrentMultiOption(state);
|
||||
state = moveOption(state, 1);
|
||||
state = toggleCurrentMultiOption(state);
|
||||
const result = toAskResult(state);
|
||||
|
||||
assert.deepEqual(result.questions, [
|
||||
{
|
||||
id: "scope",
|
||||
label: "Scope",
|
||||
prompt: "What scope?",
|
||||
type: "single",
|
||||
presentedType: "multi",
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
summarizeResult(result),
|
||||
"Scope: Docs, Tests\nNote: Some questions were presented as multi-select by user preference."
|
||||
);
|
||||
assert.equal(
|
||||
renderResultText(result),
|
||||
"✓ Scope: Docs, Tests\n Note: Some questions were presented as multi-select by user preference."
|
||||
);
|
||||
});
|
||||
|
||||
test("presentation override summary uses one compact note", () => {
|
||||
const result = {
|
||||
cancelled: false,
|
||||
mode: "submit" as const,
|
||||
questions: [
|
||||
{
|
||||
id: "apiStyle",
|
||||
label: "Single Select",
|
||||
prompt: "Pick one API style.",
|
||||
type: "single" as const,
|
||||
presentedType: "multi" as const,
|
||||
},
|
||||
{
|
||||
id: "docsSections",
|
||||
label: "Multi Select",
|
||||
prompt: "Which docs sections should be updated?",
|
||||
type: "multi" as const,
|
||||
},
|
||||
{
|
||||
id: "layout",
|
||||
label: "Preview",
|
||||
prompt: "Choose a layout with preview content.",
|
||||
type: "preview" as const,
|
||||
presentedType: "multi" as const,
|
||||
},
|
||||
],
|
||||
answers: {
|
||||
apiStyle: {
|
||||
values: ["rest", "graphql"],
|
||||
labels: ["REST", "GraphQL"],
|
||||
indices: [1, 2],
|
||||
},
|
||||
docsSections: {
|
||||
values: ["readme", "contract", "architecture"],
|
||||
labels: ["README", "Contract", "Architecture"],
|
||||
indices: [1, 2, 3],
|
||||
},
|
||||
layout: {
|
||||
values: ["compact", "detailed"],
|
||||
labels: ["Compact", "Detailed"],
|
||||
indices: [1, 2],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
summarizeResult(result),
|
||||
[
|
||||
"Single Select: REST, GraphQL",
|
||||
"Multi Select: README, Contract, Architecture",
|
||||
"Preview: Compact, Detailed",
|
||||
"Note: Some questions were presented as multi-select by user preference.",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
test("summaries include custom text answers", () => {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
|
||||
@@ -13,6 +13,7 @@ const savedConfig: AskConfig = {
|
||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||
confirmDismissWhenDirty: true,
|
||||
doublePressReviewShortcuts: true,
|
||||
presentSingleAsMulti: false,
|
||||
showFooterHints: true,
|
||||
},
|
||||
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||
@@ -65,9 +66,12 @@ test("settings list renders behaviour settings and config path", () => {
|
||||
|
||||
assert(text.includes("╭"));
|
||||
assert(text.includes("@eko24ive/pi-ask"));
|
||||
assert(text.includes("Live settings"));
|
||||
assert(text.includes("Defaults for future asks"));
|
||||
assert(text.includes("Auto-submit when answered without notes"));
|
||||
assert(text.includes("[off]"));
|
||||
assert(text.includes("Confirm dismiss when dirty"));
|
||||
assert(text.includes("Present single-select as multi-select"));
|
||||
assert(text.includes("on"));
|
||||
assert(text.includes("Edit this config file to customize"));
|
||||
assert(text.includes("keymaps"));
|
||||
@@ -107,6 +111,7 @@ test("settings list saves behaviour changes immediately without success feedback
|
||||
assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true);
|
||||
assert.equal(saved?.behaviour.confirmDismissWhenDirty, true);
|
||||
assert.equal(saved?.behaviour.doublePressReviewShortcuts, true);
|
||||
assert.equal(saved?.behaviour.presentSingleAsMulti, false);
|
||||
assert.equal(saved?.behaviour.showFooterHints, true);
|
||||
assert.equal(text.includes("Saved"), false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createInitialState } from "../src/state/create.ts";
|
||||
import { cycleCurrentQuestionType } from "../src/state/question-type.ts";
|
||||
|
||||
const DUPLICATE_QUESTION_ID_RE = /duplicate question id "scope"/;
|
||||
const DUPLICATE_OPTION_VALUE_RE = /duplicate option value "small"/;
|
||||
@@ -298,6 +299,140 @@ test("single-select space toggles the active option without advancing", () => {
|
||||
assert.equal(state.view.kind, "navigate");
|
||||
});
|
||||
|
||||
test("presentSingleAsMulti handles requested single questions as multi-select", () => {
|
||||
let state = createInitialState(sampleParams(), {
|
||||
presentSingleAsMulti: true,
|
||||
});
|
||||
|
||||
assert.equal(state.questions[0].type, "multi");
|
||||
assert.equal(state.questions[0].requestedType, "single");
|
||||
assert.equal(state.questions[0].presentedType, "multi");
|
||||
|
||||
state = toggleCurrentMultiOption(state);
|
||||
state = moveOption(state, 1);
|
||||
state = toggleCurrentMultiOption(state);
|
||||
|
||||
assert.equal(state.activeTabIndex, 0);
|
||||
assert.deepEqual(
|
||||
state.answers.lang.selected.map((selection) => selection.label),
|
||||
["Python", "TypeScript"]
|
||||
);
|
||||
});
|
||||
|
||||
test("question type hotkey toggles single and multi while preserving requested type", () => {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Pick one",
|
||||
options: [
|
||||
{ value: "a", label: "A", preview: "Preview A" },
|
||||
{ value: "b", label: "B", preview: "Preview B" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let result = cycleCurrentQuestionType(state);
|
||||
state = result.state;
|
||||
assert.equal(result.needsConfirmation, false);
|
||||
assert.equal(state.questions[0].type, "multi");
|
||||
assert.equal(state.questions[0].requestedType, "single");
|
||||
assert.equal(state.questions[0].presentedType, "multi");
|
||||
|
||||
result = cycleCurrentQuestionType(state);
|
||||
state = result.state;
|
||||
assert.equal(state.questions[0].type, "single");
|
||||
assert.equal(state.questions[0].presentedType, undefined);
|
||||
});
|
||||
|
||||
test("question type hotkey toggles preview questions with multi only", () => {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Pick one",
|
||||
type: "preview",
|
||||
options: [{ value: "a", label: "A", preview: "Preview A" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let result = cycleCurrentQuestionType(state);
|
||||
state = result.state;
|
||||
|
||||
assert.equal(result.needsConfirmation, false);
|
||||
assert.equal(state.questions[0].type, "multi");
|
||||
assert.equal(state.questions[0].requestedType, "preview");
|
||||
assert.equal(state.questions[0].presentedType, "multi");
|
||||
|
||||
result = cycleCurrentQuestionType(state);
|
||||
state = result.state;
|
||||
|
||||
assert.equal(result.needsConfirmation, false);
|
||||
assert.equal(state.questions[0].type, "preview");
|
||||
assert.equal(state.questions[0].requestedType, "preview");
|
||||
assert.equal(state.questions[0].presentedType, undefined);
|
||||
});
|
||||
|
||||
test("question type hotkey requires confirmation before clearing multi answers", () => {
|
||||
let state = createInitialState(sampleParams(), {
|
||||
presentSingleAsMulti: true,
|
||||
});
|
||||
state = toggleCurrentMultiOption(state);
|
||||
state = moveOption(state, 1);
|
||||
state = toggleCurrentMultiOption(state);
|
||||
|
||||
let result = cycleCurrentQuestionType(state);
|
||||
assert.equal(result.needsConfirmation, true);
|
||||
assert.deepEqual(
|
||||
result.state.answers.lang.selected.map((selection) => selection.value),
|
||||
["py", "ts"]
|
||||
);
|
||||
|
||||
result = cycleCurrentQuestionType(state, { confirmed: true });
|
||||
state = result.state;
|
||||
assert.equal(result.needsConfirmation, false);
|
||||
assert.equal(state.questions[0].type, "single");
|
||||
assert.equal(state.questions[0].presentedType, undefined);
|
||||
assert.equal(state.answers.lang, undefined);
|
||||
});
|
||||
|
||||
test("confirmed multi-to-single type change preserves custom text and notes", () => {
|
||||
let state = createInitialState(
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Pick options",
|
||||
type: "multi",
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ presentSingleAsMulti: true }
|
||||
);
|
||||
state = toggleCurrentMultiOption(state);
|
||||
state = submitCustomAnswer(
|
||||
{ ...state, view: { kind: "input", questionId: "q1" } },
|
||||
"Custom"
|
||||
);
|
||||
state = enterQuestionNoteMode(state, "q1");
|
||||
state = saveNote(state, "Keep note");
|
||||
|
||||
const result = cycleCurrentQuestionType(state, { confirmed: true });
|
||||
state = result.state;
|
||||
|
||||
assert.equal(state.questions[0].type, "single");
|
||||
assert.deepEqual(state.answers.q1.selected, []);
|
||||
assert.equal(state.answers.q1.customText, "Custom");
|
||||
assert.equal(state.answers.q1.customSelected, true);
|
||||
assert.equal(state.answers.q1.note, "Keep note");
|
||||
});
|
||||
|
||||
test("submit can complete even when some questions are unanswered", () => {
|
||||
let state = createInitialState(sampleParams());
|
||||
state = moveTab(state, -1);
|
||||
|
||||
Reference in New Issue
Block a user