From 33256b61d8c320f1ef5e5718bf31f4c4cf3e9786 Mon Sep 17 00:00:00 2001 From: Pier CeccoPierangioliEugenio Date: Thu, 23 Oct 2025 00:07:29 +0100 Subject: [PATCH] fix(TTktable): crash for empty tables (#481) --- .github/workflows/testing.yml | 2 +- .../TermTk/TTkAbstract/abstracttablemodel.py | 2 +- .../TTkWidgets/TTkModelView/tablemodellist.py | 16 +- .../TTkModelView/tablemodelsqlite3.py | 4 +- .../TTkWidgets/TTkModelView/tablewidget.py | 28 +- ...tablemodelcsv.py => test_tablemodelcsv.py} | 0 ...blemodellist.py => test_tablemodellist.py} | 278 ++++--- ...elsqlite3.py => test_tablemodelsqlite3.py} | 410 ++++++++++- tests/pytest/modelView/test_tablewidget.py | 690 ++++++++++++++++++ tests/t.ui/test.ui.032.table.12.py | 4 +- ...i.032.table.13.alignment.02.overloading.py | 25 +- ...test.ui.032.table.13.alignment.03.mixin.py | 25 +- 12 files changed, 1322 insertions(+), 162 deletions(-) rename tests/pytest/modelView/{test_003_tablemodelcsv.py => test_tablemodelcsv.py} (100%) rename tests/pytest/modelView/{test_001_tablemodellist.py => test_tablemodellist.py} (57%) rename tests/pytest/modelView/{test_002_tablemodelsqlite3.py => test_tablemodelsqlite3.py} (67%) create mode 100644 tests/pytest/modelView/test_tablewidget.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 8f93f965..aac395cf 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -36,7 +36,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v3 diff --git a/libs/pyTermTk/TermTk/TTkAbstract/abstracttablemodel.py b/libs/pyTermTk/TermTk/TTkAbstract/abstracttablemodel.py index b0e698a6..8235093b 100644 --- a/libs/pyTermTk/TermTk/TTkAbstract/abstracttablemodel.py +++ b/libs/pyTermTk/TermTk/TTkAbstract/abstracttablemodel.py @@ -183,7 +183,7 @@ class TTkAbstractTableModel(): ''' return _TTkModelIndexList(row,col,self) - def data(self, row:int, col:int) -> object: + def data(self, row:int, col:int) -> Any: ''' Returns the data stored for the item referred to by the row/column. diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablemodellist.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablemodellist.py index 62c1814b..19c22ac0 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablemodellist.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablemodellist.py @@ -22,6 +22,8 @@ __all__=['TTkTableModelList'] +from typing import Any + from TermTk.TTkCore.constant import TTkK from TermTk.TTkAbstract.abstracttablemodel import TTkAbstractTableModel, TTkModelIndex @@ -68,7 +70,7 @@ class TTkTableModelList(TTkAbstractTableModel): :param indexes: the index labels, defaults to the line number. :type indexes: list[str], optional ''' - self._data = self._dataOriginal = data if data else [['']] + self._data = self._dataOriginal = data if data else [] self._hheader = header if header else [] self._vheader = indexes if indexes else [] super().__init__() @@ -93,8 +95,12 @@ class TTkTableModelList(TTkAbstractTableModel): rowId = self._data[row] , rowCb = lambda rid: self._data.index(rid) ) - def data(self, row:int, col:int) -> None: - return self._data[row][col] + def data(self, row:int, col:int) -> Any: + if ( row < 0 or col <0 or + row >= len(self._data) or + col>=len(col_data:=self._data[row]) ): + return None + return col_data[col] def setData(self, row:int, col:int, data:object) -> None: self._data[row][col] = data @@ -146,13 +152,13 @@ class TTkTableModelList(TTkAbstractTableModel): for _l in self._data: _l[column:column+count] = [] # Signal: from (0, column) with size (all rows, all remaining columns from removal point) - self.dataChanged.emit((0,column),(self.rowCount(), self.columnCount() - column)) + self.dataChanged.emit((0,column),(self.rowCount(), self.columnCount() - column + 1)) self.modelChanged.emit() return True def removeRows(self, row:int, count:int) -> bool: self._data[row:row+count] = [] # Signal: from (row, 0) with size (all remaining rows from removal point, all columns) - self.dataChanged.emit((row,0),(self.rowCount() - row, self.columnCount())) + self.dataChanged.emit((row,0),(self.rowCount() - row + 1, self.columnCount())) self.modelChanged.emit() return True diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablemodelsqlite3.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablemodelsqlite3.py index ae1b1c55..698a7d73 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablemodelsqlite3.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablemodelsqlite3.py @@ -143,7 +143,7 @@ class TTkTableModelSQLite3(TTkAbstractTableModel): f"SELECT {self._key} FROM {self._table} " f"{self._sort} " f"LIMIT 1 OFFSET {row}") - key=res.fetchone()[0] + key = None if not (_fetch:=res.fetchone()) else _fetch[0] return _TTkModelIndexSQLite3(col=col,rowId=key,sqModel=self) def data(self, row:int, col:int) -> Any: @@ -152,7 +152,7 @@ class TTkTableModelSQLite3(TTkAbstractTableModel): f"SELECT {self._columns[col]} FROM {self._table} " f"{self._sort} " f"LIMIT 1 OFFSET {row}") - return res.fetchone()[0] + return None if not (_fetch:=res.fetchone()) else _fetch[0] def setData(self, row:int, col:int, data:object) -> None: with self._sqliteMutex: diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablewidget.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablewidget.py index 5111db24..382a255c 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablewidget.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/TTkModelView/tablewidget.py @@ -83,7 +83,9 @@ class _ClipboardTable(TTkString): def data(self) -> list: return self._data - def _toTTkString(self) -> str: + def _toTTkString(self) -> TTkString: + if not self._data: + return TTkString() def _lineHeight(_line): return max(len(str(_item[2]).split('\n')) for _item in _line) ret = [] @@ -446,11 +448,13 @@ class TTkTableWidget(TTkAbstractScrollView): data = self._clipboard.text() self.pasteEvent(data) - def pasteEvent(self, data:object): + def pasteEvent(self, data:object) -> bool: row,col = self._currentPos if self._currentPos else (0,0) - if isinstance(data,_ClipboardTable): - rows = self._tableModel.rowCount() - cols = self._tableModel.columnCount() + rows = self._tableModel.rowCount() + cols = self._tableModel.columnCount() + if not rows or not cols: + return + if isinstance(data,_ClipboardTable) and data.data(): dataList = [] linearData = [_item for _line in data.data() for _item in _line] minx,maxx = min(_a:=[_item[1] for _item in linearData]),max(_a) @@ -561,8 +565,8 @@ class TTkTableWidget(TTkAbstractScrollView): self._snapshotId = 0 rows = self._tableModel.rowCount() cols = self._tableModel.columnCount() - self._vHeaderSize = vhs = 1+max(len(self._tableModel.headerData(_p, TTkK.VERTICAL)) for _p in range(rows) ) - self._hHeaderSize = hhs = 1 + self._vHeaderSize = vhs = 0 if not rows else 1+max(len(self._tableModel.headerData(_p, TTkK.VERTICAL)) for _p in range(rows) ) + self._hHeaderSize = hhs = 0 if not rows else 1 self.setPadding(hhs,0,vhs,0) if self._showVSeparators: self._colsPos = [(1+x)*11 for x in range(cols)] @@ -583,8 +587,8 @@ class TTkTableWidget(TTkAbstractScrollView): showHH = self._horizontallHeader.isVisible() hhs = self._hHeaderSize if showHH else 0 vhs = self._vHeaderSize if showVH else 0 - w = vhs+self._colsPos[-1]+1 - h = hhs+self._rowsPos[-1]+1 + w = vhs+(self._colsPos[-1] if self._colsPos else 0)+1 + h = hhs+(self._rowsPos[-1] if self._rowsPos else 0)+1 return w,h def clearSelection(self) -> None: @@ -846,6 +850,8 @@ class TTkTableWidget(TTkAbstractScrollView): :param width: its width :type width: int ''' + if column < 0 or column >= len(self._colsPos): + return i = column prevPos = self._colsPos[i-1] if i>0 else -1 if self._showVSeparators: @@ -930,7 +936,7 @@ class TTkTableWidget(TTkAbstractScrollView): cola,colb = max(0,col-30), min(col+30,cols) else: cola,colb = 0,cols - return max(_hei(i) for i in range(cola,colb)) + return 0 if cola>=colb else max(_hei(i) for i in range(cola,colb)) @pyTTkSlot(int) def resizeRowToContents(self, row:int) -> None: @@ -1512,6 +1518,8 @@ class TTkTableWidget(TTkAbstractScrollView): rows = self._tableModel.rowCount() cols = self._tableModel.columnCount() + if not rows or not cols: + return rp = self._rowsPos cp = self._colsPos diff --git a/tests/pytest/modelView/test_003_tablemodelcsv.py b/tests/pytest/modelView/test_tablemodelcsv.py similarity index 100% rename from tests/pytest/modelView/test_003_tablemodelcsv.py rename to tests/pytest/modelView/test_tablemodelcsv.py diff --git a/tests/pytest/modelView/test_001_tablemodellist.py b/tests/pytest/modelView/test_tablemodellist.py similarity index 57% rename from tests/pytest/modelView/test_001_tablemodellist.py rename to tests/pytest/modelView/test_tablemodellist.py index 40cf9b34..745965f3 100644 --- a/tests/pytest/modelView/test_001_tablemodellist.py +++ b/tests/pytest/modelView/test_tablemodellist.py @@ -47,9 +47,9 @@ class TestTTkTableModelList: """Test default initialization""" model = ttk.TTkTableModelList() - assert model.rowCount() == 1 - assert model.columnCount() == 1 - assert model.data(0, 0) == '' + assert model.rowCount() == 0 + assert model.columnCount() == 0 + assert model.data(0, 0) == None def test_init_with_data(self): """Test initialization with data""" @@ -213,7 +213,7 @@ class TestTTkTableModelList: def test_insert_rows(self): """Test insertRows method""" - model = ttk.TTkTableModelList(data=self.test_data.copy()) + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) # Deep copy original_count = model.rowCount() # Insert 2 rows at position 1 @@ -222,7 +222,7 @@ class TestTTkTableModelList: assert result == True assert model.rowCount() == original_count + 2 - # Check that None values were inserted + # Check that empty string values were inserted assert model.data(1, 0) == '' assert model.data(1, 1) == '' assert model.data(1, 2) == '' @@ -235,16 +235,16 @@ class TestTTkTableModelList: def test_insert_columns(self): """Test insertColumns method""" - model = ttk.TTkTableModelList(data=[row.copy() for row in self.test_data]) + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) # Deep copy original_column_count = model.columnCount() # Insert 2 columns at position 1 result = model.insertColumns(1, 2) - assert result == True # Implementation returns True + assert result == True assert model.columnCount() == original_column_count + 2 - # Check that None values were inserted at the right position + # Check that empty string values were inserted at the right position assert model.data(0, 1) == '' # New column 1 assert model.data(0, 2) == '' # New column 2 assert model.data(0, 3) == 25 # Original column 1 shifted to position 3 @@ -256,13 +256,13 @@ class TestTTkTableModelList: def test_remove_columns(self): """Test removeColumns method""" - model = ttk.TTkTableModelList(data=[row.copy() for row in self.test_data]) + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) # Deep copy original_column_count = model.columnCount() # Remove 1 column at position 1 (Age column) result = model.removeColumns(1, 1) - assert result == True # Changed from False to True + assert result == True assert model.columnCount() == original_column_count - 1 # Check that the middle column was removed @@ -273,125 +273,223 @@ class TestTTkTableModelList: def test_remove_rows(self): """Test removeRows method""" - model = ttk.TTkTableModelList(data=[row.copy() for row in self.test_data]) + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) # Deep copy original_row_count = model.rowCount() # Remove 1 row at position 1 (Bob's row) result = model.removeRows(1, 1) - assert result == True # Changed from False to True + assert result == True assert model.rowCount() == original_row_count - 1 # Check that the middle row was removed and data shifted assert model.data(0, 0) == 'Alice' # First row unchanged assert model.data(1, 0) == 'Charlie' # Charlie moved to position 1 - def test_insert_columns_signal_emission(self): - """Test that dataChanged signal is emitted when inserting columns""" - model = ttk.TTkTableModelList(data=[row.copy() for row in self.test_data]) + def test_remove_rows_boundary_conditions(self): + """Test removeRows method with boundary conditions and invalid ranges""" + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) # Deep copy + original_row_count = model.rowCount() - # Mock the signal to track emissions - signal_mock = Mock() - @ttk.pyTTkSlot(tuple[int,int], tuple[int,int]) - def _mock_signal(pos: tuple[int,int], size: tuple[int,int]): - signal_mock(pos, size) - model.dataChanged.connect(_mock_signal) + # Test removing rows at the edge (last row) + result = model.removeRows(2, 1) # Remove Charlie's row (index 2) + assert result == True + assert model.rowCount() == original_row_count - 1 + assert model.data(0, 0) == 'Alice' + assert model.data(1, 0) == 'Bob' + # Charlie should be gone + assert model.data(2, 0) == None + + # Reset for next test + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) + + # Test removing rows starting beyond valid range + try: + result = model.removeRows(5, 1) # Start beyond data bounds + # Should either return False or handle gracefully + # The behavior depends on implementation - some might allow it + except IndexError: + pass # This is acceptable behavior + + # Test removing rows with count that goes beyond data bounds + result = model.removeRows(1, 10) # Remove more rows than exist + assert result == True + # Should remove rows 1 and 2 (Bob and Charlie), leaving only Alice + assert model.rowCount() == 1 + assert model.data(0, 0) == 'Alice' - # Insert columns - model.insertColumns(1, 2) + # Reset for next test + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) - # Verify signal was emitted with correct parameters - # Expected: start at (0,1), size is (rowCount, originalColumnCount-1) - signal_mock.assert_called_once_with((0, 1), (3, 4)) # 3 rows, 4 remaining columns + # Test removing rows with negative start index + try: + result = model.removeRows(-1, 1) + # Behavior may vary - could be handled gracefully or raise error + except (IndexError, ValueError): + pass # Expected for invalid index - def test_insert_rows_signal_emission(self): - """Test that dataChanged signal is emitted when inserting rows""" - model = ttk.TTkTableModelList(data=[row.copy() for row in self.test_data]) + # Test removing zero rows + result = model.removeRows(1, 0) + assert result == True + assert model.rowCount() == original_row_count # Should be unchanged - # Mock the signal to track emissions - signal_mock = Mock() - @ttk.pyTTkSlot(tuple[int,int], tuple[int,int]) - def _mock_signal(pos: tuple[int,int], size: tuple[int,int]): - signal_mock(pos, size) - model.dataChanged.connect(_mock_signal) + def test_remove_columns_boundary_conditions(self): + """Test removeColumns method with boundary conditions and invalid ranges""" + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) # Deep copy + original_column_count = model.columnCount() - # Insert rows - model.insertRows(1, 2) + # Test removing columns at the edge (last column) + result = model.removeColumns(2, 1) # Remove Role column (index 2) + assert result == True + assert model.columnCount() == original_column_count - 1 + assert model.data(0, 0) == 'Alice' # Name still there + assert model.data(0, 1) == 25 # Age still there + assert model.data(0, 2) == None # Role should be gone + + # Reset for next test + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) + + # Test removing columns starting beyond valid range + try: + result = model.removeColumns(5, 1) # Start beyond data bounds + # Should either return False or handle gracefully + except IndexError: + pass # This is acceptable behavior + + # Test removing columns with count that goes beyond data bounds + result = model.removeColumns(1, 10) # Remove more columns than exist + assert result == True + # Should remove columns 1 and 2 (Age and Role), leaving only Name + assert model.columnCount() == 1 + assert model.data(0, 0) == 'Alice' + assert model.data(0, 1) == None # Age should be gone - # Verify signal was emitted - # Expected: start at (1,0), size is (originalRowCount-1, columnCount) - signal_mock.assert_called_once_with((1, 0), (4, 3)) # 4 remaining rows, 3 columns + # Reset for next test + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) - def test_remove_columns_signal_emission(self): - """Test that dataChanged signal is emitted when removing columns""" - model = ttk.TTkTableModelList(data=[row.copy() for row in self.test_data]) + # Test removing columns with negative start index + try: + result = model.removeColumns(-1, 1) + # Behavior may vary - could be handled gracefully or raise error + except (IndexError, ValueError): + pass # Expected for invalid index - # Mock the signal to track emissions - signal_mock = Mock() - @ttk.pyTTkSlot(tuple[int,int], tuple[int,int]) - def _mock_signal(pos: tuple[int,int], size: tuple[int,int]): - signal_mock(pos, size) - model.dataChanged.connect(_mock_signal) + # Test removing zero columns + result = model.removeColumns(1, 0) + assert result == True + assert model.columnCount() == original_column_count # Should be unchanged + + def test_remove_operations_on_empty_model(self): + """Test remove operations on empty model""" + model = ttk.TTkTableModelList(data=[]) + + # Test removing rows from empty model + try: + result = model.removeRows(0, 1) + # Should handle gracefully or return False + except IndexError: + pass # Acceptable behavior + + # Test removing columns from empty model + try: + result = model.removeColumns(0, 1) + # Should handle gracefully or return False + except IndexError: + pass # Acceptable behavior + + def test_remove_operations_on_single_cell_model(self): + """Test remove operations on single cell model""" + model = ttk.TTkTableModelList(data=[['single']]) + + # Remove the only row + result = model.removeRows(0, 1) + assert result == True + assert model.rowCount() == 0 + assert model.columnCount() == 0 # Column structure might remain + + # Reset for column test + model = ttk.TTkTableModelList(data=[['single']]) - # Remove columns - model.removeColumns(1, 1) + # Remove the only column + result = model.removeColumns(0, 1) + assert result == True + assert model.columnCount() == 0 + assert model.rowCount() == 1 # Row structure might remain + + def test_remove_all_rows(self): + """Test removing all rows from model""" + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) + + # Remove all rows at once + result = model.removeRows(0, model.rowCount()) + assert result == True + assert model.rowCount() == 0 - # Verify signal was emitted - signal_mock.assert_called_once_with((0, 1), (3, 1)) + def test_remove_all_columns(self): + """Test removing all columns from model""" + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) - def test_remove_rows_signal_emission(self): - """Test that dataChanged signal is emitted when removing rows""" - model = ttk.TTkTableModelList(data=[row.copy() for row in self.test_data]) + # Remove all columns at once + result = model.removeColumns(0, model.columnCount()) + assert result == True + assert model.columnCount() == 0 + + def test_remove_operations_signal_emission_boundary_cases(self): + """Test signal emission for boundary cases in remove operations""" + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) # Mock the signal to track emissions signal_mock = Mock() @ttk.pyTTkSlot(tuple[int,int], tuple[int,int]) - def _mock_signal(pos: tuple[int,int], size: tuple[int,int]): + def _mock_signal(pos: tuple, size: tuple): signal_mock(pos, size) model.dataChanged.connect(_mock_signal) - # Remove rows - model.removeRows(1, 1) + # Remove last row + model.removeRows(2, 1) + # Should emit signal for the removal + assert signal_mock.call_count >= 1 + + # Reset mock and model + signal_mock.reset_mock() + model = ttk.TTkTableModelList(data=[row[:] for row in self.test_data]) + model.dataChanged.connect(_mock_signal) - # Verify signal was emitted - signal_mock.assert_called_once_with((1, 0), (1, 3)) + # Remove last column + model.removeColumns(2, 1) + # Should emit signal for the removal + assert signal_mock.call_count >= 1 - def test_sort_updates_original_data(self): - """Test that sorting affects _dataOriginal reference correctly""" + def test_remove_operations_preserve_data_integrity(self): + """Test that remove operations preserve data integrity""" original_data = [ - ['Charlie', 35, 'Manager'], - ['Alice', 25, 'Engineer'], - ['Bob', 30, 'Designer'] + ['A', 'B', 'C', 'D'], + ['E', 'F', 'G', 'H'], + ['I', 'J', 'K', 'L'], + ['M', 'N', 'O', 'P'] ] - model = ttk.TTkTableModelList(data=original_data) - - # Sort by name - model.sort(0, ttk.TTkK.SortOrder.AscendingOrder) - sorted_data = model.modelList() - assert sorted_data[0][0] == 'Alice' + model = ttk.TTkTableModelList(data=[row[:] for row in original_data]) - # Reset should go back to original order - model.sort(-1, ttk.TTkK.SortOrder.AscendingOrder) - reset_data = model.modelList() - assert reset_data == original_data + # Remove middle rows (1 and 2) + model.removeRows(1, 2) - def test_edge_case_empty_list_initialization(self): - """Test edge case with empty list""" - model = ttk.TTkTableModelList(data=[]) + # Verify remaining data is correct + assert model.rowCount() == 2 + assert model.data(0, 0) == 'A' # First row unchanged + assert model.data(1, 0) == 'M' # Last row shifted up - # With empty data, it should create default [[']] - assert model.rowCount() == 1 - assert model.columnCount() == 1 - assert model.data(0, 0) == '' + # Reset and test column removal + model = ttk.TTkTableModelList(data=[row[:] for row in original_data]) - def test_edge_case_none_data_initialization(self): - """Test edge case with None data""" - model = ttk.TTkTableModelList(data=None) + # Remove middle columns (1 and 2) + model.removeColumns(1, 2) - # With None data, it should create default [['']] - assert model.rowCount() == 1 - assert model.columnCount() == 1 - assert model.data(0, 0) == '' + # Verify remaining data is correct + assert model.columnCount() == 2 + assert model.data(0, 0) == 'A' # First column unchanged + assert model.data(0, 1) == 'D' # Last column shifted left + assert model.data(1, 0) == 'E' + assert model.data(1, 1) == 'H' def test_integration_with_table_widget(): """Integration test - verify TTkTableModelList works with table widgets""" diff --git a/tests/pytest/modelView/test_002_tablemodelsqlite3.py b/tests/pytest/modelView/test_tablemodelsqlite3.py similarity index 67% rename from tests/pytest/modelView/test_002_tablemodelsqlite3.py rename to tests/pytest/modelView/test_tablemodelsqlite3.py index dbe798d6..aed18a5a 100644 --- a/tests/pytest/modelView/test_002_tablemodelsqlite3.py +++ b/tests/pytest/modelView/test_tablemodelsqlite3.py @@ -751,49 +751,407 @@ class TestTTkTableModelSQLite3: for j in range(model.columnCount()): assert model.data(i, j) == original_data[i][j] + def test_empty_table_initialization(self): + """Test initialization with an empty table""" + # Create empty table + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE empty_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + age INTEGER + ) + ''') + conn.commit() + conn.close() + + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='empty_users') + + assert model.rowCount() == 0 + assert model.columnCount() == 2 # name, age (id is primary key) + + # Test data access on empty table + assert model.data(0, 0) == None or model.data(0, 0) is None + + # Test header data still works + assert model.headerData(0, ttk.TTkK.HORIZONTAL) == 'name' + assert model.headerData(1, ttk.TTkK.HORIZONTAL) == 'age' + + def test_empty_table_operations(self): + """Test operations on empty table""" + # Create empty table + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE empty_test ( + id INTEGER PRIMARY KEY, + value TEXT + ) + ''') + conn.commit() + conn.close() -def test_integration_with_table_widget(): - """Integration test with TTkTable widget""" - # Create temporary database - temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False) - temp_db_path = temp_db.name - temp_db.close() + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='empty_test') - try: - # Set up database - conn = sqlite3.connect(temp_db_path) + # Test insert on empty table + result = model.insertRows(0, 1) + assert result == True + assert model.rowCount() == 1 + + # Test data setting on newly inserted row + result = model.setData(0, 0, 'test_value') + assert result == True + assert model.data(0, 0) == 'test_value' + + # Test remove from single-row table + result = model.removeRows(0, 1) + assert result == True + assert model.rowCount() == 0 + + def test_empty_table_invalid_operations(self): + """Test invalid operations on empty table""" + # Create empty table + conn = sqlite3.connect(self.temp_db_path) cur = conn.cursor() cur.execute(''' - CREATE TABLE test_table ( + CREATE TABLE empty_invalid ( + id INTEGER PRIMARY KEY, + data TEXT + ) + ''') + conn.commit() + conn.close() + + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='empty_invalid') + + # Test invalid remove operations on empty table + result = model.removeRows(0, 1) + assert result == False + + result = model.removeRows(-1, 1) + assert result == False + + result = model.removeRows(1, 1) + assert result == False + + # Test invalid insert operations + result = model.insertRows(-1, 1) + assert result == False + + result = model.insertRows(1, 1) # Beyond row count + assert result == False + + def test_empty_table_sorting(self): + """Test sorting operations on empty table""" + # Create empty table + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE empty_sort ( id INTEGER PRIMARY KEY, name TEXT, value INTEGER ) ''') + conn.commit() + conn.close() - cur.executemany('INSERT INTO test_table (name, value) VALUES (?, ?)', [ - ('Item1', 100), - ('Item2', 200) - ]) + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='empty_sort') + + # Test sorting on empty table (should not crash) + model.sort(0, ttk.TTkK.SortOrder.AscendingOrder) + assert model.rowCount() == 0 + model.sort(1, ttk.TTkK.SortOrder.DescendingOrder) + assert model.rowCount() == 0 + + # Reset sort + model.sort(-1, ttk.TTkK.SortOrder.AscendingOrder) + assert model.rowCount() == 0 + + def test_remove_all_rows_from_populated_table(self): + """Test removing all rows from a populated table""" + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='users') + + initial_count = model.rowCount() + assert initial_count == 4 + + # Remove all rows at once + result = model.removeRows(0, initial_count) + assert result == True + assert model.rowCount() == 0 + + # Verify table is now empty in database + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + res = cur.execute("SELECT COUNT(*) FROM users") + assert res.fetchone()[0] == 0 + conn.close() + + # Test operations on now-empty table + result = model.removeRows(0, 1) # Should fail + assert result == False + + # Test insert into now-empty table + result = model.insertRows(0, 1) + assert result == True + assert model.rowCount() == 1 + + def test_remove_all_rows_one_by_one(self): + """Test removing all rows one by one""" + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='users') + + initial_count = model.rowCount() + + # Remove rows one by one from the beginning + for i in range(initial_count): + remaining_count = model.rowCount() + result = model.removeRows(0, 1) + assert result == True + assert model.rowCount() == remaining_count - 1 + + # Should now be empty + assert model.rowCount() == 0 + + # Verify in database + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + res = cur.execute("SELECT COUNT(*) FROM users") + assert res.fetchone()[0] == 0 + conn.close() + + def test_remove_all_rows_with_sorting(self): + """Test removing all rows when table is sorted""" + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='users') + + # Sort by name first + model.sort(0, ttk.TTkK.SortOrder.AscendingOrder) + + initial_count = model.rowCount() + + # Remove all rows + result = model.removeRows(0, initial_count) + assert result == True + assert model.rowCount() == 0 + + # Sorting should still work on empty table + model.sort(1, ttk.TTkK.SortOrder.DescendingOrder) + assert model.rowCount() == 0 + + def test_remove_rows_boundary_edge_cases(self): + """Test boundary edge cases for removeRows""" + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='users') + + row_count = model.rowCount() + + # Test removing exactly the remaining count from position 0 + result = model.removeRows(0, row_count) + assert result == True + assert model.rowCount() == 0 + + # Reset table for next test + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + test_data = [ + ('Alice', 25, 'Engineer'), + ('Bob', 30, 'Designer'), + ('Charlie', 35, 'Manager') + ] + cur.executemany('INSERT INTO users (name, age, role) VALUES (?, ?, ?)', test_data) + conn.commit() + conn.close() + + # Refresh model count + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='users') + + # Test removing from last position with count that exceeds available + result = model.removeRows(2, 5) # Only 1 row at position 2, trying to remove 5 + assert result == False + assert model.rowCount() == 3 # Should be unchanged + + def test_insert_rows_into_empty_table(self): + """Test inserting rows into various empty table configurations""" + # Create empty table with different column types + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE empty_mixed ( + id INTEGER PRIMARY KEY, + name TEXT, + age INTEGER, + salary REAL, + active BOOLEAN + ) + ''') + conn.commit() + conn.close() + + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='empty_mixed') + + # Insert multiple rows into empty table + result = model.insertRows(0, 3) + assert result == True + assert model.rowCount() == 3 + + # Check default values based on column types + assert model.data(0, 0) == '' # TEXT -> empty string + assert model.data(0, 1) == 0 # INTEGER -> 0 + assert model.data(0, 2) == 0.0 # REAL -> 0.0 + assert model.data(0, 3) == 0 # BOOLEAN -> 0 + + def test_empty_table_index_operations(self): + """Test index operations on empty table""" + # Create empty table + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE empty_index ( + id INTEGER PRIMARY KEY, + value TEXT + ) + ''') conn.commit() conn.close() - # Test model with table widget - model = ttk.TTkTableModelSQLite3(fileName=temp_db_path, table='test_table') - table = ttk.TTkTable(tableModel=model) + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='empty_index') + + # Test index creation on empty table (should handle gracefully) + try: + index = model.index(0, 0) # May raise exception or return invalid index + # If it doesn't raise an exception, the implementation handles it gracefully + except (sqlite3.Error, IndexError): + pass # Expected behavior for empty table + + def test_table_emptied_then_repopulated(self): + """Test table that is emptied and then repopulated""" + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='users') + + # Verify initial state + assert model.rowCount() == 4 + + # Empty the table + model.removeRows(0, model.rowCount()) + assert model.rowCount() == 0 - # Basic integration tests - assert table.model() == model + # Repopulate with new data + model.insertRows(0, 2) assert model.rowCount() == 2 - assert model.columnCount() == 2 - finally: - # Cleanup - if os.path.exists(temp_db_path): - os.unlink(temp_db_path) + # Set data for new rows + model.setData(0, 0, 'NewUser1') + model.setData(0, 1, 40) + model.setData(0, 2, 'Admin') + + model.setData(1, 0, 'NewUser2') + model.setData(1, 1, 45) + model.setData(1, 2, 'Manager') + + # Verify data integrity + assert model.data(0, 0) == 'NewUser1' + assert model.data(1, 0) == 'NewUser2' + + # Test sorting on repopulated table + model.sort(0, ttk.TTkK.SortOrder.AscendingOrder) + assert model.data(0, 0) == 'NewUser1' # Should be first alphabetically + + def test_concurrent_operations_on_empty_table(self): + """Test concurrent operations on empty table (basic thread safety)""" + # Create empty table + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE empty_concurrent ( + id INTEGER PRIMARY KEY, + value INTEGER + ) + ''') + conn.commit() + conn.close() + + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='empty_concurrent') + + # Simulate concurrent operations (basic test) + operations = [ + lambda: model.insertRows(0, 1), + lambda: model.sort(0, ttk.TTkK.SortOrder.AscendingOrder), + lambda: model.rowCount(), + lambda: model.columnCount() + ] + + # Execute multiple operations - should not crash + for op in operations * 3: # Repeat operations + try: + result = op() + except Exception as e: + pytest.fail(f"Concurrent operation failed: {e}") + + def test_large_batch_remove_operations(self): + """Test removing large batches of rows""" + # First populate with more data + conn = sqlite3.connect(self.temp_db_path) + cur = conn.cursor() + + # Add more test data + large_data = [(f'User{i}', 20+i, f'Role{i%3}') for i in range(50)] + cur.executemany('INSERT INTO users (name, age, role) VALUES (?, ?, ?)', large_data) + conn.commit() + conn.close() + + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='users') + + initial_count = model.rowCount() + assert initial_count > 50 # Should have original 4 + 50 new = 54 + + # Remove large batch from middle + result = model.removeRows(10, 30) + assert result == True + assert model.rowCount() == initial_count - 30 + + # Remove another large batch from beginning + result = model.removeRows(0, 15) + assert result == True + assert model.rowCount() == initial_count - 45 + + # Remove remaining rows + remaining = model.rowCount() + result = model.removeRows(0, remaining) + assert result == True + assert model.rowCount() == 0 + + def test_alternating_empty_and_populate_operations(self): + """Test alternating between emptying and populating table""" + model = ttk.TTkTableModelSQLite3(fileName=self.temp_db_path, table='users') + + for cycle in range(3): # Repeat cycle 3 times + # Start with some data + if model.rowCount() == 0: + model.insertRows(0, 2) + model.setData(0, 0, f'User{cycle}A') + model.setData(1, 0, f'User{cycle}B') + + assert model.rowCount() >= 2 + + # Empty the table + model.removeRows(0, model.rowCount()) + assert model.rowCount() == 0 + + # Insert different amount + insert_count = (cycle + 1) * 2 + model.insertRows(0, insert_count) + assert model.rowCount() == insert_count + # Set some data + for i in range(insert_count): + model.setData(i, 0, f'Cycle{cycle}_User{i}') -if __name__ == '__main__': - pytest.main([__file__]) \ No newline at end of file + # Final cleanup + model.removeRows(0, model.rowCount()) + assert model.rowCount() == 0 \ No newline at end of file diff --git a/tests/pytest/modelView/test_tablewidget.py b/tests/pytest/modelView/test_tablewidget.py new file mode 100644 index 00000000..af5ff903 --- /dev/null +++ b/tests/pytest/modelView/test_tablewidget.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +# 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 os +import sys +import pytest +from unittest.mock import Mock, MagicMock, patch + +sys.path.append(os.path.join(sys.path[0],'../../../libs/pyTermTk')) +import TermTk as ttk + + +class TestTTkTableWidget: + """Test cases for TTkTableWidget class""" + + def setup_method(self): + """Set up test fixtures before each test method.""" + self.test_data = [ + ['Alice', 25, 'Engineer'], + ['Bob', 30, 'Designer'], + ['Charlie', 35, 'Manager'] + ] + self.header = ['Name', 'Age', 'Role'] + self.indexes = ['Row1', 'Row2', 'Row3'] + + # Create a basic table model for testing + self.table_model = ttk.TTkTableModelList( + data=self.test_data, + header=self.header, + indexes=self.indexes + ) + + def test_init_default(self): + """Test default initialization""" + widget = ttk.TTkTableWidget() + + assert widget is not None + assert widget.model() is not None + assert isinstance(widget.model(), ttk.TTkAbstractTableModel) + # Default model should have 10x10 grid with empty strings + assert widget.rowCount() == 10 + assert widget.columnCount() == 10 + + def test_init_with_model(self): + """Test initialization with a table model""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + assert widget.model() == self.table_model + assert widget.rowCount() == 3 + assert widget.columnCount() == 3 + + def test_init_with_separator_options(self): + """Test initialization with separator visibility options""" + widget = ttk.TTkTableWidget( + vSeparator=False, + hSeparator=False + ) + + assert not widget.vSeparatorVisibility() + assert not widget.hSeparatorVisibility() + + def test_init_with_header_options(self): + """Test initialization with header visibility options""" + widget = ttk.TTkTableWidget( + vHeader=False, + hHeader=False + ) + + assert not widget.verticalHeader().isVisible() + assert not widget.horizontalHeader().isVisible() + + def test_init_with_sorting_enabled(self): + """Test initialization with sorting enabled""" + widget = ttk.TTkTableWidget(sortingEnabled=True) + + assert widget.isSortingEnabled() + + def test_init_with_data_padding(self): + """Test initialization with custom data padding""" + widget = ttk.TTkTableWidget(dataPadding=3) + + # Data padding is internal, but we can verify the widget was created + assert widget is not None + + def test_model_getter_setter(self): + """Test model getter and setter""" + widget = ttk.TTkTableWidget() + original_model = widget.model() + + # Set new model + widget.setModel(self.table_model) + assert widget.model() == self.table_model + assert widget.model() != original_model + + # Verify model data is accessible + assert widget.rowCount() == 3 + assert widget.columnCount() == 3 + + def test_row_column_count(self): + """Test row and column count methods""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + assert widget.rowCount() == 3 + assert widget.columnCount() == 3 + + def test_current_row_column(self): + """Test current row and column methods""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Initially should be at (0, 0) or (-1, -1) if no selection + current_row = widget.currentRow() + current_col = widget.currentColumn() + + assert isinstance(current_row, int) + assert isinstance(current_col, int) + assert current_row >= -1 + assert current_col >= -1 + + def test_header_views(self): + """Test vertical and horizontal header views""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + v_header = widget.verticalHeader() + h_header = widget.horizontalHeader() + + assert isinstance(v_header, ttk.TTkHeaderView) + assert isinstance(h_header, ttk.TTkHeaderView) + assert v_header.isVisible() + assert h_header.isVisible() + + def test_separator_visibility(self): + """Test separator visibility methods""" + widget = ttk.TTkTableWidget() + + # Default should be visible + assert widget.hSeparatorVisibility() + assert widget.vSeparatorVisibility() + + # Test setters + widget.setHSeparatorVisibility(False) + widget.setVSeparatorVisibility(False) + + assert not widget.hSeparatorVisibility() + assert not widget.vSeparatorVisibility() + + # Test setting back to visible + widget.setHSeparatorVisibility(True) + widget.setVSeparatorVisibility(True) + + assert widget.hSeparatorVisibility() + assert widget.vSeparatorVisibility() + + def test_sorting_functionality(self): + """Test sorting enabled/disabled and sort by column""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Initially sorting should be disabled + assert not widget.isSortingEnabled() + + # Enable sorting + widget.setSortingEnabled(True) + assert widget.isSortingEnabled() + + # Test sorting by column + widget.sortByColumn(0, ttk.TTkK.SortOrder.AscendingOrder) + # Note: We can't easily verify the actual sorting without checking the model state + + # Disable sorting + widget.setSortingEnabled(False) + assert not widget.isSortingEnabled() + + def test_selection_methods(self): + """Test selection-related methods""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Test clear selection + widget.clearSelection() + + # Test select all + widget.selectAll() + + # Test select row + widget.selectRow(0) + + # Test select column + widget.selectColumn(1) + + # Test set selection with position and size + widget.setSelection((0, 0), (2, 2), ttk.TTkK.TTkItemSelectionModel.Select) + + # These methods should not raise exceptions + # Actual selection state testing would require more complex setup + + def test_column_width_operations(self): + """Test column width setting and resizing""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Test setting column width + widget.setColumnWidth(0, 20) + + # Test resize column to contents + widget.resizeColumnToContents(0) + + # Test resize all columns to contents + widget.resizeColumnsToContents() + + # These methods should not raise exceptions + + def test_row_height_operations(self): + """Test row height setting and resizing""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Test setting row height + widget.setRowHeight(0, 3) + + # Test resize row to contents + widget.resizeRowToContents(0) + + # Test resize all rows to contents + widget.resizeRowsToContents() + + # These methods should not raise exceptions + + def test_clipboard_operations(self): + """Test copy, cut, and paste operations""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # These operations should not raise exceptions + widget.copy() + widget.cut() + widget.paste() + + def test_undo_redo_operations(self): + """Test undo and redo operations""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Initially, there should be no undo/redo available + # But the methods should not raise exceptions + widget.undo() + widget.redo() + + def test_signals_exist(self): + """Test that all expected signals exist""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Test that signals are accessible + assert hasattr(widget, 'cellChanged') + assert hasattr(widget, 'cellClicked') + assert hasattr(widget, 'cellDoubleClicked') + assert hasattr(widget, 'cellEntered') + assert hasattr(widget, 'currentCellChanged') + + # Test that they are signal objects + assert isinstance(widget.cellChanged, ttk.pyTTkSignal) + assert isinstance(widget.cellClicked, ttk.pyTTkSignal) + assert isinstance(widget.cellDoubleClicked, ttk.pyTTkSignal) + assert isinstance(widget.cellEntered, ttk.pyTTkSignal) + assert isinstance(widget.currentCellChanged, ttk.pyTTkSignal) + + def test_signal_connections(self): + """Test signal connections""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Create mock slots + + cell_changed_slot = Mock() + cell_clicked_slot = Mock() + current_cell_changed_slot = Mock() + @ttk.pyTTkSlot(int,int) + def _mock_cell_changed_slot(row:int,col:int): + cell_changed_slot(row, col) + @ttk.pyTTkSlot(int,int) + def _mock_cell_clicked_slot(row:int,col:int): + cell_clicked_slot(row, col) + @ttk.pyTTkSlot(int,int,int,int) + def _mock_cell_cchanged_slot(a:int,b:int,c:int,d:int): + current_cell_changed_slot(a,b,c,d) + + # Connect signals + widget.cellChanged.connect(_mock_cell_changed_slot) + widget.cellClicked.connect(_mock_cell_clicked_slot) + widget.currentCellChanged.connect(_mock_cell_cchanged_slot) + + # Verify connections were made (signals should have connections) + assert len(widget.cellChanged._connected_slots) > 0 + assert len(widget.cellClicked._connected_slots) > 0 + assert len(widget.currentCellChanged._connected_slots) > 0 + + def test_model_change_propagation(self): + """Test that model changes are propagated to the widget""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Modify the model + self.table_model.setData(0, 0, 'Modified') + + # The widget should be notified (through signal connections) + # We can't easily test the actual update without a more complex setup + assert widget.model().data(0, 0) == 'Modified' + + def test_empty_model_handling(self): + """Test widget behavior with empty model""" + empty_model = ttk.TTkTableModelList(data=[[]]) + widget = ttk.TTkTableWidget(tableModel=empty_model) + + assert widget.rowCount() == 1 + assert widget.columnCount() == 0 + + def test_large_model_handling(self): + """Test widget behavior with large model""" + large_data = [[f'Cell_{i}_{j}' for j in range(100)] for i in range(100)] + large_model = ttk.TTkTableModelList(data=large_data) + widget = ttk.TTkTableWidget(tableModel=large_model) + + assert widget.rowCount() == 100 + assert widget.columnCount() == 100 + + def test_model_replacement(self): + """Test replacing the model after widget creation""" + widget = ttk.TTkTableWidget() + original_row_count = widget.rowCount() + original_col_count = widget.columnCount() + + # Replace with our test model + widget.setModel(self.table_model) + + # Verify the change + assert widget.rowCount() != original_row_count + assert widget.columnCount() != original_col_count + assert widget.rowCount() == 3 + assert widget.columnCount() == 3 + + def test_widget_with_different_model_types(self): + """Test widget with different types of table models""" + # Test with TTkTableModelList + list_model = ttk.TTkTableModelList(data=self.test_data) + widget1 = ttk.TTkTableWidget(tableModel=list_model) + assert widget1.rowCount() == 3 + + # Test with TTkTableModelCSV (using in-memory data) + import io + csv_data = io.StringIO("Name,Age\nAlice,25\nBob,30") + csv_model = ttk.TTkTableModelCSV(fd=csv_data) + widget2 = ttk.TTkTableWidget(tableModel=csv_model) + assert widget2.rowCount() == 2 + + def test_header_visibility_changes(self): + """Test changing header visibility after initialization""" + widget = ttk.TTkTableWidget() + + # Initially headers should be visible + assert widget.verticalHeader().isVisible() + assert widget.horizontalHeader().isVisible() + + # Hide headers + widget.verticalHeader().hide() + widget.horizontalHeader().hide() + + assert not widget.verticalHeader().isVisible() + assert not widget.horizontalHeader().isVisible() + + # Show headers again + widget.verticalHeader().show() + widget.horizontalHeader().show() + + assert widget.verticalHeader().isVisible() + assert widget.horizontalHeader().isVisible() + + def test_focus_policy(self): + """Test that the widget has proper focus policy""" + widget = ttk.TTkTableWidget() + + # Should accept click and tab focus + focus_policy = widget.focusPolicy() + assert focus_policy & ttk.TTkK.ClickFocus + assert focus_policy & ttk.TTkK.TabFocus + + def test_minimum_size(self): + """Test minimum size constraints""" + widget = ttk.TTkTableWidget() + + # Should have minimum height of 1 + min_size = widget.minimumSize() + assert min_size[1] >= 1 # height should be at least 1 + + def test_style_properties(self): + """Test that the widget has proper style properties""" + widget = ttk.TTkTableWidget() + + # Should have class style defined + assert hasattr(ttk.TTkTableWidget, 'classStyle') + assert isinstance(ttk.TTkTableWidget.classStyle, dict) + assert 'default' in ttk.TTkTableWidget.classStyle + + def test_boundary_conditions(self): + """Test boundary conditions and edge cases""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Test with invalid row/column indices (should not crash) + try: + widget.selectRow(-1) + widget.selectRow(1000) + widget.selectColumn(-1) + widget.selectColumn(1000) + widget.setColumnWidth(-1, 10) + widget.setColumnWidth(1000, 10) + widget.setRowHeight(-1, 2) + widget.setRowHeight(1000, 2) + except Exception as e: + # Some operations might raise exceptions, but they shouldn't crash + pass + + def test_integration_with_scroll_area(self): + """Test that the widget works as expected within a scroll area (via TTkTable)""" + table = ttk.TTkTable(tableModel=self.table_model) + + # TTkTable should forward methods to the internal TTkTableWidget + assert table.rowCount() == 3 + assert table.columnCount() == 3 + assert table.model() == self.table_model + + def test_zero_rows_model(self): + """Test widget behavior with model that has zero rows""" + # Create model with zero rows but some columns + empty_rows_model = ttk.TTkTableModelList( + data=[], + header=['Col1', 'Col2', 'Col3'] + ) + widget = ttk.TTkTableWidget(tableModel=empty_rows_model) + + assert widget.rowCount() == 0 + assert widget.columnCount() == 0 + assert widget.currentRow() == 0 # No valid current row + assert widget.currentColumn() == 0 # No valid current column + + # Selection operations should handle empty rows gracefully + widget.clearSelection() + widget.selectAll() # Should not crash with no rows + + # Column operations should still work + widget.setColumnWidth(0, 50) + widget.resizeColumnsToContents() + + # Row operations should handle empty case + widget.resizeRowsToContents() # Should not crash with no rows + + def test_zero_columns_model(self): + """Test widget behavior with model that has zero columns""" + # Create model with zero columns but some rows + empty_cols_model = ttk.TTkTableModelList( + data=[[], [], []], # 3 empty rows + header=[] + ) + widget = ttk.TTkTableWidget(tableModel=empty_cols_model) + + assert widget.rowCount() == 3 + assert widget.columnCount() == 0 + assert widget.currentRow() == 0 # No valid current row + assert widget.currentColumn() == 0 # No valid current column + + # Selection operations should handle empty columns gracefully + widget.clearSelection() + widget.selectAll() # Should not crash with no columns + + # Row operations should still work + widget.setRowHeight(0, 2) + widget.resizeRowsToContents() + + # Column operations should handle empty case + widget.resizeColumnsToContents() # Should not crash with no columns + + def test_completely_empty_model(self): + """Test widget behavior with model that has zero rows and zero columns""" + # Create completely empty model + empty_model = ttk.TTkTableModelList( + data=[], + header=[] + ) + widget = ttk.TTkTableWidget(tableModel=empty_model) + + assert widget.rowCount() == 0 + assert widget.columnCount() == 0 + assert widget.currentRow() == 0 + assert widget.currentColumn() == 0 + + # All operations should handle completely empty case gracefully + widget.clearSelection() + widget.selectAll() + widget.resizeColumnsToContents() + widget.resizeRowsToContents() + + # Clipboard operations with empty model + widget.copy() # Should not crash + widget.cut() # Should not crash + widget.paste() # Should not crash + + # Undo/redo with empty model + widget.undo() + widget.redo() + + def test_single_cell_model(self): + """Test widget behavior with model that has exactly one cell""" + single_cell_model = ttk.TTkTableModelList( + data=[['SingleCell']], + header=['OnlyColumn'] + ) + widget = ttk.TTkTableWidget(tableModel=single_cell_model) + + assert widget.rowCount() == 1 + assert widget.columnCount() == 1 + + # Selection operations + widget.selectAll() + widget.selectRow(0) + widget.selectColumn(0) + widget.setSelection((0, 0), (1, 1), ttk.TTkK.TTkItemSelectionModel.Select) + + # Resize operations + widget.setColumnWidth(0, 20) + widget.setRowHeight(0, 3) + widget.resizeColumnToContents(0) + widget.resizeRowToContents(0) + + def test_model_transitions(self): + """Test transitioning between different model sizes""" + widget = ttk.TTkTableWidget(tableModel=self.table_model) + + # Start with normal model + assert widget.rowCount() == 3 + assert widget.columnCount() == 3 + + # Transition to empty rows + empty_rows_model = ttk.TTkTableModelList(data=[], header=['A', 'B']) + widget.setModel(empty_rows_model) + assert widget.rowCount() == 0 + assert widget.columnCount() == 0 + + # Transition to empty columns + empty_cols_model = ttk.TTkTableModelList(data=[[], []], header=[]) + widget.setModel(empty_cols_model) + assert widget.rowCount() == 2 + assert widget.columnCount() == 0 + + # Transition to completely empty + empty_model = ttk.TTkTableModelList(data=[], header=[]) + widget.setModel(empty_model) + assert widget.rowCount() == 0 + assert widget.columnCount() == 0 + + # Transition back to normal model + widget.setModel(self.table_model) + assert widget.rowCount() == 3 + assert widget.columnCount() == 3 + + def test_boundary_operations_on_empty_models(self): + """Test boundary operations on various empty model configurations""" + + # Test with zero rows model + zero_rows_model = ttk.TTkTableModelList(data=[], header=['A', 'B']) + widget = ttk.TTkTableWidget(tableModel=zero_rows_model) + + # These should not crash even with no rows + try: + widget.selectRow(0) # Invalid row + widget.unselectRow(0) # Invalid row + widget.setRowHeight(0, 2) # Invalid row + widget.resizeRowToContents(0) # Invalid row + except (IndexError, ValueError): + pass # Expected for invalid indices + + # Test with zero columns model + zero_cols_model = ttk.TTkTableModelList(data=[[], []], header=[]) + widget.setModel(zero_cols_model) + + # These should not crash even with no columns + try: + widget.selectColumn(0) # Invalid column + widget.unselectColumn(0) # Invalid column + widget.setColumnWidth(0, 20) # Invalid column + widget.resizeColumnToContents(0) # Invalid column + except (IndexError, ValueError): + pass # Expected for invalid indices + + def test_empty_model_sorting(self): + """Test sorting operations on empty models""" + + # Test sorting with zero rows + zero_rows_model = ttk.TTkTableModelList(data=[], header=['Name', 'Age']) + widget = ttk.TTkTableWidget(tableModel=zero_rows_model, sortingEnabled=True) + + assert widget.isSortingEnabled() + # Sorting empty data should not crash + widget.sortByColumn(0, ttk.TTkK.SortOrder.AscendingOrder) + widget.sortByColumn(1, ttk.TTkK.SortOrder.DescendingOrder) + + # Test sorting with zero columns + zero_cols_model = ttk.TTkTableModelList(data=[[], []], header=[]) + widget.setModel(zero_cols_model) + + # Sorting with no columns should handle gracefully + try: + widget.sortByColumn(0, ttk.TTkK.SortOrder.AscendingOrder) # Invalid column + except (IndexError, ValueError): + pass # Expected for invalid column index + + def test_empty_model_signals(self): + """Test that signals work correctly with empty models""" + empty_model = ttk.TTkTableModelList(data=[], header=[]) + widget = ttk.TTkTableWidget(tableModel=empty_model) + + # Connect signals to mock slots + cell_changed_calls = [] + cell_clicked_calls = [] + + @ttk.pyTTkSlot(int, int) + def mock_cell_changed(row, col): + cell_changed_calls.append((row, col)) + + @ttk.pyTTkSlot(int, int) + def mock_cell_clicked(row, col): + cell_clicked_calls.append((row, col)) + + widget.cellChanged.connect(mock_cell_changed) + widget.cellClicked.connect(mock_cell_clicked) + + # Signals should be connected even with empty model + assert len(widget.cellChanged._connected_slots) > 0 + assert len(widget.cellClicked._connected_slots) > 0 + + # No signals should be emitted for empty model operations + widget.clearSelection() + widget.selectAll() + + # Should have no signal calls since there are no cells + assert len(cell_changed_calls) == 0 + assert len(cell_clicked_calls) == 0 + + +def test_integration_with_full_application(): + """Integration test - verify TTkTableWidget works in a full application context""" + # Create a more complex scenario + data = [ + ['Product A', 100, 29.99], + ['Product B', 50, 19.99], + ['Product C', 75, 39.99] + ] + headers = ['Product', 'Stock', 'Price'] + + model = ttk.TTkTableModelList(data=data, header=headers) + widget = ttk.TTkTableWidget( + tableModel=model, + sortingEnabled=True, + vSeparator=True, + hSeparator=True + ) + + # Test various operations in sequence + widget.resizeColumnsToContents() + widget.setSortingEnabled(True) + widget.sortByColumn(2, ttk.TTkK.SortOrder.DescendingOrder) # Sort by price descending + widget.selectRow(0) + widget.copy() + + # Verify the widget still functions correctly + assert widget.rowCount() == 3 + assert widget.columnCount() == 3 + assert widget.isSortingEnabled() + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/t.ui/test.ui.032.table.12.py b/tests/t.ui/test.ui.032.table.12.py index 6f49560a..31755ba2 100755 --- a/tests/t.ui/test.ui.032.table.12.py +++ b/tests/t.ui/test.ui.032.table.12.py @@ -397,7 +397,7 @@ def _insertRows(): @ttk.pyTTkSlot() def _deleteRows(): _model = table.model() - _model.removeRows(5,5) + _model.removeRows(0,5) @ttk.pyTTkSlot() def _insertCols(): @@ -407,7 +407,7 @@ def _insertCols(): @ttk.pyTTkSlot() def _deleteCols(): _model = table.model() - _model.removeColumns(5,5) + _model.removeColumns(0,5) btn_ins_row.clicked.connect(_insertRows) btn_del_row.clicked.connect(_deleteRows) diff --git a/tests/t.ui/test.ui.032.table.13.alignment.02.overloading.py b/tests/t.ui/test.ui.032.table.13.alignment.02.overloading.py index 44cc0ebf..f6d65504 100755 --- a/tests/t.ui/test.ui.032.table.13.alignment.02.overloading.py +++ b/tests/t.ui/test.ui.032.table.13.alignment.02.overloading.py @@ -47,7 +47,7 @@ args = parser.parse_args() fullScreen = not args.w mouseTrack = True -class MyTableMixin(): +class MyTableModel(ttk.TTkTableModelList): def headerData(self, num, orientation): if orientation == ttk.TTkK.HORIZONTAL: if 0 == num%4: @@ -61,28 +61,15 @@ class MyTableMixin(): return super().headerData(num, orientation) def displayData(self:ttk.TTkTableModelList, row:int, col:int) -> Tuple[ttk.TTkString, ttk.TTkK.Alignment]: - data, legacy_align = super().displayData(row,col) if 0 == col%4: - return data, ttk.TTkK.Alignment.LEFT_ALIGN + return self.ttkStringData(row, col), ttk.TTkK.Alignment.LEFT_ALIGN if 1 == col%4: - return data, ttk.TTkK.Alignment.CENTER_ALIGN + return self.ttkStringData(row, col), ttk.TTkK.Alignment.CENTER_ALIGN if 2 == col%4: - return data, ttk.TTkK.Alignment.RIGHT_ALIGN + return self.ttkStringData(row, col), ttk.TTkK.Alignment.RIGHT_ALIGN if 3 == col%4: - return data, ttk.TTkK.Alignment.JUSTIFY - return data, legacy_align - -class MyTableModel(MyTableMixin, ttk.TTkTableModelList): - def flags(self, row: int, col: int) -> ttk.TTkConstant.ItemFlag: - if col==0: - return ( - ttk.TTkK.ItemFlag.ItemIsEnabled | - ttk.TTkK.ItemFlag.ItemIsSelectable ) - if col==1: - return ( - ttk.TTkK.ItemFlag.ItemIsEnabled | - ttk.TTkK.ItemFlag.ItemIsEditable ) - return super().flags(row, col) + return self.ttkStringData(row, col), ttk.TTkK.Alignment.JUSTIFY + return super().displayData(row,col) data_list1 = [[f"{y:03}\npippo\npeppo-ooo"]+[str(x) for x in range(10) ] for y in range(20)] data_list1[1][1] = "abc def ghi\ndef ghi\nghi\njkl - pippo" diff --git a/tests/t.ui/test.ui.032.table.13.alignment.03.mixin.py b/tests/t.ui/test.ui.032.table.13.alignment.03.mixin.py index f6d65504..44cc0ebf 100755 --- a/tests/t.ui/test.ui.032.table.13.alignment.03.mixin.py +++ b/tests/t.ui/test.ui.032.table.13.alignment.03.mixin.py @@ -47,7 +47,7 @@ args = parser.parse_args() fullScreen = not args.w mouseTrack = True -class MyTableModel(ttk.TTkTableModelList): +class MyTableMixin(): def headerData(self, num, orientation): if orientation == ttk.TTkK.HORIZONTAL: if 0 == num%4: @@ -61,15 +61,28 @@ class MyTableModel(ttk.TTkTableModelList): return super().headerData(num, orientation) def displayData(self:ttk.TTkTableModelList, row:int, col:int) -> Tuple[ttk.TTkString, ttk.TTkK.Alignment]: + data, legacy_align = super().displayData(row,col) if 0 == col%4: - return self.ttkStringData(row, col), ttk.TTkK.Alignment.LEFT_ALIGN + return data, ttk.TTkK.Alignment.LEFT_ALIGN if 1 == col%4: - return self.ttkStringData(row, col), ttk.TTkK.Alignment.CENTER_ALIGN + return data, ttk.TTkK.Alignment.CENTER_ALIGN if 2 == col%4: - return self.ttkStringData(row, col), ttk.TTkK.Alignment.RIGHT_ALIGN + return data, ttk.TTkK.Alignment.RIGHT_ALIGN if 3 == col%4: - return self.ttkStringData(row, col), ttk.TTkK.Alignment.JUSTIFY - return super().displayData(row,col) + return data, ttk.TTkK.Alignment.JUSTIFY + return data, legacy_align + +class MyTableModel(MyTableMixin, ttk.TTkTableModelList): + def flags(self, row: int, col: int) -> ttk.TTkConstant.ItemFlag: + if col==0: + return ( + ttk.TTkK.ItemFlag.ItemIsEnabled | + ttk.TTkK.ItemFlag.ItemIsSelectable ) + if col==1: + return ( + ttk.TTkK.ItemFlag.ItemIsEnabled | + ttk.TTkK.ItemFlag.ItemIsEditable ) + return super().flags(row, col) data_list1 = [[f"{y:03}\npippo\npeppo-ooo"]+[str(x) for x in range(10) ] for y in range(20)] data_list1[1][1] = "abc def ghi\ndef ghi\nghi\njkl - pippo"