Browse Source

chore(combobox): add tests (#570)

pull/573/head
Pier CeccoPierangioliEugenio 3 months ago committed by GitHub
parent
commit
02fe83ade1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 124
      libs/pyTermTk/TermTk/TTkWidgets/combobox.py
  2. 166
      tests/pytest/test_helpers.py
  3. 643
      tests/pytest/widgets/test_combobox.py

124
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:

166
tests/pytest/test_helpers.py

@ -0,0 +1,166 @@
# MIT License
#
# Copyright (c) 2025 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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}"

643
tests/pytest/widgets/test_combobox.py

@ -0,0 +1,643 @@
# MIT License
#
# Copyright (c) 2025 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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"
Loading…
Cancel
Save