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,3 +1,8 @@
# 0.6.1
* Consolidate some code related to opening documents using the Documents feature.
* More code coverage
# 0.6.0 # 0.6.0
* Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature. * Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature.

64
bouquin/document_utils.py Normal file
View file

@ -0,0 +1,64 @@
"""
Utility functions for document operations.
This module provides shared functionality for document handling across
different widgets (TodaysDocumentsWidget, DocumentsDialog, SearchResultsDialog,
and TagBrowserDialog).
"""
from __future__ import annotations
from pathlib import Path
import tempfile
from typing import TYPE_CHECKING, Optional
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox, QWidget
from . import strings
if TYPE_CHECKING:
from .db import DBManager
def open_document_from_db(
db: DBManager, doc_id: int, file_name: str, parent_widget: Optional[QWidget] = None
) -> bool:
"""
Open a document by fetching it from the database and opening with system default app.
"""
# Fetch document data from database
try:
data = db.document_data(doc_id)
except Exception as e:
# Show error dialog if parent widget is provided
if parent_widget:
QMessageBox.warning(
parent_widget,
strings._("project_documents_title"),
strings._("documents_open_failed").format(error=str(e)),
)
return False
# Extract file extension
suffix = Path(file_name).suffix or ""
# Create temporary file with same extension
tmp = tempfile.NamedTemporaryFile(
prefix="bouquin_doc_",
suffix=suffix,
delete=False,
)
# Write data to temp file
try:
tmp.write(data)
tmp.flush()
finally:
tmp.close()
# Open with system default application
success = QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))
return success

View file

@ -1,11 +1,9 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import tempfile
from typing import Optional from typing import Optional
from PySide6.QtCore import Qt, QUrl from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices, QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
@ -147,29 +145,9 @@ class TodaysDocumentsWidget(QFrame):
def _open_document(self, doc_id: int, file_name: str) -> None: def _open_document(self, doc_id: int, file_name: str) -> None:
"""Open a document from the list.""" """Open a document from the list."""
try: from .document_utils import open_document_from_db
data = self._db.document_data(doc_id)
except Exception as e:
QMessageBox.warning(
self,
strings._("project_documents_title"),
strings._("documents_open_failed").format(error=str(e)),
)
return
suffix = Path(file_name).suffix or "" open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
tmp = tempfile.NamedTemporaryFile(
prefix="bouquin_doc_",
suffix=suffix,
delete=False,
)
try:
tmp.write(data)
tmp.flush()
finally:
tmp.close()
QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))
def _open_documents_dialog(self) -> None: def _open_documents_dialog(self) -> None:
"""Open the full DocumentsDialog.""" """Open the full DocumentsDialog."""
@ -553,29 +531,9 @@ class DocumentsDialog(QDialog):
""" """
Fetch BLOB from DB, write to a temporary file, and open with default app. Fetch BLOB from DB, write to a temporary file, and open with default app.
""" """
try: from .document_utils import open_document_from_db
data = self._db.document_data(doc_id)
except Exception as e:
QMessageBox.warning(
self,
strings._("project_documents_title"),
strings._("documents_open_failed").format(error=str(e)),
)
return
suffix = Path(file_name).suffix or "" open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
tmp = tempfile.NamedTemporaryFile(
prefix="bouquin_doc_",
suffix=suffix,
delete=False,
)
try:
tmp.write(data)
tmp.flush()
finally:
tmp.close()
QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))
@staticmethod @staticmethod
def _format_size(size_bytes: int) -> str: def _format_size(size_bytes: int) -> str:

View file

@ -69,38 +69,10 @@ class Search(QWidget):
self._open_document(int(doc_id), file_name) self._open_document(int(doc_id), file_name)
def _open_document(self, doc_id: int, file_name: str) -> None: def _open_document(self, doc_id: int, file_name: str) -> None:
""" """Open the selected document in the user's default app."""
Open a document search result via a temp file. from bouquin.document_utils import open_document_from_db
"""
from pathlib import Path
import tempfile
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMessageBox
try: open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
data = self._db.document_data(doc_id)
except Exception as e:
QMessageBox.warning(
self,
strings._("project_documents_title"),
strings._("documents_open_failed").format(error=str(e)),
)
return
suffix = Path(file_name).suffix or ""
tmp = tempfile.NamedTemporaryFile(
prefix="bouquin_doc_",
suffix=suffix,
delete=False,
)
try:
tmp.write(data)
tmp.flush()
finally:
tmp.close()
QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))
def _search(self, text: str): def _search(self, text: str):
""" """

View file

@ -1,5 +1,5 @@
from PySide6.QtCore import Qt, Signal, QUrl from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QDesktopServices from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
@ -13,9 +13,6 @@ from PySide6.QtWidgets import (
QInputDialog, QInputDialog,
) )
from pathlib import Path
import tempfile
from .db import DBManager from .db import DBManager
from .settings import load_db_config from .settings import load_db_config
from . import strings from . import strings
@ -197,30 +194,10 @@ class TagBrowserDialog(QDialog):
self._open_document(int(doc_id), str(data.get("file_name"))) self._open_document(int(doc_id), str(data.get("file_name")))
def _open_document(self, doc_id: int, file_name: str) -> None: def _open_document(self, doc_id: int, file_name: str) -> None:
"""Open a tagged document via the default external application.""" """Open a tagged document from the list."""
try: from bouquin.document_utils import open_document_from_db
data = self._db.document_data(doc_id)
except Exception as e:
QMessageBox.warning(
self,
strings._("project_documents_title"),
strings._("documents_open_failed").format(error=str(e)),
)
return
suffix = Path(file_name).suffix or "" open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
tmp = tempfile.NamedTemporaryFile(
prefix="bouquin_doc_",
suffix=suffix,
delete=False,
)
try:
tmp.write(data)
tmp.flush()
finally:
tmp.close()
QDesktopServices.openUrl(QUrl.fromLocalFile(tmp.name))
def _add_a_tag(self): def _add_a_tag(self):
"""Add a new tag""" """Add a new tag"""

View file

@ -1,7 +1,14 @@
from PySide6.QtWidgets import QPushButton from PySide6.QtWidgets import QPushButton
from bouquin.code_block_editor_dialog import CodeBlockEditorDialog
from bouquin import strings 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): def _find_button_by_text(widget, text):
for btn in widget.findChildren(QPushButton): 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 _find_button_by_text(dlg, delete_txt) is None
assert dlg.code() == "x = 1" assert dlg.code() == "x = 1"
assert dlg.language() is None 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, QMessageBox,
QInputDialog, QInputDialog,
QFileDialog, QFileDialog,
QDialog,
) )
from sqlcipher3.dbapi2 import IntegrityError from sqlcipher3.dbapi2 import IntegrityError
@ -17,6 +18,8 @@ from bouquin.time_log import (
) )
import bouquin.strings as strings import bouquin.strings as strings
from unittest.mock import patch, MagicMock
@pytest.fixture @pytest.fixture
def theme_manager(app): def theme_manager(app):
@ -2562,3 +2565,371 @@ def test_time_log_with_entry(qtbot, fresh_db):
# Widget should have been created successfully # Widget should have been created successfully
assert widget is not None 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