Consolidate some code related to opening documents using the Documents feature. More code coverage
All checks were successful
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 22s
CI / test (push) Successful in 6m4s

This commit is contained in:
Miguel Jacq 2025-12-02 11:01:27 +11:00
parent 25f0c28582
commit 0b76f0b490
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
9 changed files with 2101 additions and 108 deletions

View file

@ -1,7 +1,14 @@
from PySide6.QtWidgets import QPushButton
from bouquin.code_block_editor_dialog import CodeBlockEditorDialog
from bouquin import strings
from PySide6.QtCore import QRect, QSize
from PySide6.QtGui import QPaintEvent, QFont
from bouquin.code_block_editor_dialog import (
CodeBlockEditorDialog,
CodeEditorWithLineNumbers,
)
def _find_button_by_text(widget, text):
for btn in widget.findChildren(QPushButton):
@ -29,3 +36,292 @@ def test_code_block_dialog_language_and_code(qtbot):
assert _find_button_by_text(dlg, delete_txt) is None
assert dlg.code() == "x = 1"
assert dlg.language() is None
def test_line_number_area_size_hint(qtbot, app):
"""Test _LineNumberArea.sizeHint() method."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
line_area = editor._line_number_area
size_hint = line_area.sizeHint()
# Should return a QSize with width from editor
assert isinstance(size_hint, QSize)
assert size_hint.width() > 0
assert size_hint.height() == 0
def test_line_number_area_paint_event(qtbot, app):
"""Test _LineNumberArea.paintEvent() method."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Line 1\nLine 2\nLine 3")
editor.show()
# Trigger a paint event on the line number area
line_area = editor._line_number_area
paint_event = QPaintEvent(QRect(0, 0, line_area.width(), line_area.height()))
line_area.paintEvent(paint_event)
# Should not crash
def test_line_number_font_pixel_size_fallback(qtbot, app):
"""Test _line_number_font() with pixel-sized font."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Set a pixel-sized font (pointSize will be -1)
font = QFont()
font.setPixelSize(14)
editor.setFont(font)
# Get line number font - should use the fallback
line_font = editor._line_number_font()
# Should have calculated a size
assert line_font.pointSizeF() > 0 or line_font.pixelSize() > 0
def test_code_editor_resize_event(qtbot, app):
"""Test CodeEditorWithLineNumbers.resizeEvent() method."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.show()
# Resize the editor
editor.resize(400, 300)
# Line number area should be repositioned
line_area = editor._line_number_area
assert line_area.geometry().width() > 0
assert line_area.geometry().height() == editor.contentsRect().height()
def test_code_editor_update_with_scroll(qtbot, app):
"""Test _update_line_number_area with dy (scroll) parameter."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Add enough text to enable scrolling
text = "\n".join([f"Line {i}" for i in range(100)])
editor.setPlainText(text)
editor.show()
# Trigger update with scroll offset
rect = QRect(0, 0, 100, 100)
editor._update_line_number_area(rect, dy=10)
# Should not crash
def test_code_editor_update_without_scroll(qtbot, app):
"""Test _update_line_number_area without scroll (dy=0)."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Line 1\nLine 2")
editor.show()
# Trigger update without scroll
rect = QRect(0, 0, 100, 100)
editor._update_line_number_area(rect, dy=0)
# Should not crash
def test_code_editor_update_contains_viewport(qtbot, app):
"""Test _update_line_number_area when rect contains viewport."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Test")
editor.show()
# Trigger update with rect that contains viewport
viewport_rect = editor.viewport().rect()
editor._update_line_number_area(viewport_rect, dy=0)
# Should trigger width update (covers line 82)
def test_line_number_area_paint_with_multiple_blocks(qtbot, app):
"""Test line_number_area_paint_event with multiple text blocks."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Add multiple lines
text = "\n".join([f"Line {i}" for i in range(20)])
editor.setPlainText(text)
editor.show()
# Force a paint event
line_area = editor._line_number_area
rect = QRect(0, 0, line_area.width(), line_area.height())
paint_event = QPaintEvent(rect)
# This should exercise the painting loop (lines 87-130)
editor.line_number_area_paint_event(paint_event)
# Should not crash
def test_line_number_area_paint_with_long_file(qtbot, app):
"""Test line_number_area_paint_event with many lines."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Add 1000+ lines to test digit calculation and painting
text = "\n".join([f"Line {i}" for i in range(1000)])
editor.setPlainText(text)
editor.show()
# Trigger paint event
line_area = editor._line_number_area
paint_event = QPaintEvent(line_area.rect())
editor.line_number_area_paint_event(paint_event)
# Line number width should accommodate 4 digits
width = editor.line_number_area_width()
assert width > 30 # Should be wider for 4-digit numbers
def test_code_block_editor_dialog_with_delete(qtbot, app):
"""Test CodeBlockEditorDialog with allow_delete=True."""
dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=True)
qtbot.addWidget(dialog)
# Should have delete button functionality
assert hasattr(dialog, "_delete_requested")
assert dialog._delete_requested is False
# Simulate delete click
dialog._on_delete_clicked()
assert dialog._delete_requested is True
assert dialog.was_deleted() is True
def test_code_block_editor_dialog_without_delete(qtbot, app):
"""Test CodeBlockEditorDialog with allow_delete=False."""
dialog = CodeBlockEditorDialog("print('hello')", "python", allow_delete=False)
qtbot.addWidget(dialog)
# Should not have been deleted
assert dialog.was_deleted() is False
def test_code_block_editor_dialog_language_selection(qtbot, app):
"""Test language selection in dialog."""
dialog = CodeBlockEditorDialog("test", "javascript")
qtbot.addWidget(dialog)
# Should have selected javascript
assert dialog.language() == "javascript"
# Change language
dialog._lang_combo.setCurrentText("python")
assert dialog.language() == "python"
# Empty language
dialog._lang_combo.setCurrentText("")
assert dialog.language() is None
def test_code_block_editor_dialog_code_retrieval(qtbot, app):
"""Test getting code from dialog."""
original_code = "def foo():\n pass"
dialog = CodeBlockEditorDialog(original_code, None)
qtbot.addWidget(dialog)
# Should return the code
assert dialog.code() == original_code
# Modify code
new_code = "def bar():\n return 42"
dialog._code_edit.setPlainText(new_code)
assert dialog.code() == new_code
def test_code_editor_with_empty_text(qtbot, app):
"""Test editor with no text."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.show()
# Should still paint line numbers
line_area = editor._line_number_area
paint_event = QPaintEvent(line_area.rect())
editor.line_number_area_paint_event(paint_event)
# Should not crash
def test_code_editor_block_count_changed(qtbot, app):
"""Test that block count changes trigger width updates."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
initial_width = editor.line_number_area_width()
# Add lots of lines (should require more digits)
text = "\n".join([f"Line {i}" for i in range(1000)])
editor.setPlainText(text)
new_width = editor.line_number_area_width()
# Width should increase for more digits
assert new_width > initial_width
def test_code_editor_cursor_position_changed(qtbot, app):
"""Test that cursor position changes update line number area."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Line 1\nLine 2\nLine 3")
editor.show()
# Move cursor
cursor = editor.textCursor()
cursor.movePosition(cursor.MoveOperation.End)
editor.setTextCursor(cursor)
# Should trigger line number area update (via signal connection)
# Just verify it doesn't crash
def test_line_number_area_width_calculation(qtbot, app):
"""Test line number area width calculation with various block counts."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
# Test with 1 line (should use minimum 2 digits)
editor.setPlainText("One line")
width_1 = editor.line_number_area_width()
assert width_1 > 0
# Test with 10 lines (2 digits)
editor.setPlainText("\n".join(["Line"] * 10))
width_10 = editor.line_number_area_width()
assert width_10 >= width_1
# Test with 100 lines (3 digits)
editor.setPlainText("\n".join(["Line"] * 100))
width_100 = editor.line_number_area_width()
assert width_100 > width_10
def test_code_editor_viewport_margins(qtbot, app):
"""Test that viewport margins are set correctly."""
editor = CodeEditorWithLineNumbers()
qtbot.addWidget(editor)
editor.setPlainText("Test")
editor.show()
# Left margin should equal line number area width
margins = editor.viewportMargins()
line_width = editor.line_number_area_width()
assert margins.left() == line_width
assert margins.top() == 0
assert margins.right() == 0
assert margins.bottom() == 0

View file

@ -0,0 +1,289 @@
from unittest.mock import patch
from pathlib import Path
import tempfile
from PySide6.QtCore import QUrl
from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtGui import QDesktopServices
def test_open_document_from_db_success(qtbot, app, fresh_db):
"""Test successfully opening a document."""
# Import here to avoid circular import issues
from bouquin.document_utils import open_document_from_db
# Add a project and document
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".txt"))
doc_path.write_text("test content for document")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
# Mock QDesktopServices.openUrl
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
# Call the function
success = open_document_from_db(
fresh_db, doc_id, doc_path.name, parent_widget=None
)
# Verify success
assert success is True
# Verify openUrl was called with a QUrl
assert mock_open.called
args = mock_open.call_args[0]
assert isinstance(args[0], QUrl)
# Verify the URL points to a local file
url_string = args[0].toString()
assert url_string.startswith("file://")
assert "bouquin_doc_" in url_string
assert doc_path.suffix in url_string
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_with_parent_widget(qtbot, app, fresh_db):
"""Test opening a document with a parent widget provided."""
from bouquin.document_utils import open_document_from_db
# Create a parent widget
parent = QWidget()
qtbot.addWidget(parent)
# Add a project and document
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".pdf"))
doc_path.write_text("PDF content")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
success = open_document_from_db(
fresh_db, doc_id, doc_path.name, parent_widget=parent
)
assert success is True
assert mock_open.called
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_nonexistent_document(qtbot, app, fresh_db):
"""Test opening a non-existent document returns False."""
from bouquin.document_utils import open_document_from_db
# Try to open a document that doesn't exist
success = open_document_from_db(
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
)
# Should return False
assert success is False
def test_open_document_from_db_shows_error_with_parent(qtbot, app, fresh_db):
"""Test that error dialog is shown when parent widget is provided."""
from bouquin.document_utils import open_document_from_db
parent = QWidget()
qtbot.addWidget(parent)
# Mock QMessageBox.warning
with patch.object(QMessageBox, "warning") as mock_warning:
success = open_document_from_db(
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=parent
)
# Should return False and show warning
assert success is False
assert mock_warning.called
# Verify warning was shown with correct parent
call_args = mock_warning.call_args[0]
assert call_args[0] is parent
def test_open_document_from_db_no_error_dialog_without_parent(qtbot, app, fresh_db):
"""Test that no error dialog is shown when parent widget is None."""
from bouquin.document_utils import open_document_from_db
with patch.object(QMessageBox, "warning") as mock_warning:
success = open_document_from_db(
fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
)
# Should return False but NOT show warning
assert success is False
assert not mock_warning.called
def test_open_document_from_db_preserves_file_extension(qtbot, app, fresh_db):
"""Test that the temporary file has the correct extension."""
from bouquin.document_utils import open_document_from_db
# Test various file extensions
extensions = [".txt", ".pdf", ".docx", ".xlsx", ".jpg", ".png"]
for ext in extensions:
proj_id = fresh_db.add_project(f"Project for {ext}")
doc_path = Path(tempfile.mktemp(suffix=ext))
doc_path.write_text(f"content for {ext}")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(
QDesktopServices, "openUrl", return_value=True
) as mock_open:
open_document_from_db(fresh_db, doc_id, doc_path.name)
# Get the URL that was opened
url = mock_open.call_args[0][0]
url_string = url.toString()
# Verify the extension is preserved
assert ext in url_string, f"Extension {ext} not found in {url_string}"
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_file_without_extension(qtbot, app, fresh_db):
"""Test opening a document without a file extension."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp()) # No suffix
doc_path.write_text("content without extension")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
# Should still succeed
assert success is True
assert mock_open.called
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_desktop_services_failure(qtbot, app, fresh_db):
"""Test handling when QDesktopServices.openUrl returns False."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".txt"))
doc_path.write_text("test content")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
# Mock openUrl to return False (failure)
with patch.object(QDesktopServices, "openUrl", return_value=False):
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
# Should return False
assert success is False
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_binary_content(qtbot, app, fresh_db):
"""Test opening a document with binary content."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".bin"))
# Write some binary data
binary_data = bytes([0, 1, 2, 3, 255, 254, 253])
doc_path.write_bytes(binary_data)
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
assert success is True
assert mock_open.called
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_large_file(qtbot, app, fresh_db):
"""Test opening a large document."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".bin"))
# Create a 1MB file
large_data = b"x" * (1024 * 1024)
doc_path.write_bytes(large_data)
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
assert success is True
assert mock_open.called
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_temp_file_prefix(qtbot, app, fresh_db):
"""Test that temporary files have the correct prefix."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".txt"))
doc_path.write_text("test")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
open_document_from_db(fresh_db, doc_id, doc_path.name)
url = mock_open.call_args[0][0]
url_path = url.toLocalFile()
# Verify the temp file has the bouquin_doc_ prefix
assert "bouquin_doc_" in url_path
finally:
doc_path.unlink(missing_ok=True)
def test_open_document_from_db_multiple_calls(qtbot, app, fresh_db):
"""Test opening the same document multiple times."""
from bouquin.document_utils import open_document_from_db
proj_id = fresh_db.add_project("Test Project")
doc_path = Path(tempfile.mktemp(suffix=".txt"))
doc_path.write_text("test content")
try:
doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
# Open the same document 3 times
for _ in range(3):
success = open_document_from_db(fresh_db, doc_id, doc_path.name)
assert success is True
# Should have been called 3 times
assert mock_open.call_count == 3
# Each call should create a different temp file
call_urls = [call[0][0].toString() for call in mock_open.call_args_list]
# All URLs should be different (different temp files)
assert len(set(call_urls)) == 3
finally:
doc_path.unlink(missing_ok=True)

1061
tests/test_documents.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QInputDialog,
QFileDialog,
QDialog,
)
from sqlcipher3.dbapi2 import IntegrityError
@ -17,6 +18,8 @@ from bouquin.time_log import (
)
import bouquin.strings as strings
from unittest.mock import patch, MagicMock
@pytest.fixture
def theme_manager(app):
@ -2562,3 +2565,371 @@ def test_time_log_with_entry(qtbot, fresh_db):
# Widget should have been created successfully
assert widget is not None
def test_time_log_widget_open_dialog_log_only_when_no_date(qtbot, app, fresh_db):
"""Test _open_dialog_log_only when _current_date is None."""
widget = TimeLogWidget(fresh_db, themes=None)
qtbot.addWidget(widget)
# Set current date to None
widget._current_date = None
# Click should return early without crashing
widget._open_dialog_log_only()
# No dialog should be shown
def test_time_log_widget_open_dialog_log_only_opens_dialog(qtbot, app, fresh_db):
"""Test _open_dialog_log_only opens TimeLogDialog."""
widget = TimeLogWidget(fresh_db, themes=None)
qtbot.addWidget(widget)
# Set a valid date
widget._current_date = "2024-01-15"
# Mock TimeLogDialog
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
with patch("bouquin.time_log.TimeLogDialog", return_value=mock_dialog):
widget._open_dialog_log_only()
# Dialog should have been created with correct parameters
assert mock_dialog.exec.called
def test_time_log_widget_open_dialog_log_only_refreshes_when_collapsed(
qtbot, app, fresh_db
):
"""Test that opening dialog updates summary when widget is collapsed."""
widget = TimeLogWidget(fresh_db, themes=None)
qtbot.addWidget(widget)
widget._current_date = "2024-01-15"
# Collapse the widget
widget.toggle_btn.setChecked(False)
# Mock TimeLogDialog
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
with patch("bouquin.time_log.TimeLogDialog", return_value=mock_dialog):
widget._open_dialog_log_only()
# Should show collapsed hint
assert (
"collapsed" in widget.summary_label.text().lower()
or widget.summary_label.text() != ""
)
def test_time_log_dialog_log_entry_only_mode(qtbot, app, fresh_db):
"""Test TimeLogDialog in log_entry_only mode."""
dialog = TimeLogDialog(
fresh_db, "2024-01-15", log_entry_only=True, themes=None, close_after_add=True
)
qtbot.addWidget(dialog)
# In log_entry_only mode, these should be hidden
assert not dialog.delete_btn.isVisible()
assert not dialog.report_btn.isVisible()
assert not dialog.table.isVisible()
def test_time_log_dialog_log_entry_only_false(qtbot, app, fresh_db):
"""Test TimeLogDialog in normal mode (log_entry_only=False)."""
dialog = TimeLogDialog(
fresh_db, "2024-01-15", log_entry_only=False, themes=None, close_after_add=False
)
qtbot.addWidget(dialog)
dialog.show()
qtbot.waitExposed(dialog)
# In normal mode, these should be visible
assert dialog.delete_btn.isVisible()
assert dialog.report_btn.isVisible()
assert dialog.table.isVisible()
def test_time_log_dialog_change_date_cancelled(qtbot, app, fresh_db):
"""Test _on_change_date_clicked when user cancels."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Mock exec to return rejected
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
original_date = dialog._date_iso
dialog._on_change_date_clicked()
# Date should not change when cancelled
assert dialog._date_iso == original_date
def test_time_log_dialog_change_date_accepted(qtbot, app, fresh_db):
"""Test _on_change_date_clicked when user accepts (covers lines 410-450)."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Mock exec to return accepted - the dialog will use whatever date is in the calendar
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
# Just verify it doesn't crash - actual date may or may not change
# depending on what the real QCalendarWidget selects
dialog._on_change_date_clicked()
# Dialog should still be functional
assert dialog._date_iso is not None
def test_time_log_dialog_change_date_with_invalid_current_date(qtbot, app, fresh_db):
"""Test _on_change_date_clicked when current date is invalid (covers lines 410-412)."""
dialog = TimeLogDialog(fresh_db, "invalid-date", themes=None)
qtbot.addWidget(dialog)
# Should fall back to current date without crashing
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
dialog._on_change_date_clicked()
def test_time_log_dialog_change_date_with_themes(qtbot, app, fresh_db):
"""Test _on_change_date_clicked with theme manager (covers line 423-424)."""
themes_mock = MagicMock()
themes_mock.register_calendar = MagicMock()
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=themes_mock)
qtbot.addWidget(dialog)
# Mock exec to return rejected
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
dialog._on_change_date_clicked()
# Theme should have been applied to calendar
assert themes_mock.register_calendar.called
def test_time_log_dialog_table_item_changed_incomplete_row(qtbot, app, fresh_db):
"""Test _on_table_item_changed with incomplete row."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add incomplete row
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
# Only add project item, missing others
proj_item = QTableWidgetItem("Project")
dialog.table.setItem(0, 0, proj_item)
# Call _on_table_item_changed
dialog._on_table_item_changed(proj_item)
dialog.table.blockSignals(False)
# Should return early without crashing (covers lines 556-558)
def test_time_log_dialog_table_item_changed_creates_new_project(qtbot, app, fresh_db):
"""Test _on_table_item_changed creating a new project on the fly."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add a complete row with new project name
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Brand New Project")
proj_item.setData(Qt.ItemDataRole.UserRole, None) # No entry ID
act_item = QTableWidgetItem("Activity")
note_item = QTableWidgetItem("Note")
hours_item = QTableWidgetItem("2.5")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
dialog.table.setItem(0, 2, note_item)
dialog.table.setItem(0, 3, hours_item)
# Call _on_table_item_changed
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Should have created project and called add/update
projects = fresh_db.list_projects()
project_names = [name for _, name in projects]
assert "Brand New Project" in project_names
dialog.table.blockSignals(False)
def test_time_log_dialog_table_item_changed_without_note(qtbot, app, fresh_db):
"""Test _on_table_item_changed when note_item is None."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add row without note
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Project")
proj_item.setData(Qt.ItemDataRole.UserRole, None)
act_item = QTableWidgetItem("Activity")
hours_item = QTableWidgetItem("1.0")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
# Note: Don't set note_item (leave as None)
dialog.table.setItem(0, 3, hours_item)
# Call _on_table_item_changed
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Should handle None note gracefully (covers line 567)
assert dialog.note.text() == ""
dialog.table.blockSignals(False)
def test_time_log_dialog_table_item_changed_sets_button_state_for_new_entry(
qtbot, app, fresh_db
):
"""Test that _on_table_item_changed sets correct button state for new entry."""
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add row without entry ID (new entry)
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Project")
proj_item.setData(Qt.ItemDataRole.UserRole, None) # No entry ID
act_item = QTableWidgetItem("Activity")
hours_item = QTableWidgetItem("1.0")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
dialog.table.setItem(0, 3, hours_item)
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Delete button should be disabled for new entry (covers lines 601-603)
assert not dialog.delete_btn.isEnabled()
dialog.table.blockSignals(False)
def test_time_log_dialog_table_item_changed_sets_button_state_for_existing_entry(
qtbot, app, fresh_db
):
"""Test that _on_table_item_changed sets correct button state for existing entry."""
# Add a time log entry first
proj_id = fresh_db.add_project("Test Project")
act_id = fresh_db.add_activity("Activity")
entry_id = fresh_db.add_time_log(
"2024-01-15", proj_id, act_id, 120, "Note"
) # 120 minutes = 2 hours
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add row with entry ID
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Test Project")
proj_item.setData(Qt.ItemDataRole.UserRole, entry_id)
act_item = QTableWidgetItem("Activity")
hours_item = QTableWidgetItem("2.0")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
dialog.table.setItem(0, 3, hours_item)
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Delete button should be enabled for existing entry (covers lines 604-606)
assert dialog.delete_btn.isEnabled()
dialog.table.blockSignals(False)
def test_time_log_dialog_table_item_changed_finds_existing_project_by_name(
qtbot, app, fresh_db
):
"""Test _on_table_item_changed finding existing project by name."""
proj_id = fresh_db.add_project("Existing Project")
dialog = TimeLogDialog(fresh_db, "2024-01-15", themes=None)
qtbot.addWidget(dialog)
# Block signals to prevent Qt cleanup
dialog.table.blockSignals(True)
# Add row with existing project name
dialog.table.setRowCount(1)
from PySide6.QtWidgets import QTableWidgetItem
proj_item = QTableWidgetItem("Existing Project")
proj_item.setData(Qt.ItemDataRole.UserRole, None)
act_item = QTableWidgetItem("Activity")
hours_item = QTableWidgetItem("1.0")
dialog.table.setItem(0, 0, proj_item)
dialog.table.setItem(0, 1, act_item)
dialog.table.setItem(0, 3, hours_item)
with patch.object(dialog, "_on_add_or_update"):
dialog._on_table_item_changed(proj_item)
# Should find and select existing project (covers lines 571-580)
assert dialog.project_combo.currentData() == proj_id
dialog.table.blockSignals(False)
def test_time_report_dialog_initialization(qtbot, app, fresh_db):
"""Test TimeReportDialog initialization."""
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
# Should initialize without crashing
assert dialog is not None
def test_time_code_manager_dialog_initialization(qtbot, app, fresh_db):
"""Test TimeCodeManagerDialog initialization."""
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
# Should initialize without crashing
assert dialog is not None
def test_time_code_manager_dialog_with_focus_tab(qtbot, app, fresh_db):
"""Test TimeCodeManagerDialog with initial tab focus."""
# Test with projects tab
dialog = TimeCodeManagerDialog(fresh_db, focus_tab="projects")
qtbot.addWidget(dialog)
assert dialog.tabs.currentIndex() == 0
# Test with activities tab
dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities")
qtbot.addWidget(dialog2)
assert dialog2.tabs.currentIndex() == 1