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