From 02fe83ade1a20768cacd6828a93213aaf4905137 Mon Sep 17 00:00:00 2001 From: Pier CeccoPierangioliEugenio Date: Thu, 25 Dec 2025 16:29:29 +0000 Subject: [PATCH] chore(combobox): add tests (#570) --- libs/pyTermTk/TermTk/TTkWidgets/combobox.py | 124 +++- tests/pytest/test_helpers.py | 166 +++++ tests/pytest/widgets/test_combobox.py | 643 ++++++++++++++++++++ 3 files changed, 905 insertions(+), 28 deletions(-) create mode 100644 tests/pytest/test_helpers.py create mode 100644 tests/pytest/widgets/test_combobox.py diff --git a/libs/pyTermTk/TermTk/TTkWidgets/combobox.py b/libs/pyTermTk/TermTk/TTkWidgets/combobox.py index 0d97c13d..9d8e4a1e 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/combobox.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/combobox.py @@ -24,9 +24,7 @@ __all__ = ['TTkComboBox'] from typing import Dict,Any,List,Optional -from TermTk.TTkCore.cfg import TTkCfg from TermTk.TTkCore.constant import TTkK -from TermTk.TTkCore.log import TTkLog from TermTk.TTkCore.signal import pyTTkSlot, pyTTkSignal from TermTk.TTkCore.helper import TTkHelper from TermTk.TTkCore.string import TTkString @@ -35,7 +33,6 @@ from TermTk.TTkCore.canvas import TTkCanvas from TermTk.TTkCore.TTkTerm.inputkey import TTkKeyEvent from TermTk.TTkCore.TTkTerm.inputmouse import TTkMouseEvent from TermTk.TTkLayouts.gridlayout import TTkGridLayout -from TermTk.TTkWidgets.widget import TTkWidget from TermTk.TTkWidgets.container import TTkContainer from TermTk.TTkWidgets.list_ import TTkList from TermTk.TTkWidgets.lineedit import TTkLineEdit @@ -43,6 +40,40 @@ from TermTk.TTkWidgets.resizableframe import TTkResizableFrame class _TTkComboBoxPopup(TTkResizableFrame): + ''' _TTkComboBoxPopup: + + Internal popup widget for :py:class:`TTkComboBox` that displays a selectable list of items. + + :: + + ┌╼ Customized search component + │ + ┌╼ dolore ╾────────────────────┐ + │sed aute tempor in et deseru ▲│ + │cupidatat sit magna in cillum▓│ + │nostrud -- Zero --incididunt ▓│ + │dolore ▓│ + │dolore esse ullamco ┊│ + │est sunt issum dolore velit e┊│ + │irure nulla sed --Zeno-- aut▼│ + └──────────────────────────────┘ + + This widget extends :py:class:`TTkResizableFrame` and wraps a :py:class:`TTkList` widget, + providing a custom overlay display for combo box selections. Unlike the standard list widget, + this popup provides enhanced visual feedback for incremental search by overlaying the search + text at the top of the frame. + + The key customization over the legacy list widget behavior: + - Disables the default list search display (showSearch=False) + - Implements a custom paintEvent that renders the search text as an overlay + - Uses styled search text with custom colors (yellow by default) + - Displays truncation indicator (≼) when search text exceeds available width + - Positions search overlay at the top border of the frame (row 0) + + This approach allows better visual integration with the combo box frame borders + while maintaining the full search functionality of TTkList. + ''' + classStyle:Dict[str,Dict[str,Any]] = TTkResizableFrame.classStyle classStyle['default'] |= {'searchColor': TTkColor.fg("#FFFF00")} @@ -52,11 +83,18 @@ class _TTkComboBoxPopup(TTkResizableFrame): #exportedSignals 'textClicked') def __init__(self, *, items:list[str], **kwargs) -> None: + ''' + :param items: the list of items to display in the popup + :type items: list[str] + ''' super().__init__(**kwargs|{'layout':TTkGridLayout()}) + # Create internal list with search disabled - we'll render search text manually self._list:TTkList = TTkList(parent=self, showSearch=False) self._list.addItems(items) + # Trigger repaint when search changes to update our custom search overlay self._list.searchModified.connect(self.update) + # Export key list methods and signals for external use self.textClicked = self._list.textClicked self.setCurrentRow = self._list.setCurrentRow @@ -64,17 +102,30 @@ class _TTkComboBoxPopup(TTkResizableFrame): # self._list.viewport().setFocus() def keyEvent(self, evt:TTkKeyEvent) -> bool: + '''Forward all key events to the list viewport for navigation and search''' return self._list.viewport().keyEvent(evt) def paintEvent(self, canvas:TTkCanvas) -> None: + '''Custom paint event that overlays search text on top of the frame. + + This overrides the default behavior to provide a styled search text display + at the top of the popup frame, replacing the standard list widget search display. + The search text is rendered as an overlay with: + - Custom search color (yellow by default) + - Truncation indicator (≼) when text is too long + - Decorative borders (╼ ╾) around the search text + ''' super().paintEvent(canvas) + # Only render search overlay if there's active search text if str_text := self._list.search(): w = self.width()-6 color = self.currentStyle()['searchColor'] + # Truncate and show indicator if search text is too long if len(str_text) > w: text = TTkString("≼",TTkColor.BG_BLUE+TTkColor.FG_CYAN)+TTkString(str_text[-w+1:],color) else: text = TTkString(str_text,color) + # Draw search text overlay at the top of the frame canvas.drawText(pos=(1,0), text=f"╼ {text} ╾") canvas.drawTTkString(pos=(3,0), text=text) @@ -173,8 +224,17 @@ class TTkComboBox(TTkContainer): self.setMaximumHeight(1) def _lineEditChanged(self) -> None: + '''Internal callback triggered when line edit text changes. + + Handles text updates in editable mode by: + - Checking if the text matches an existing item + - Inserting new items based on the insert policy + - Emitting appropriate signals for index and text changes + ''' + if self._lineEdit is None: + return text = self._lineEdit.text().toAscii() - self._id=-1 + self._id = -1 if text in self._list: self._id = self._list.index(text) elif self._insertPolicy == TTkK.NoInsert: @@ -269,7 +329,7 @@ class TTkComboBox(TTkContainer): color = style['color'] borderColor = style['borderColor'] - if self._id == -1: + if self._id == -1 or self._id >= len(self._list): text = "- select -" else: text = self._list[self._id] @@ -313,12 +373,12 @@ class TTkComboBox(TTkContainer): :type index: int ''' if index < 0: return - if index > len(self._list)-1: return + if index >= len(self._list): return if self._id == index: return self._id = index - if self._lineEdit is not None: - self._lineEdit.setText(self._list[self._id]) - else: + if self._id >= 0: + if self._lineEdit is not None: + self._lineEdit.setText(self._list[self._id]) self.currentTextChanged.emit(self._list[self._id]) self.currentIndexChanged.emit(self._id) self.update() @@ -334,19 +394,20 @@ class TTkComboBox(TTkContainer): if self._lineEdit is not None: self.setEditText(text) else: - if text not in self._list: - id = 0 - else: + if text in self._list: id = self._list.index(text) - self.setCurrentIndex(id) + self.setCurrentIndex(id) + elif len(self._list) > 0: + # Text not found, select first item + self.setCurrentIndex(0) @pyTTkSlot(str) - def setEditText(self, text:TTkString) -> None: + def setEditText(self, text) -> None: ''' Set the text in the :py:class:`TTkLineEdit` widget - :param text: - :type text: :py:class:`TTkString` + :param text: the text to set (str or TTkString) + :type text: str, :py:class:`TTkString` ''' if self._lineEdit is not None: self._lineEdit.setText(text) @@ -387,8 +448,11 @@ class TTkComboBox(TTkContainer): ''' if editable: if self._lineEdit is None: - self._lineEdit = TTkLineEdit(parent=self) + self._lineEdit = TTkLineEdit(parent=self, hint=' - select - ') self._lineEdit.returnPressed.connect(self._lineEditChanged) + # Initialize line edit with current selected text + if self._id >= 0 and self._id < len(self._list): + self._lineEdit.setText(self._list[self._id]) self.setFocusPolicy(TTkK.ClickFocus) else: if self._lineEdit is not None: @@ -400,6 +464,13 @@ class TTkComboBox(TTkContainer): @pyTTkSlot(str) def _callback(self, label:str) -> None: + '''Internal callback when an item is selected from the popup list. + + Updates the combobox selection, closes the popup, and restores focus. + + :param label: the selected item text + :type label: str + ''' if self._lineEdit is not None: self._lineEdit.setText(label) self.setCurrentIndex(self._list.index(label)) @@ -409,6 +480,14 @@ class TTkComboBox(TTkContainer): self.update() def _pressEvent(self) -> bool: + '''Internal method to display the popup list overlay. + + Creates and shows the popup frame with the list of items, + positioning it as an overlay on top of the combobox. + + :return: True to indicate event was handled + :rtype: bool + ''' frameHeight = len(self._list) + 2 frameWidth = self.width() if frameHeight > 20: frameHeight = 20 @@ -420,17 +499,6 @@ class TTkComboBox(TTkContainer): self._popupFrame.setCurrentRow(self._id) self._popupFrame.textClicked.connect(self._callback) self.update() - - # self._popupFrame = TTkResizableFrame(layout=TTkGridLayout(), size=(frameWidth,frameHeight)) - # TTkHelper.overlay(self, self._popupFrame, 0, 0) - # listw = TTkList(parent=self._popupFrame) - # # TTkLog.debug(f"{self._list}") - # listw.addItems(self._list) - # if self._id != -1: - # listw.setCurrentRow(self._id) - # listw.textClicked.connect(self._callback) - # listw.viewport().setFocus() - # self.update() return True def wheelEvent(self, evt:TTkMouseEvent) -> bool: diff --git a/tests/pytest/test_helpers.py b/tests/pytest/test_helpers.py new file mode 100644 index 00000000..6e1aa8c5 --- /dev/null +++ b/tests/pytest/test_helpers.py @@ -0,0 +1,166 @@ +# MIT License +# +# Copyright (c) 2025 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Helper utilities for pyTermTk tests. +""" + +from typing import Any, List, Tuple + + +class MockSlot: + """ + A mock slot for testing signal-slot connections. + + Tracks all calls and allows querying call count and arguments. + + Example: + ms = mock_slot(int, str) + widget.someSignal.connect(ms) + + # Trigger signal + widget.doSomething() + + # Check calls + assert ms.called() == 1 + assert ms.arg(0) == 123 + assert ms.arg(1) == "test" + assert ms.args() == [(123, "test")] + """ + + def __init__(self, *arg_types): + """ + Initialize mock slot with expected argument types. + + :param arg_types: Expected types for the signal arguments (for documentation only) + :type arg_types: type + """ + self._arg_types = arg_types + self._calls: List[Tuple[Any, ...]] = [] + + def __call__(self, *args): + """ + Called when the signal is emitted. + + :param args: Arguments passed by the signal + """ + self._calls.append(args) + + def called(self) -> int: + """ + Get the number of times this slot was called. + + :return: Number of calls + :rtype: int + """ + return len(self._calls) + + def arg(self, index: int, call_index: int = -1) -> Any: + """ + Get a specific argument from a specific call. + + :param index: Index of the argument (0-based) + :type index: int + :param call_index: Index of the call (-1 for last call) + :type call_index: int + :return: The argument value + :rtype: Any + :raises IndexError: If call_index or index is out of range + """ + if not self._calls: + raise IndexError("No calls recorded") + return self._calls[call_index][index] + + def args(self, call_index: int = -1) -> Tuple[Any, ...]: + """ + Get all arguments from a specific call. + + :param call_index: Index of the call (-1 for last call) + :type call_index: int + :return: Tuple of all arguments from that call + :rtype: tuple + :raises IndexError: If call_index is out of range + """ + if not self._calls: + raise IndexError("No calls recorded") + return self._calls[call_index] + + def all_args(self) -> List[Tuple[Any, ...]]: + """ + Get all arguments from all calls. + + :return: List of tuples, each containing arguments from one call + :rtype: list[tuple] + """ + return self._calls.copy() + + def reset(self): + """ + Reset the call history. + """ + self._calls.clear() + + def assert_called(self, times: int = None): + """ + Assert that the slot was called a specific number of times. + + :param times: Expected number of calls (None = at least once) + :type times: int, optional + :raises AssertionError: If assertion fails + """ + if times is None: + assert self._calls, "Expected at least one call, but slot was never called" + else: + assert len(self._calls) == times, f"Expected {times} calls, but got {len(self._calls)}" + + def assert_not_called(self): + """ + Assert that the slot was never called. + + :raises AssertionError: If slot was called + """ + assert not self._calls, f"Expected no calls, but got {len(self._calls)}" + + def assert_called_with(self, *args, call_index: int = -1): + """ + Assert that a specific call was made with specific arguments. + + :param args: Expected arguments + :param call_index: Index of the call to check (-1 for last call) + :type call_index: int + :raises AssertionError: If arguments don't match + """ + if not self._calls: + raise AssertionError("Expected call with arguments, but slot was never called") + actual = self._calls[call_index] + assert actual == args, f"Expected call with {args}, but got {actual}" + + def assert_any_call(self, *args): + """ + Assert that at least one call was made with specific arguments. + + :param args: Expected arguments + :raises AssertionError: If no matching call found + """ + assert args in self._calls, f"Expected at least one call with {args}, but not found in {self._calls}" + + diff --git a/tests/pytest/widgets/test_combobox.py b/tests/pytest/widgets/test_combobox.py new file mode 100644 index 00000000..5fa916a0 --- /dev/null +++ b/tests/pytest/widgets/test_combobox.py @@ -0,0 +1,643 @@ +# MIT License +# +# Copyright (c) 2025 Eugenio Parodi +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys, os +from unittest.mock import Mock, patch + +sys.path.append(os.path.join(sys.path[0],'../../../libs/pyTermTk')) +import TermTk as ttk + +# Import test helpers +sys.path.insert(0, os.path.join(sys.path[0],'..')) +from test_helpers import MockSlot + + +# ============================================================================ +# TTkComboBox Initialization Tests +# ============================================================================ + +def test_combobox_init_empty(): + ''' + Test creating an empty combobox. + ''' + combo = ttk.TTkComboBox() + assert combo.currentIndex() == -1 + assert combo.currentText() == "" + assert not combo.isEditable() + + +def test_combobox_init_with_list(): + ''' + Test creating a combobox with a list of items. + ''' + items = ["Item 1", "Item 2", "Item 3"] + combo = ttk.TTkComboBox(list=items) + assert combo.currentIndex() == -1 + assert combo.currentText() == "" + + +def test_combobox_init_with_index(): + ''' + Test creating a combobox with a specific initial index. + ''' + items = ["Item 1", "Item 2", "Item 3"] + combo = ttk.TTkComboBox(list=items, index=1) + assert combo.currentIndex() == 1 + + +def test_combobox_init_editable(): + ''' + Test creating an editable combobox. + ''' + combo = ttk.TTkComboBox(editable=True) + assert combo.isEditable() + assert combo.lineEdit() is not None + + +def test_combobox_init_text_align(): + ''' + Test creating a combobox with specific text alignment. + ''' + combo = ttk.TTkComboBox(textAlign=ttk.TTkK.LEFT_ALIGN) + assert combo.textAlign() == ttk.TTkK.LEFT_ALIGN + + +def test_combobox_init_insert_policy(): + ''' + Test creating a combobox with specific insert policy. + ''' + combo = ttk.TTkComboBox(insertPolicy=ttk.TTkK.InsertAtTop) + assert combo.insertPolicy() == ttk.TTkK.InsertAtTop + + +# ============================================================================ +# TTkComboBox Non-Editable Mode Tests +# ============================================================================ + +def test_combobox_add_item(): + ''' + Test adding a single item to the combobox. + ''' + combo = ttk.TTkComboBox() + combo.addItem("Item 1") + assert combo.currentIndex() == -1 + + combo.setCurrentIndex(0) + assert combo.currentText() == "Item 1" + + +def test_combobox_add_items(): + ''' + Test adding multiple items to the combobox. + ''' + combo = ttk.TTkComboBox() + items = ["Item 1", "Item 2", "Item 3"] + combo.addItems(items) + + combo.setCurrentIndex(0) + assert combo.currentText() == "Item 1" + combo.setCurrentIndex(2) + assert combo.currentText() == "Item 3" + + +def test_combobox_clear(): + ''' + Test clearing all items from the combobox. + ''' + items = ["Item 1", "Item 2", "Item 3"] + combo = ttk.TTkComboBox(list=items, index=1) + + combo.clear() + assert combo.currentIndex() == -1 + assert combo.currentText() == "" + + +def test_combobox_current_index(): + ''' + Test getting and setting current index. + ''' + items = ["Item 1", "Item 2", "Item 3"] + combo = ttk.TTkComboBox(list=items) + + assert combo.currentIndex() == -1 + + combo.setCurrentIndex(0) + assert combo.currentIndex() == 0 + + combo.setCurrentIndex(2) + assert combo.currentIndex() == 2 + + # Test invalid indices (should not change) + combo.setCurrentIndex(10) + assert combo.currentIndex() == 2 + + combo.setCurrentIndex(-5) + assert combo.currentIndex() == 2 + + +def test_combobox_current_text(): + ''' + Test getting and setting current text. + ''' + items = ["Apple", "Banana", "Cherry"] + combo = ttk.TTkComboBox(list=items) + + combo.setCurrentIndex(1) + assert combo.currentText() == "Banana" + + combo.setCurrentText("Cherry") + assert combo.currentText() == "Cherry" + assert combo.currentIndex() == 2 + + +def test_combobox_text_align(): + ''' + Test text alignment getter and setter. + ''' + combo = ttk.TTkComboBox() + + # Default alignment + assert combo.textAlign() == ttk.TTkK.CENTER_ALIGN + + combo.setTextAlign(ttk.TTkK.LEFT_ALIGN) + assert combo.textAlign() == ttk.TTkK.LEFT_ALIGN + + combo.setTextAlign(ttk.TTkK.RIGHT_ALIGN) + assert combo.textAlign() == ttk.TTkK.RIGHT_ALIGN + + +def test_combobox_insert_policy(): + ''' + Test insert policy getter and setter. + ''' + combo = ttk.TTkComboBox() + + # Default policy + assert combo.insertPolicy() == ttk.TTkK.InsertAtBottom + + combo.setInsertPolicy(ttk.TTkK.InsertAtTop) + assert combo.insertPolicy() == ttk.TTkK.InsertAtTop + + combo.setInsertPolicy(ttk.TTkK.NoInsert) + assert combo.insertPolicy() == ttk.TTkK.NoInsert + + +# ============================================================================ +# TTkComboBox Editable Mode Tests +# ============================================================================ + +def test_combobox_editable_mode(): + ''' + Test switching between editable and non-editable modes. + ''' + combo = ttk.TTkComboBox() + + # Initially non-editable + assert not combo.isEditable() + assert combo.lineEdit() is None + + # Make editable + combo.setEditable(True) + assert combo.isEditable() + assert combo.lineEdit() is not None + + # Make non-editable again + combo.setEditable(False) + assert not combo.isEditable() + assert combo.lineEdit() is None + + +def test_combobox_editable_init_with_selection(): + ''' + Test that making a combobox editable initializes line edit with current selection. + ''' + items = ["Apple", "Banana", "Cherry"] + combo = ttk.TTkComboBox(list=items, index=1) + + # Make editable - line edit should show current selection + combo.setEditable(True) + assert combo.lineEdit().text() == "Banana" + + +def test_combobox_editable_set_edit_text(): + ''' + Test setting text in the line edit widget. + ''' + items = ["Apple", "Banana", "Cherry"] + combo = ttk.TTkComboBox(list=items, editable=True) + + combo.setEditText("New Text") + assert combo.lineEdit().text() == "New Text" + + +def test_combobox_editable_current_text(): + ''' + Test that currentText returns line edit text in editable mode. + ''' + items = ["Apple", "Banana", "Cherry"] + combo = ttk.TTkComboBox(list=items, editable=True, index=0) + + # Initially shows selected item + assert combo.currentText() == "Apple" + + # Change line edit text + combo.lineEdit().setText("Custom") + assert combo.currentText() == "Custom" + + +def test_combobox_editable_insert_policy_no_insert(): + ''' + Test that NoInsert policy doesn't add new items. + ''' + items = ["Apple", "Banana"] + combo = ttk.TTkComboBox(list=items, editable=True, insertPolicy=ttk.TTkK.NoInsert) + + combo.lineEdit().setText("Cherry") + combo._lineEditChanged() # Simulate return key + + # Index should be -1 (not found) and item not added + assert combo.currentIndex() == -1 + + +def test_combobox_editable_insert_policy_bottom(): + ''' + Test that InsertAtBottom policy adds new items at the end. + ''' + items = ["Apple", "Banana"] + combo = ttk.TTkComboBox(list=items, editable=True, insertPolicy=ttk.TTkK.InsertAtBottom) + + combo.lineEdit().setText("Cherry") + combo._lineEditChanged() # Simulate return key + + # New item should be added at the bottom + assert combo.currentIndex() == 2 + assert combo.currentText() == "Cherry" + + +def test_combobox_editable_insert_policy_top(): + ''' + Test that InsertAtTop policy adds new items at the beginning. + ''' + items = ["Apple", "Banana"] + combo = ttk.TTkComboBox(list=items, editable=True, insertPolicy=ttk.TTkK.InsertAtTop) + + combo.lineEdit().setText("Cherry") + combo._lineEditChanged() # Simulate return key + + # New item should be added at the top + assert combo.currentIndex() == 0 + assert combo.currentText() == "Cherry" + + +def test_combobox_editable_existing_item(): + ''' + Test that typing an existing item name selects it. + ''' + items = ["Apple", "Banana", "Cherry"] + combo = ttk.TTkComboBox(list=items, editable=True, insertPolicy=ttk.TTkK.InsertAtBottom) + + combo.lineEdit().setText("Banana") + combo._lineEditChanged() # Simulate return key + + # Should select existing item, not add duplicate + assert combo.currentIndex() == 1 + assert combo.currentText() == "Banana" + + +def test_combobox_editable_clear(): + ''' + Test that clearing an editable combobox also clears the line edit. + ''' + items = ["Apple", "Banana"] + combo = ttk.TTkComboBox(list=items, editable=True, index=0) + + combo.clear() + assert combo.lineEdit().text() == "" + assert combo.currentIndex() == -1 + + +# ============================================================================ +# TTkComboBox Signal Tests +# ============================================================================ + +def test_combobox_signal_current_index_changed(): + ''' + Test that currentIndexChanged signal is emitted when index changes. + ''' + items = ["Item 1", "Item 2", "Item 3"] + combo = ttk.TTkComboBox(list=items) + + mock_slot = MockSlot(int) + combo.currentIndexChanged.connect(mock_slot) + + combo.setCurrentIndex(1) + assert mock_slot.called() == 1 + assert mock_slot.arg(0) == 1 + + combo.setCurrentIndex(2) + assert mock_slot.called() == 2 + assert mock_slot.arg(0) == 2 # Last call + mock_slot.assert_called_with(2) + + +def test_combobox_signal_current_text_changed(): + ''' + Test that currentTextChanged signal is emitted when text changes. + ''' + items = ["Apple", "Banana", "Cherry"] + combo = ttk.TTkComboBox(list=items) + + mock_slot = MockSlot(str) + combo.currentTextChanged.connect(mock_slot) + + combo.setCurrentIndex(0) + assert mock_slot.called() == 1 + assert mock_slot.arg(0) == "Apple" + + combo.setCurrentIndex(2) + assert mock_slot.called() == 2 + assert mock_slot.arg(0) == "Cherry" + mock_slot.assert_called_with("Cherry") + + +def test_combobox_signal_edit_text_changed(): + ''' + Test that editTextChanged signal is emitted in editable mode. + ''' + items = ["Apple", "Banana"] + combo = ttk.TTkComboBox(list=items, editable=True, insertPolicy=ttk.TTkK.InsertAtBottom) + + mock_slot = MockSlot(str) + combo.editTextChanged.connect(mock_slot) + + combo.lineEdit().setText("Cherry") + combo._lineEditChanged() # Simulate return key + + assert mock_slot.called() == 1 + assert mock_slot.arg(0) == "Cherry" + mock_slot.assert_called_with("Cherry") + + +def test_combobox_signal_no_duplicate_emission(): + ''' + Test that signals are not emitted when setting the same index. + ''' + items = ["Item 1", "Item 2", "Item 3"] + combo = ttk.TTkComboBox(list=items, index=1) + + mock_slot = MockSlot(int) + combo.currentIndexChanged.connect(mock_slot) + + # Setting same index should not emit + combo.setCurrentIndex(1) + mock_slot.assert_not_called() + + +def test_combobox_multiple_signal_connections(): + ''' + Test that multiple slots can be connected to the same signal. + ''' + items = ["Item 1", "Item 2"] + combo = ttk.TTkComboBox(list=items) + + ms1 = MockSlot(int) + ms2 = MockSlot(int) + + combo.currentIndexChanged.connect(ms1) + combo.currentIndexChanged.connect(ms2) + + combo.setCurrentIndex(0) + + assert ms1.called() == 1 + assert ms1.arg(0) == 0 + assert ms2.called() == 1 + assert ms2.arg(0) == 0 + + +def test_combobox_signal_disconnect(): + ''' + Test disconnecting signals. + ''' + items = ["Item 1", "Item 2"] + combo = ttk.TTkComboBox(list=items) + + mock_slot = MockSlot(int) + combo.currentIndexChanged.connect(mock_slot) + + combo.setCurrentIndex(0) + assert mock_slot.called() == 1 + assert mock_slot.arg(0) == 0 + + combo.currentIndexChanged.disconnect(mock_slot) + combo.setCurrentIndex(1) + + # Should still only be called once (from before disconnect) + assert mock_slot.called() == 1 + ''' + Test operations on an empty combobox. + ''' + combo = ttk.TTkComboBox() + + assert combo.currentIndex() == -1 + assert combo.currentText() == "" + + # Setting index on empty list should not crash + combo.setCurrentIndex(0) + assert combo.currentIndex() == -1 + + +def test_combobox_boundary_indices(): + ''' + Test boundary conditions for index setting. + ''' + items = ["Item 1", "Item 2", "Item 3"] + combo = ttk.TTkComboBox(list=items, index=1) + + # Test negative index + combo.setCurrentIndex(-1) + assert combo.currentIndex() == 1 # Should not change + + # Test index equal to length + combo.setCurrentIndex(3) + assert combo.currentIndex() == 1 # Should not change + + # Test large index + combo.setCurrentIndex(100) + assert combo.currentIndex() == 1 # Should not change + + +def test_combobox_text_not_in_list(): + ''' + Test setting text that doesn't exist in the list (non-editable mode). + ''' + items = ["Apple", "Banana", "Cherry"] + combo = ttk.TTkComboBox(list=items, index=1) + + combo.setCurrentText("Durian") + # Should select first item as fallback + assert combo.currentIndex() == 0 + + +def test_combobox_editable_mode_with_empty_list(): + ''' + Test editable mode operations with an initially empty list. + ''' + combo = ttk.TTkComboBox(editable=True, insertPolicy=ttk.TTkK.InsertAtBottom) + + combo.lineEdit().setText("First Item") + combo._lineEditChanged() + + assert combo.currentIndex() == 0 + assert combo.currentText() == "First Item" + + +def test_combobox_update_called_on_changes(): + ''' + Test that update() is called when making changes. + ''' + items = ["Item 1", "Item 2"] + combo = ttk.TTkComboBox(list=items) + + with patch.object(combo, 'update') as mock_update: + combo.setCurrentIndex(0) + mock_update.assert_called() + + with patch.object(combo, 'update') as mock_update: + combo.addItem("Item 3") + mock_update.assert_called() + + with patch.object(combo, 'update') as mock_update: + combo.setTextAlign(ttk.TTkK.LEFT_ALIGN) + mock_update.assert_called() + + +def test_combobox_focus_policy(): + ''' + Test that focus policy changes based on editable state. + ''' + combo = ttk.TTkComboBox() + + # Non-editable should have ClickFocus and TabFocus + assert combo.focusPolicy() & ttk.TTkK.ClickFocus + assert combo.focusPolicy() & ttk.TTkK.TabFocus + + # Editable should only have ClickFocus + combo.setEditable(True) + assert combo.focusPolicy() & ttk.TTkK.ClickFocus + # Note: TabFocus behavior may vary based on implementation + + +# ============================================================================ +# TTkComboBox Integration Tests +# ============================================================================ + +def test_combobox_full_workflow_non_editable(): + ''' + Test a complete workflow with non-editable combobox. + ''' + combo = ttk.TTkComboBox() + + # Add items + combo.addItems(["Red", "Green", "Blue"]) + + # Track signals + index_changes = [] + text_changes = [] + + combo.currentIndexChanged.connect(lambda i: index_changes.append(i)) + combo.currentTextChanged.connect(lambda t: text_changes.append(t)) + + # Select first item + combo.setCurrentIndex(0) + assert combo.currentText() == "Red" + assert index_changes == [0] + assert text_changes == ["Red"] + + # Select by text + combo.setCurrentText("Blue") + assert combo.currentIndex() == 2 + assert len(index_changes) == 2 + assert len(text_changes) == 2 + + # Add more items + combo.addItem("Yellow") + combo.setCurrentIndex(3) + assert combo.currentText() == "Yellow" + + +def test_combobox_full_workflow_editable(): + ''' + Test a complete workflow with editable combobox. + ''' + combo = ttk.TTkComboBox(editable=True, insertPolicy=ttk.TTkK.InsertAtBottom) + + # Add initial items + combo.addItems(["Cat", "Dog"]) + + # Track signals + index_changes = [] + text_changes = [] + edit_changes = [] + + combo.currentIndexChanged.connect(lambda i: index_changes.append(i)) + combo.currentTextChanged.connect(lambda t: text_changes.append(t)) + combo.editTextChanged.connect(lambda t: edit_changes.append(t)) + + # Select existing item + combo.setCurrentIndex(0) + assert combo.currentText() == "Cat" + + # Add new item via line edit + combo.lineEdit().setText("Bird") + combo._lineEditChanged() + + assert combo.currentIndex() == 2 + assert combo.currentText() == "Bird" + assert "Bird" in edit_changes + + # Select existing item via line edit + combo.lineEdit().setText("Dog") + combo._lineEditChanged() + + assert combo.currentIndex() == 1 + assert combo.currentText() == "Dog" + + +def test_combobox_switch_editable_preserves_selection(): + ''' + Test that switching to editable mode preserves the current selection. + ''' + items = ["Apple", "Banana", "Cherry"] + combo = ttk.TTkComboBox(list=items, index=1) + + # Check initial state + assert combo.currentText() == "Banana" + + # Make editable - should preserve selection + combo.setEditable(True) + assert combo.lineEdit().text() == "Banana" + assert combo.currentText() == "Banana" + + # Make non-editable again + combo.setEditable(False) + assert combo.currentIndex() == 1 + assert combo.currentText() == "Banana"