vibe/tests/cli/test_question_app.py
Mathias Gesbert d50704e694
v2.18.2 (#857)
Co-authored-by: Cyprien <courtot.c@gmail.com>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Peter Evers <peter.evers@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Yousria <yousria.debaud@mistral.ai>
Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-06-29 17:35:54 +02:00

781 lines
25 KiB
Python

from __future__ import annotations
import pytest
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
@pytest.fixture
def single_question_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[
Choice(label="PostgreSQL", description="Relational DB"),
Choice(label="MongoDB", description="Document DB"),
],
)
]
)
@pytest.fixture
def multi_question_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
),
Question(
question="Which framework?",
header="Framework",
options=[Choice(label="FastAPI"), Choice(label="Django")],
),
]
)
@pytest.fixture
def multi_select_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which features?",
header="Features",
options=[
Choice(label="Auth"),
Choice(label="Caching"),
Choice(label="Logging"),
],
multi_select=True,
)
]
)
class TestQuestionAppState:
def test_init_state(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.current_question_idx == 0
assert app.selected_option == 0
assert len(app.answers) == 0
assert len(app.other_texts) == 0
def test_total_options_single_select(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
# 2 options + Other = 3 (no Submit for single-select)
assert app._total_options == 3
def test_total_options_multi_select_includes_submit(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# 3 options + Other + Submit = 5
assert app._total_options == 5
assert app._other_option_idx == 3
assert app._submit_option_idx == 4
def test_is_other_selected(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app._is_other_selected is False
app.selected_option = 2 # Other option
assert app._is_other_selected is True
def test_is_submit_selected(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
assert app._is_submit_selected is False
app.selected_option = 4 # Submit option
assert app._is_submit_selected is True
def test_is_submit_selected_false_for_single_select(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
# Even if selected_option is 3, is_submit_selected is False for single-select
app.selected_option = 3
assert app._is_submit_selected is False
def test_store_other_text_per_question(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
# Store text for question 0
app.other_texts[0] = "Custom DB"
# Switch to question 1
app.current_question_idx = 1
app.other_texts[1] = "Custom Framework"
# Verify both stored separately
assert app._get_other_text(0) == "Custom DB"
assert app._get_other_text(1) == "Custom Framework"
def test_save_regular_option_answer(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 0 # PostgreSQL
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "PostgreSQL"
assert is_other is False
def test_save_other_option_answer(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "SQLite"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "SQLite"
assert is_other is True
def test_save_other_option_empty_does_not_save(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "" # Empty
app._save_current_answer()
assert 0 not in app.answers
def test_all_answered_false_initially(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
assert app._all_answered() is False
def test_all_answered_true_when_complete(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
app.answers[0] = ("PostgreSQL", False)
app.answers[1] = ("FastAPI", False)
assert app._all_answered() is True
def test_multi_select_toggle(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# Initially no selections
assert len(app.multi_selections.get(0, set())) == 0
# Add selection
app.multi_selections.setdefault(0, set()).add(0)
assert 0 in app.multi_selections[0]
# Add another
app.multi_selections[0].add(2)
assert 2 in app.multi_selections[0]
# Remove first
app.multi_selections[0].discard(0)
assert 0 not in app.multi_selections[0]
assert 2 in app.multi_selections[0]
def test_multi_select_save_answer(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.multi_selections[0] = {0, 2} # Auth and Logging
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Logging" in answer_text
assert is_other is False
def test_multi_select_with_other(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.multi_selections[0] = {0, 3} # Auth and Other
app.other_texts[0] = "Custom Feature"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Custom Feature" in answer_text
assert is_other is True
class TestQuestionAppActions:
def test_action_move_down(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.selected_option == 0
app.action_move_down()
assert app.selected_option == 1
app.action_move_down()
assert app.selected_option == 2 # Other
app.action_move_down()
assert app.selected_option == 0 # Wraps around
def test_action_move_up(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.selected_option == 0
app.action_move_up()
assert app.selected_option == 2 # Wraps to Other
app.action_move_up()
assert app.selected_option == 1
def test_switch_question_preserves_other_text(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
app.other_texts[0] = "Text for Q1"
app._switch_question(1)
assert app.current_question_idx == 1
assert app._get_other_text(0) == "Text for Q1"
assert app._get_other_text(1) == ""
def test_switch_question_restores_cursor_to_answered_option(
self, multi_question_args
):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
# Answer question 0 with MongoDB (index 1)
app.answers[0] = ("MongoDB", False)
# Switch away then back
app._switch_question(1)
app._switch_question(0)
assert app.selected_option == 1
def test_switch_question_restores_cursor_to_other(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
# Answer question 0 with Other
app.answers[0] = ("Custom DB", True)
app._switch_question(1)
app._switch_question(0)
assert app.selected_option == len(app.questions[0].options)
assert app._is_other_selected
def test_switch_question_defaults_to_zero_if_unanswered(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
app._switch_question(1)
assert app.selected_option == 0
def test_switch_question_restores_cursor_multi_select(self):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
args = AskUserQuestionArgs(
questions=[
Question(
question="Q1?", options=[Choice(label="A"), Choice(label="B")]
),
Question(
question="Q2?",
options=[Choice(label="X"), Choice(label="Y"), Choice(label="Z")],
multi_select=True,
),
]
)
app = QuestionApp(args)
# Select Y (1) and Z (2) for question 1
app.multi_selections[1] = {1, 2}
app._switch_question(1)
assert app.selected_option == 1 # min of {1, 2}
class TestMultiSelectOtherBehavior:
def test_multi_select_other_does_not_advance_on_save(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.selected_option = 3 # Other option (3 options + Other)
app.other_texts[0] = "Custom feature"
# Save should not advance for multi-select
app._save_current_answer()
# Should stay on same question
assert app.current_question_idx == 0
def test_multi_select_other_toggle_adds_to_selections(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options) # 3
# Initially no selections
assert len(app.multi_selections.get(0, set())) == 0
# Add Other to selections
app.multi_selections.setdefault(0, set()).add(other_idx)
app.other_texts[0] = "Custom"
# Can still add regular options
app.multi_selections[0].add(0) # Auth
app.multi_selections[0].add(1) # Caching
assert other_idx in app.multi_selections[0]
assert 0 in app.multi_selections[0]
assert 1 in app.multi_selections[0]
def test_multi_select_save_with_other_and_regular_options(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Auth (0), Logging (2), and Other (3)
app.multi_selections[0] = {0, 2, other_idx}
app.other_texts[0] = "Custom Feature"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Logging" in answer_text
assert "Custom Feature" in answer_text
assert is_other is True
def test_multi_select_other_without_text_not_in_answer(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Auth (0) and Other (3) but no text for Other
app.multi_selections[0] = {0, other_idx}
app.other_texts[0] = ""
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
# Empty Other should not appear
assert is_other is False # No valid Other text
def test_multi_select_can_toggle_after_selecting_other(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Other first
app.multi_selections[0] = {other_idx}
app.other_texts[0] = "Custom"
# Now navigate to and toggle Auth
app.selected_option = 0
selections = app.multi_selections.setdefault(0, set())
selections.add(0) # Toggle Auth on
assert 0 in app.multi_selections[0]
assert other_idx in app.multi_selections[0]
# Toggle Auth off
selections.discard(0)
assert 0 not in app.multi_selections[0]
assert other_idx in app.multi_selections[0]
def test_multi_select_empty_selections_does_not_save(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# No selections
app._save_current_answer()
assert 0 not in app.answers
class TestSingleSelectOtherBehavior:
def test_single_select_other_with_text_saves(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "Custom DB"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "Custom DB"
assert is_other is True
def test_single_select_other_without_text_does_not_save(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = ""
app._save_current_answer()
assert 0 not in app.answers
def test_single_select_regular_option_saves_immediately(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 1 # MongoDB
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "MongoDB"
assert is_other is False
class TestMultiSelectAutoSelect:
def test_typing_auto_selects_other(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
app.other_input.value = "Custom text"
# Initially no selections
assert app._other_option_idx not in app.multi_selections.get(0, set())
# Simulate input change
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, "Custom text"))
# Other should now be selected
assert app._other_option_idx in app.multi_selections[0]
def test_clearing_auto_deselects_other(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
# Start with Other selected and text
app.multi_selections[0] = {app._other_option_idx}
app.other_input.value = "" # Cleared
# Simulate input change with empty value
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, ""))
# Other should now be deselected
assert app._other_option_idx not in app.multi_selections[0]
def test_auto_select_preserves_other_selections(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
app.other_input.value = "Custom"
# Pre-select Auth and Logging
app.multi_selections[0] = {0, 2}
# Simulate typing
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, "Custom"))
# All selections should be preserved plus Other
assert 0 in app.multi_selections[0]
assert 2 in app.multi_selections[0]
assert app._other_option_idx in app.multi_selections[0]
class TestNumberKeyShortcuts:
def test_number_key_selects_predefined_option(self, single_question_args):
from unittest.mock import patch
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 0
# Press "2" -> should select MongoDB (index 1) and save
event = events.Key("2", "2")
with patch.object(app, "_advance_or_submit"):
app._handle_number_key(event)
assert app.selected_option == 1
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "MongoDB"
assert is_other is False
def test_number_key_first_option(self, single_question_args):
from unittest.mock import patch
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
event = events.Key("1", "1")
with patch.object(app, "_advance_or_submit"):
app._handle_number_key(event)
assert app.selected_option == 0
assert 0 in app.answers
answer_text, _ = app.answers[0]
assert answer_text == "PostgreSQL"
def test_number_key_other_empty_focuses(self, single_question_args):
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
# Other is option index 2 -> key "3"
event = events.Key("3", "3")
app._handle_number_key(event)
# Should navigate to Other but not validate (no text)
assert app.selected_option == 2
assert app._is_other_selected is True
assert 0 not in app.answers
def test_number_key_other_with_text_focuses_without_validating(
self, single_question_args
):
from unittest.mock import MagicMock
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.other_texts[0] = "SQLite"
app.other_input = MagicMock()
app.other_input.has_focus = False
event = events.Key("3", "3")
app._handle_number_key(event)
# Should navigate to Other but not validate — just focus the input
assert app.selected_option == 2
assert app._is_other_selected is True
assert 0 not in app.answers
def test_number_key_out_of_range_ignored(self, single_question_args):
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
# 3 options total (2 + Other), so "4" is out of range
event = events.Key("4", "4")
app._handle_number_key(event)
assert app.selected_option == 0
assert 0 not in app.answers
def test_number_key_zero_ignored(self, single_question_args):
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
event = events.Key("0", "0")
app._handle_number_key(event)
assert app.selected_option == 0
assert 0 not in app.answers
def test_number_key_ignored_when_input_focused(self, single_question_args):
from unittest.mock import MagicMock
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.other_input = MagicMock()
app.other_input.has_focus = True
event = events.Key("1", "1")
app._handle_number_key(event)
assert app.selected_option == 0
assert 0 not in app.answers
def test_number_key_multi_select_toggles(self, multi_select_args):
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# Press "1" in multi-select -> should toggle Auth
event = events.Key("1", "1")
app._handle_number_key(event)
assert 0 in app.multi_selections.get(0, set())
def test_number_key_multi_select_submit_ignored(self, multi_select_args):
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# Submit is at index 4 -> key "5"
event = events.Key("5", "5")
app._handle_number_key(event)
# Should not navigate to submit
assert app.selected_option == 0
class TestMultiSelectSubmit:
def test_navigate_to_submit(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# Navigate down through all options to Submit
for _ in range(4): # 0->1->2->3(Other)->4(Submit)
app.action_move_down()
assert app.selected_option == 4
assert app._is_submit_selected is True
def test_submit_wraps_around(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.selected_option = 4 # Submit
app.action_move_down()
assert app.selected_option == 0
class TestVimKeybindings:
def test_j_moves_down(self, single_question_args):
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.selected_option == 0
app.on_key(events.Key("j", "j"))
assert app.selected_option == 1
def test_k_moves_up(self, single_question_args):
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.selected_option == 0
app.on_key(events.Key("k", "k"))
# Wraps to last option (Other = index 2)
assert app.selected_option == 2
def test_j_k_ignored_when_other_input_focused(self, single_question_args):
from unittest.mock import MagicMock
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.other_input = MagicMock()
app.other_input.has_focus = True
app.on_key(events.Key("j", "j"))
assert app.selected_option == 0
def test_j_moves_down_for_single_question(self):
# Vim navigation must fire before the len <= 1 early return in on_key.
from textual import events
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
args = AskUserQuestionArgs(
questions=[
Question(
question="Only question?",
header="Q",
options=[Choice(label="A"), Choice(label="B")],
)
]
)
app = QuestionApp(args)
assert app.selected_option == 0
app.on_key(events.Key("j", "j"))
assert app.selected_option == 1