bouquin/tests/test_time_log.py
Miguel Jacq 9435800910
Some checks failed
CI / test (push) Has been cancelled
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 25s
More tests
2025-11-26 17:12:58 +11:00

2598 lines
76 KiB
Python

import pytest
from datetime import date, timedelta
from PySide6.QtCore import Qt, QDate
from PySide6.QtWidgets import (
QMessageBox,
QInputDialog,
QFileDialog,
)
from sqlcipher3.dbapi2 import IntegrityError
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.time_log import (
TimeLogWidget,
TimeLogDialog,
TimeCodeManagerDialog,
TimeReportDialog,
)
import bouquin.strings as strings
@pytest.fixture
def theme_manager(app):
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
def _today():
return date.today().isoformat()
def _yesterday():
return (date.today() - timedelta(days=1)).isoformat()
def _tomorrow():
return (date.today() + timedelta(days=1)).isoformat()
# ============================================================================
# DB Methods Tests
# ============================================================================
def test_list_projects_empty(fresh_db):
"""List projects on empty db returns empty list."""
projects = fresh_db.list_projects()
assert projects == []
def test_add_project(fresh_db):
"""Add a project and verify it's retrievable."""
proj_id = fresh_db.add_project("Project Alpha")
assert proj_id > 0
projects = fresh_db.list_projects()
assert len(projects) == 1
assert projects[0] == (proj_id, "Project Alpha")
def test_add_project_duplicate_name(fresh_db):
"""Adding project with duplicate name is idempotent."""
id1 = fresh_db.add_project("Duplicate")
id2 = fresh_db.add_project("Duplicate")
assert id1 == id2
projects = fresh_db.list_projects()
assert len(projects) == 1
def test_add_project_empty_name(fresh_db):
"""Adding project with empty name raises ValueError."""
with pytest.raises(ValueError, match="empty project name"):
fresh_db.add_project("")
with pytest.raises(ValueError, match="empty project name"):
fresh_db.add_project(" ")
def test_add_project_strips_whitespace(fresh_db):
"""Project name is trimmed of leading/trailing whitespace."""
fresh_db.add_project(" Trimmed ")
projects = fresh_db.list_projects()
assert projects[0][1] == "Trimmed"
def test_list_projects_sorted(fresh_db):
"""Projects are returned sorted case-insensitively by name."""
fresh_db.add_project("Zebra")
fresh_db.add_project("alpha")
fresh_db.add_project("Beta")
projects = fresh_db.list_projects()
names = [p[1] for p in projects]
assert names == ["alpha", "Beta", "Zebra"]
def test_rename_project(fresh_db):
"""Rename a project."""
proj_id = fresh_db.add_project("Old Name")
fresh_db.rename_project(proj_id, "New Name")
projects = fresh_db.list_projects()
assert len(projects) == 1
assert projects[0] == (proj_id, "New Name")
def test_rename_project_to_existing_name_raises(fresh_db):
"""Renaming to existing name raises IntegrityError."""
fresh_db.add_project("Project A")
id_b = fresh_db.add_project("Project B")
with pytest.raises(IntegrityError):
fresh_db.rename_project(id_b, "Project A")
def test_rename_project_empty_name_does_nothing(fresh_db):
"""Renaming to empty string does nothing."""
proj_id = fresh_db.add_project("Original")
fresh_db.rename_project(proj_id, "")
projects = fresh_db.list_projects()
assert projects[0][1] == "Original"
def test_delete_project(fresh_db):
"""Delete a project."""
proj_id = fresh_db.add_project("To Delete")
fresh_db.delete_project(proj_id)
projects = fresh_db.list_projects()
assert len(projects) == 0
def test_delete_project_with_time_entries_raises(fresh_db):
"""Deleting project with time entries raises IntegrityError."""
proj_id = fresh_db.add_project("Active Project")
act_id = fresh_db.add_activity("Coding")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
with pytest.raises(IntegrityError):
fresh_db.delete_project(proj_id)
def test_list_activities_empty(fresh_db):
"""List activities on empty db returns empty list."""
activities = fresh_db.list_activities()
assert activities == []
def test_add_activity(fresh_db):
"""Add an activity and verify it's retrievable."""
act_id = fresh_db.add_activity("Coding")
assert act_id > 0
activities = fresh_db.list_activities()
assert len(activities) == 1
assert activities[0] == (act_id, "Coding")
def test_add_activity_duplicate_name(fresh_db):
"""Adding activity with duplicate name is idempotent."""
id1 = fresh_db.add_activity("Meeting")
id2 = fresh_db.add_activity("Meeting")
assert id1 == id2
activities = fresh_db.list_activities()
assert len(activities) == 1
def test_add_activity_empty_name(fresh_db):
"""Adding activity with empty name raises ValueError."""
with pytest.raises(ValueError, match="empty activity name"):
fresh_db.add_activity("")
with pytest.raises(ValueError, match="empty activity name"):
fresh_db.add_activity(" ")
def test_add_activity_strips_whitespace(fresh_db):
"""Activity name is trimmed of leading/trailing whitespace."""
fresh_db.add_activity(" Planning ")
activities = fresh_db.list_activities()
assert activities[0][1] == "Planning"
def test_list_activities_sorted(fresh_db):
"""Activities are returned sorted case-insensitively by name."""
fresh_db.add_activity("Writing")
fresh_db.add_activity("coding")
fresh_db.add_activity("Planning")
activities = fresh_db.list_activities()
names = [a[1] for a in activities]
assert names == ["coding", "Planning", "Writing"]
def test_rename_activity(fresh_db):
"""Rename an activity."""
act_id = fresh_db.add_activity("Old Activity")
fresh_db.rename_activity(act_id, "New Activity")
activities = fresh_db.list_activities()
assert len(activities) == 1
assert activities[0] == (act_id, "New Activity")
def test_rename_activity_to_existing_name_raises(fresh_db):
"""Renaming to existing name raises IntegrityError."""
fresh_db.add_activity("Activity A")
id_b = fresh_db.add_activity("Activity B")
with pytest.raises(IntegrityError):
fresh_db.rename_activity(id_b, "Activity A")
def test_rename_activity_empty_name_does_nothing(fresh_db):
"""Renaming to empty string does nothing."""
act_id = fresh_db.add_activity("Original")
fresh_db.rename_activity(act_id, "")
activities = fresh_db.list_activities()
assert activities[0][1] == "Original"
def test_delete_activity(fresh_db):
"""Delete an activity."""
act_id = fresh_db.add_activity("To Delete")
fresh_db.delete_activity(act_id)
activities = fresh_db.list_activities()
assert len(activities) == 0
def test_delete_activity_with_time_entries_raises(fresh_db):
"""Deleting activity with time entries raises IntegrityError."""
proj_id = fresh_db.add_project("Some Project")
act_id = fresh_db.add_activity("Used Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
with pytest.raises(IntegrityError):
fresh_db.delete_activity(act_id)
def test_add_time_log(fresh_db):
"""Add a time log entry."""
proj_id = fresh_db.add_project("Research")
act_id = fresh_db.add_activity("Reading")
entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 90, "Paper review")
assert entry_id > 0
entries = fresh_db.time_log_for_date(_today())
assert len(entries) == 1
entry = entries[0]
assert entry[0] == entry_id
assert entry[1] == _today()
assert entry[2] == proj_id
assert entry[3] == "Research"
assert entry[4] == act_id
assert entry[5] == "Reading"
assert entry[6] == 90
assert entry[7] == "Paper review"
def test_add_time_log_creates_page_if_needed(fresh_db):
"""Adding time log creates page row if it doesn't exist."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Verify page doesn't exist
dates = fresh_db.dates_with_content()
assert _today() not in dates
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
# Page should now exist (even with no text content)
entries = fresh_db.time_log_for_date(_today())
assert len(entries) == 1
def test_add_time_log_without_note(fresh_db):
"""Add time log without note (None)."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 30)
entries = fresh_db.time_log_for_date(_today())
assert entries[0][7] is None
def test_update_time_log(fresh_db):
"""Update an existing time log entry."""
proj1_id = fresh_db.add_project("Project 1")
proj2_id = fresh_db.add_project("Project 2")
act1_id = fresh_db.add_activity("Activity 1")
act2_id = fresh_db.add_activity("Activity 2")
entry_id = fresh_db.add_time_log(_today(), proj1_id, act1_id, 60, "Original")
fresh_db.update_time_log(entry_id, proj2_id, act2_id, 120, "Updated")
entries = fresh_db.time_log_for_date(_today())
assert len(entries) == 1
entry = entries[0]
assert entry[0] == entry_id
assert entry[2] == proj2_id
assert entry[3] == "Project 2"
assert entry[4] == act2_id
assert entry[5] == "Activity 2"
assert entry[6] == 120
assert entry[7] == "Updated"
def test_delete_time_log(fresh_db):
"""Delete a time log entry."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 60)
fresh_db.delete_time_log(entry_id)
entries = fresh_db.time_log_for_date(_today())
assert len(entries) == 0
def test_time_log_for_date_empty(fresh_db):
"""Query time log for date with no entries."""
entries = fresh_db.time_log_for_date(_today())
assert entries == []
def test_time_log_for_date_multiple_entries(fresh_db):
"""Query returns multiple entries sorted by project, activity, id."""
proj_a = fresh_db.add_project("AAA")
proj_b = fresh_db.add_project("BBB")
act_x = fresh_db.add_activity("XXX")
act_y = fresh_db.add_activity("YYY")
# Add in non-sorted order
fresh_db.add_time_log(_today(), proj_b, act_y, 10)
fresh_db.add_time_log(_today(), proj_a, act_x, 20)
fresh_db.add_time_log(_today(), proj_a, act_y, 30)
fresh_db.add_time_log(_today(), proj_b, act_x, 40)
entries = fresh_db.time_log_for_date(_today())
assert len(entries) == 4
# Should be sorted by project name, then activity name
assert entries[0][3] == "AAA" and entries[0][5] == "XXX"
assert entries[1][3] == "AAA" and entries[1][5] == "YYY"
assert entries[2][3] == "BBB" and entries[2][5] == "XXX"
assert entries[3][3] == "BBB" and entries[3][5] == "YYY"
def test_time_report_by_day(fresh_db):
"""Time report grouped by day."""
proj_id = fresh_db.add_project("Project")
act1_id = fresh_db.add_activity("Activity 1")
act2_id = fresh_db.add_activity("Activity 2")
# Add entries for multiple days
fresh_db.add_time_log(_yesterday(), proj_id, act1_id, 60)
fresh_db.add_time_log(_yesterday(), proj_id, act2_id, 30)
fresh_db.add_time_log(_today(), proj_id, act1_id, 90)
fresh_db.add_time_log(_today(), proj_id, act2_id, 45)
report = fresh_db.time_report(proj_id, _yesterday(), _today(), "day")
assert len(report) == 4
# Each row is (period, activity_name, total_minutes)
yesterday_act1 = next(
r for r in report if r[0] == _yesterday() and r[1] == "Activity 1"
)
assert yesterday_act1[3] == 60
today_act1 = next(r for r in report if r[0] == _today() and r[1] == "Activity 1")
assert today_act1[3] == 90
def test_time_report_by_week(fresh_db):
"""Time report grouped by week."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entries spanning multiple weeks
date1 = "2024-01-01" # Monday, Week 1
date2 = "2024-01-03" # Same week
date3 = "2024-01-08" # Following Monday, Week 2
fresh_db.add_time_log(date1, proj_id, act_id, 60)
fresh_db.add_time_log(date2, proj_id, act_id, 30)
fresh_db.add_time_log(date3, proj_id, act_id, 45)
report = fresh_db.time_report(proj_id, date1, date3, "week")
# Should have 2 rows (2 weeks)
assert len(report) == 2
# First week total
assert report[0][3] == 90 # 60 + 30
# Second week total
assert report[1][3] == 45
def test_time_report_by_month(fresh_db):
"""Time report grouped by month."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entries spanning multiple months
date1 = "2024-01-15"
date2 = "2024-01-25"
date3 = "2024-02-10"
fresh_db.add_time_log(date1, proj_id, act_id, 60)
fresh_db.add_time_log(date2, proj_id, act_id, 30)
fresh_db.add_time_log(date3, proj_id, act_id, 45)
report = fresh_db.time_report(proj_id, date1, date3, "month")
# Should have 2 rows (2 months)
assert len(report) == 2
# January total
jan_row = next(r for r in report if r[0] == "2024-01")
assert jan_row[3] == 90 # 60 + 30
# February total
feb_row = next(r for r in report if r[0] == "2024-02")
assert feb_row[3] == 45
def test_time_report_multiple_activities(fresh_db):
"""Time report aggregates by activity within period."""
proj_id = fresh_db.add_project("Project")
act1_id = fresh_db.add_activity("Activity 1")
act2_id = fresh_db.add_activity("Activity 2")
fresh_db.add_time_log(_today(), proj_id, act1_id, 60)
fresh_db.add_time_log(_today(), proj_id, act1_id, 30) # Same activity
fresh_db.add_time_log(_today(), proj_id, act2_id, 45)
report = fresh_db.time_report(proj_id, _today(), _today(), "day")
assert len(report) == 2
act1_row = next(r for r in report if r[1] == "Activity 1")
assert act1_row[3] == 90 # 60 + 30 aggregated
act2_row = next(r for r in report if r[1] == "Activity 2")
assert act2_row[3] == 45
def test_time_report_filters_by_project(fresh_db):
"""Time report only includes specified project."""
proj1_id = fresh_db.add_project("Project 1")
proj2_id = fresh_db.add_project("Project 2")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj1_id, act_id, 60)
fresh_db.add_time_log(_today(), proj2_id, act_id, 90)
report = fresh_db.time_report(proj1_id, _today(), _today(), "day")
assert len(report) == 1
assert report[0][3] == 60
def test_time_report_empty(fresh_db):
"""Time report with no matching entries."""
proj_id = fresh_db.add_project("Project")
report = fresh_db.time_report(proj_id, _today(), _today(), "day")
assert report == []
# ============================================================================
# TimeLogWidget Tests
# ============================================================================
def test_time_log_widget_toggle(qtbot, fresh_db):
"""Toggle expands/collapses the widget."""
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.show()
# Initially collapsed
assert not widget.body.isVisible()
assert widget.toggle_btn.arrowType() == Qt.RightArrow
# Toggle to expand
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
assert widget.body.isVisible()
assert widget.toggle_btn.arrowType() == Qt.DownArrow
# Toggle to collapse
widget.toggle_btn.setChecked(False)
widget._on_toggle(False)
assert not widget.body.isVisible()
assert widget.toggle_btn.arrowType() == Qt.RightArrow
def test_time_log_widget_set_current_date_no_entries(qtbot, fresh_db):
"""Set current date with no entries."""
strings.load_strings("en")
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.set_current_date(_today())
assert widget._current_date == _today()
# When collapsed, shows hint
assert "Time log" in widget.summary_label.text()
def test_time_log_widget_set_current_date_with_entries(qtbot, fresh_db):
"""Set current date with entries shows summary."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 90) # 1.5 hours
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
# Expand first
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
widget.set_current_date(_today())
# Should show total in title
assert "1.5" in widget.toggle_btn.text() or "1.50" in widget.toggle_btn.text()
# Body should show breakdown
summary = widget.summary_label.text()
assert "Project" in summary
def test_time_log_widget_open_dialog(qtbot, fresh_db, monkeypatch):
"""Open dialog button opens TimeLogDialog."""
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.set_current_date(_today())
dialog_shown = {"shown": False}
def mock_exec(self):
dialog_shown["shown"] = True
return 0
monkeypatch.setattr(TimeLogDialog, "exec", mock_exec)
widget.open_btn.click()
assert dialog_shown["shown"]
def test_time_log_widget_no_date_open_dialog_does_nothing(qtbot, fresh_db, monkeypatch):
"""Open dialog with no date set does nothing."""
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
dialog_shown = {"shown": False}
def mock_exec(self):
dialog_shown["shown"] = True
return 0
monkeypatch.setattr(TimeLogDialog, "exec", mock_exec)
widget.open_btn.click()
assert not dialog_shown["shown"]
def test_time_log_widget_updates_after_dialog_close(qtbot, fresh_db, monkeypatch):
"""Widget refreshes summary after dialog closes."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
widget.set_current_date(_today())
# Add entry via DB (simulating dialog action)
def mock_exec(self):
fresh_db.add_time_log(_today(), proj_id, act_id, 120)
return 0
monkeypatch.setattr(TimeLogDialog, "exec", mock_exec)
widget.open_btn.click()
# Summary should be updated
assert "2.0" in widget.toggle_btn.text() or "2.00" in widget.toggle_btn.text()
# ============================================================================
# TimeLogDialog Tests
# ============================================================================
def test_time_log_dialog_creation(qtbot, fresh_db):
"""TimeLogDialog can be created."""
strings.load_strings("en")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
assert _today() in dialog.windowTitle()
assert dialog.project_combo.count() == 0
assert dialog.table.rowCount() == 0
def test_time_log_dialog_loads_projects(qtbot, fresh_db):
"""Dialog loads existing projects into combo."""
fresh_db.add_project("Project A")
fresh_db.add_project("Project B")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 2
def test_time_log_dialog_loads_activities_for_autocomplete(qtbot, fresh_db):
"""Dialog loads activities for autocomplete."""
fresh_db.add_activity("Coding")
fresh_db.add_activity("Testing")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
completer = dialog.activity_edit.completer()
assert completer is not None
def test_time_log_dialog_loads_existing_entries(qtbot, fresh_db):
"""Dialog loads existing time log entries."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 90)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
assert dialog.table.rowCount() == 1
assert "Project" in dialog.table.item(0, 0).text()
assert "Activity" in dialog.table.item(0, 1).text()
assert (
"1.5" in dialog.table.item(0, 3).text()
or "1.50" in dialog.table.item(0, 3).text()
)
def test_time_log_dialog_add_entry_without_project_shows_warning(
qtbot, fresh_db, monkeypatch
):
"""Add entry without selecting project shows warning."""
strings.load_strings("en")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
dialog.activity_edit.setText("Some Activity")
dialog.hours_spin.setValue(1.0)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._on_add_or_update()
assert warning_shown["shown"]
def test_time_log_dialog_add_entry_without_activity_shows_warning(
qtbot, fresh_db, monkeypatch
):
"""Add entry without activity shows warning."""
strings.load_strings("en")
fresh_db.add_project("Project")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.hours_spin.setValue(1.0)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._on_add_or_update()
assert warning_shown["shown"]
def test_time_log_dialog_add_entry_success(qtbot, fresh_db):
"""Successfully add a new time entry."""
fresh_db.add_project("Project")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.activity_edit.setText("New Activity")
dialog.hours_spin.setValue(2.5)
dialog._on_add_or_update()
assert dialog.table.rowCount() == 1
assert "New Activity" in dialog.table.item(0, 1).text()
assert (
"2.5" in dialog.table.item(0, 3).text()
or "2.50" in dialog.table.item(0, 3).text()
)
def test_time_log_dialog_select_row_enables_delete(qtbot, fresh_db):
"""Selecting a row enables delete button and populates fields."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 90)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
# Initially delete is disabled
assert not dialog.delete_btn.isEnabled()
# Select first row
dialog.table.selectRow(0)
# Delete should be enabled
assert dialog.delete_btn.isEnabled()
# Fields should be populated
assert dialog.activity_edit.text() == "Activity"
assert dialog.hours_spin.value() == 1.5
def test_time_log_dialog_update_entry(qtbot, fresh_db):
"""Update an existing entry."""
proj1_id = fresh_db.add_project("Project 1")
fresh_db.add_project("Project 2")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj1_id, act_id, 60)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
# Select and modify
dialog.table.selectRow(0)
dialog.project_combo.setCurrentIndex(1) # Project 2
dialog.hours_spin.setValue(2.0)
dialog._on_add_or_update()
# Should still have 1 row (updated, not added)
assert dialog.table.rowCount() == 1
assert "Project 2" in dialog.table.item(0, 0).text()
assert (
"2.0" in dialog.table.item(0, 3).text()
or "2.00" in dialog.table.item(0, 3).text()
)
def test_time_log_dialog_delete_entry(qtbot, fresh_db):
"""Delete a time log entry."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
assert dialog.table.rowCount() == 1
dialog.table.selectRow(0)
dialog._on_delete_entry()
assert dialog.table.rowCount() == 0
def test_time_log_dialog_manage_projects_opens_dialog(qtbot, fresh_db, monkeypatch):
"""Manage projects button opens TimeCodeManagerDialog."""
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
manager_shown = {"shown": False}
def mock_exec(self):
manager_shown["shown"] = True
return 0
monkeypatch.setattr(TimeCodeManagerDialog, "exec", mock_exec)
dialog.manage_projects_btn.click()
assert manager_shown["shown"]
def test_time_log_dialog_manage_activities_opens_dialog(qtbot, fresh_db, monkeypatch):
"""Manage activities button opens TimeCodeManagerDialog."""
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
manager_shown = {"shown": False}
def mock_exec(self):
manager_shown["shown"] = True
return 0
monkeypatch.setattr(TimeCodeManagerDialog, "exec", mock_exec)
dialog.manage_activities_btn.click()
assert manager_shown["shown"]
def test_time_log_dialog_run_report_opens_dialog(qtbot, fresh_db, monkeypatch):
"""Run report button opens TimeReportDialog."""
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
report_shown = {"shown": False}
def mock_exec(self):
report_shown["shown"] = True
return 0
monkeypatch.setattr(TimeReportDialog, "exec", mock_exec)
dialog.report_btn.click()
assert report_shown["shown"]
# ============================================================================
# TimeCodeManagerDialog Tests
# ============================================================================
def test_time_code_manager_dialog_creation(qtbot, fresh_db):
"""TimeCodeManagerDialog can be created."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.tabs.count() == 2
assert dialog.tabs.currentIndex() == 0 # Projects tab
def test_time_code_manager_dialog_focus_activities_tab(qtbot, fresh_db):
"""Can focus on activities tab initially."""
dialog = TimeCodeManagerDialog(fresh_db, focus_tab="activities")
qtbot.addWidget(dialog)
assert dialog.tabs.currentIndex() == 1
def test_time_code_manager_dialog_loads_projects(qtbot, fresh_db):
"""Dialog loads existing projects."""
fresh_db.add_project("Project A")
fresh_db.add_project("Project B")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_list.count() == 2
def test_time_code_manager_dialog_loads_activities(qtbot, fresh_db):
"""Dialog loads existing activities."""
fresh_db.add_activity("Activity 1")
fresh_db.add_activity("Activity 2")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.activity_list.count() == 2
def test_time_code_manager_add_project(qtbot, fresh_db, monkeypatch):
"""Add a new project."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
def mock_get_text(parent, title, label, mode, default):
return "New Project", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._add_project()
assert dialog.project_list.count() == 1
assert dialog.project_list.item(0).text() == "New Project"
def test_time_code_manager_add_project_cancelled(qtbot, fresh_db, monkeypatch):
"""Cancel adding project."""
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
def mock_get_text(parent, title, label, mode, default):
return "", False
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._add_project()
assert dialog.project_list.count() == 0
def test_time_code_manager_add_project_invalid_shows_warning(
qtbot, fresh_db, monkeypatch
):
"""Adding invalid project shows warning."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
def mock_get_text(parent, title, label, mode, default):
return "Valid Name", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
# Force add_project to raise ValueError
original_add = fresh_db.add_project
def bad_add(name):
raise ValueError("empty project name")
fresh_db.add_project = bad_add
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._add_project()
assert warning_shown["shown"]
fresh_db.add_project = original_add
def test_time_code_manager_rename_project(qtbot, fresh_db, monkeypatch):
"""Rename an existing project."""
strings.load_strings("en")
fresh_db.add_project("Old Name")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_list.setCurrentRow(0)
def mock_get_text(parent, title, label, mode, default):
return "New Name", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._rename_project()
assert dialog.project_list.item(0).text() == "New Name"
def test_time_code_manager_rename_project_no_selection_shows_info(
qtbot, fresh_db, monkeypatch
):
"""Rename without selection shows info message."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
info_shown = {"shown": False}
def mock_info(*args):
info_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "information", mock_info)
dialog._rename_project()
assert info_shown["shown"]
def test_time_code_manager_rename_project_to_duplicate_shows_warning(
qtbot, fresh_db, monkeypatch
):
"""Renaming to duplicate name shows warning."""
strings.load_strings("en")
fresh_db.add_project("Project A")
fresh_db.add_project("Project B")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_list.setCurrentRow(1)
def mock_get_text(parent, title, label, mode, default):
return "Project A", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._rename_project()
assert warning_shown["shown"]
def test_time_code_manager_delete_project(qtbot, fresh_db, monkeypatch):
"""Delete a project."""
strings.load_strings("en")
fresh_db.add_project("To Delete")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_list.setCurrentRow(0)
def mock_question(*args, **kwargs):
return QMessageBox.StandardButton.Yes
monkeypatch.setattr(QMessageBox, "question", mock_question)
dialog._delete_project()
assert dialog.project_list.count() == 0
def test_time_code_manager_delete_project_cancelled(qtbot, fresh_db, monkeypatch):
"""Cancel delete project."""
strings.load_strings("en")
fresh_db.add_project("Keep Me")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_list.setCurrentRow(0)
def mock_question(*args, **kwargs):
return QMessageBox.StandardButton.No
monkeypatch.setattr(QMessageBox, "question", mock_question)
dialog._delete_project()
assert dialog.project_list.count() == 1
def test_time_code_manager_delete_project_with_entries_shows_warning(
qtbot, fresh_db, monkeypatch
):
"""Deleting project with entries shows warning."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Used Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_list.setCurrentRow(0)
def mock_question(*args, **kwargs):
return QMessageBox.StandardButton.Yes
monkeypatch.setattr(QMessageBox, "question", mock_question)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._delete_project()
assert warning_shown["shown"]
assert dialog.project_list.count() == 1 # Not deleted
def test_time_code_manager_add_activity(qtbot, fresh_db, monkeypatch):
"""Add a new activity."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
def mock_get_text(parent, title, label, mode, default):
return "New Activity", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._add_activity()
assert dialog.activity_list.count() == 1
assert dialog.activity_list.item(0).text() == "New Activity"
def test_time_code_manager_rename_activity(qtbot, fresh_db, monkeypatch):
"""Rename an existing activity."""
strings.load_strings("en")
fresh_db.add_activity("Old Activity")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.activity_list.setCurrentRow(0)
def mock_get_text(parent, title, label, mode, default):
return "New Activity", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._rename_activity()
assert dialog.activity_list.item(0).text() == "New Activity"
def test_time_code_manager_delete_activity(qtbot, fresh_db, monkeypatch):
"""Delete an activity."""
strings.load_strings("en")
fresh_db.add_activity("To Delete")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.activity_list.setCurrentRow(0)
def mock_question(*args, **kwargs):
return QMessageBox.StandardButton.Yes
monkeypatch.setattr(QMessageBox, "question", mock_question)
dialog._delete_activity()
assert dialog.activity_list.count() == 0
def test_time_code_manager_delete_activity_with_entries_shows_warning(
qtbot, fresh_db, monkeypatch
):
"""Deleting activity with entries shows warning."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Used Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.activity_list.setCurrentRow(0)
def mock_question(*args, **kwargs):
return QMessageBox.StandardButton.Yes
monkeypatch.setattr(QMessageBox, "question", mock_question)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._delete_activity()
assert warning_shown["shown"]
assert dialog.activity_list.count() == 1 # Not deleted
# ============================================================================
# TimeReportDialog Tests
# ============================================================================
def test_time_report_dialog_creation(qtbot, fresh_db):
"""TimeReportDialog can be created."""
strings.load_strings("en")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 0
assert dialog.granularity.count() == 3 # day, week, month
def test_time_report_dialog_loads_projects(qtbot, fresh_db):
"""Dialog loads projects."""
fresh_db.add_project("Project A")
fresh_db.add_project("Project B")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 2
def test_time_report_dialog_default_date_range(qtbot, fresh_db):
"""Dialog defaults to last 7 days."""
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
today = QDate.currentDate()
week_ago = today.addDays(-7)
assert dialog.from_date.date() == week_ago
assert dialog.to_date.date() == today
def test_time_report_dialog_run_report(qtbot, fresh_db):
"""Run a time report."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 90)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(0) # day
dialog._run_report()
assert dialog.table.rowCount() == 1
assert "Activity" in dialog.table.item(0, 1).text()
assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text()
def test_time_report_dialog_run_report_no_project_selected(qtbot, fresh_db):
"""Run report with no project selected does nothing."""
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog._run_report()
# Should not crash, table remains empty
assert dialog.table.rowCount() == 0
def test_time_report_dialog_export_csv_no_report_shows_info(
qtbot, fresh_db, monkeypatch
):
"""Export CSV without running report shows info."""
strings.load_strings("en")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
info_shown = {"shown": False}
def mock_info(*args):
info_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "information", mock_info)
dialog._export_csv()
assert info_shown["shown"]
def test_time_report_dialog_export_csv_success(qtbot, fresh_db, tmp_path, monkeypatch):
"""Export report to CSV."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 120)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog._run_report()
csv_file = str(tmp_path / "report.csv")
def mock_get_save_filename(*args, **kwargs):
return csv_file, "CSV Files (*.csv)"
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename)
dialog._export_csv()
import os
assert os.path.exists(csv_file)
with open(csv_file, "r") as f:
content = f.read()
assert "Activity" in content
assert "2.00" in content
def test_time_report_dialog_export_csv_cancelled(qtbot, fresh_db, monkeypatch):
"""Cancel CSV export."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog._run_report()
def mock_get_save_filename(*args, **kwargs):
return "", ""
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename)
# Should not crash
dialog._export_csv()
def test_time_report_dialog_export_pdf_no_report_shows_info(
qtbot, fresh_db, monkeypatch
):
"""Export PDF without running report shows info."""
strings.load_strings("en")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
info_shown = {"shown": False}
def mock_info(*args):
info_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "information", mock_info)
dialog._export_pdf()
assert info_shown["shown"]
def test_time_report_dialog_export_pdf_success(qtbot, fresh_db, tmp_path, monkeypatch):
"""Export report to PDF."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Test Project")
act_id = fresh_db.add_activity("Testing")
fresh_db.add_time_log(_today(), proj_id, act_id, 150)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog._run_report()
pdf_file = str(tmp_path / "report.pdf")
def mock_get_save_filename(*args, **kwargs):
return pdf_file, "PDF Files (*.pdf)"
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename)
dialog._export_pdf()
import os
assert os.path.exists(pdf_file)
# PDF should have content
assert os.path.getsize(pdf_file) > 0
def test_time_report_dialog_export_pdf_cancelled(qtbot, fresh_db, monkeypatch):
"""Cancel PDF export."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog._run_report()
def mock_get_save_filename(*args, **kwargs):
return "", ""
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename)
# Should not crash
dialog._export_pdf()
def test_time_report_dialog_granularity_week(qtbot, fresh_db):
"""Report with week granularity."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entries on different days of the same week
date1 = "2024-01-01"
date2 = "2024-01-03"
fresh_db.add_time_log(date1, proj_id, act_id, 60)
fresh_db.add_time_log(date2, proj_id, act_id, 90)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(1) # week
dialog._run_report()
# Should aggregate to single week
assert dialog.table.rowCount() == 1
hours_text = dialog.table.item(0, 3).text()
assert "2.5" in hours_text or "2.50" in hours_text
def test_time_report_dialog_granularity_month(qtbot, fresh_db):
"""Report with month granularity."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entries on different days of the same month
date1 = "2024-01-05"
date2 = "2024-01-25"
fresh_db.add_time_log(date1, proj_id, act_id, 60)
fresh_db.add_time_log(date2, proj_id, act_id, 90)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(2) # month
dialog._run_report()
# Should aggregate to single month
assert dialog.table.rowCount() == 1
hours_text = dialog.table.item(0, 3).text()
assert "2.5" in hours_text or "2.50" in hours_text
def test_time_report_dialog_multiple_activities_same_period(qtbot, fresh_db):
"""Report shows multiple activities separately."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act1_id = fresh_db.add_activity("Activity 1")
act2_id = fresh_db.add_activity("Activity 2")
fresh_db.add_time_log(_today(), proj_id, act1_id, 60)
fresh_db.add_time_log(_today(), proj_id, act2_id, 90)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog._run_report()
assert dialog.table.rowCount() == 2
def test_time_log_widget_calculates_per_project_totals(qtbot, fresh_db):
"""Widget shows per-project breakdown in summary."""
strings.load_strings("en")
proj1_id = fresh_db.add_project("Project A")
proj2_id = fresh_db.add_project("Project B")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj1_id, act_id, 60)
fresh_db.add_time_log(_today(), proj2_id, act_id, 90)
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
widget.set_current_date(_today())
summary = widget.summary_label.text()
assert "Project A" in summary
assert "Project B" in summary
assert "1.00h" in summary
assert "1.50h" in summary
def test_time_report_dialog_csv_export_handles_os_error(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""CSV export handles OSError gracefully."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog._run_report()
# Use a path that will cause an error (e.g., directory instead of file)
bad_path = str(tmp_path)
def mock_get_save_filename(*args, **kwargs):
return bad_path, "CSV Files (*.csv)"
monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._export_csv()
assert warning_shown["shown"]
# ============================================================================
# Additional TimeLogWidget Edge Cases
# ============================================================================
def test_time_log_widget_collapsed_shows_hint_after_date_set(qtbot, fresh_db):
"""When collapsed, setting date shows hint instead of full summary."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 90)
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
# Keep collapsed
assert not widget.toggle_btn.isChecked()
widget.set_current_date(_today())
# Should show hint, not full breakdown
assert (
"hint" in widget.summary_label.text().lower()
or "time log" in widget.summary_label.text().lower()
)
def test_time_log_widget_no_date_shows_no_date_message(qtbot, fresh_db):
"""Widget with no date set shows appropriate message."""
strings.load_strings("en")
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
# Expand to see summary
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Don't set a date
widget._reload_summary()
# Should indicate no date is set
summary = widget.summary_label.text()
assert "no date" in summary.lower() or "time log" in summary.lower()
def test_time_log_widget_header_updates_on_toggle(qtbot, fresh_db):
"""Header total is visible even when collapsed."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 120) # 2 hours
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.set_current_date(_today())
# Expand to trigger reload
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
# Header should show total
assert "2" in widget.toggle_btn.text()
# Collapse again
widget.toggle_btn.setChecked(False)
widget._on_toggle(False)
# Header total should still be visible
assert "2" in widget.toggle_btn.text()
def test_time_log_widget_dialog_updates_on_close_when_collapsed(
qtbot, fresh_db, monkeypatch
):
"""When dialog closes, widget updates summary even if collapsed."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.set_current_date(_today())
# Keep collapsed
assert not widget.toggle_btn.isChecked()
def mock_exec(self):
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
return 0
monkeypatch.setattr(TimeLogDialog, "exec", mock_exec)
widget.open_btn.click()
# Should show hint after update when collapsed
assert (
"hint" in widget.summary_label.text().lower()
or "time log" in widget.summary_label.text().lower()
)
# ============================================================================
# Additional TimeLogDialog Edge Cases
# ============================================================================
def test_time_log_dialog_deselect_clears_current_entry(qtbot, fresh_db):
"""Deselecting row clears current entry and disables delete."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
# Select
dialog.table.selectRow(0)
assert dialog.delete_btn.isEnabled()
# Clear selection
dialog.table.clearSelection()
# Trigger selection changed
dialog._on_row_selected()
assert not dialog.delete_btn.isEnabled()
assert dialog._current_entry_id is None
def test_time_log_dialog_delete_without_selection_does_nothing(qtbot, fresh_db):
"""Delete button when no entry selected does nothing."""
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
# No selection
dialog._current_entry_id = None
# Should not crash
dialog._on_delete_entry()
def test_time_log_dialog_creates_activity_if_new(qtbot, fresh_db):
"""Dialog creates activity if it doesn't exist."""
fresh_db.add_project("Project")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.activity_edit.setText("Brand New Activity")
dialog.hours_spin.setValue(1.0)
dialog._on_add_or_update()
# Activity should have been created
activities = fresh_db.list_activities()
assert len(activities) == 1
assert activities[0][1] == "Brand New Activity"
def test_time_log_dialog_rounds_hours_to_minutes(qtbot, fresh_db):
"""Hours are correctly converted to integer minutes."""
fresh_db.add_project("Project")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.activity_edit.setText("Activity")
dialog.hours_spin.setValue(1.75) # 1 hour 45 minutes = 105 minutes
dialog._on_add_or_update()
entries = fresh_db.time_log_for_date(_today())
assert entries[0][6] == 105 # minutes
def test_time_log_dialog_update_button_text_changes(qtbot, fresh_db):
"""Button text changes between Add and Update."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
# Initially "Add"
assert "add" in dialog.add_update_btn.text().lower()
# Select entry
dialog.table.selectRow(0)
# Should change to "Update"
assert "update" in dialog.add_update_btn.text().lower()
# Deselect
dialog.table.clearSelection()
dialog._on_row_selected()
# Back to "Add"
assert "add" in dialog.add_update_btn.text().lower()
def test_time_log_dialog_project_selection_by_name(qtbot, fresh_db):
"""Selecting entry sets project combo by name match."""
fresh_db.add_project("Project A")
proj2_id = fresh_db.add_project("Project B")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj2_id, act_id, 60)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
dialog.table.selectRow(0)
# Should select Project B
assert dialog.project_combo.currentText() == "Project B"
def test_time_log_dialog_project_not_found_in_combo(qtbot, fresh_db):
"""If project name not found in combo, selection doesn't crash."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 60)
# Delete the project from DB manually (shouldn't happen, but defensive)
fresh_db.delete_time_log(entry_id)
fresh_db.delete_project(proj_id)
# Re-add entry with orphaned project reference (simulated edge case)
# Actually can't easily simulate this due to FK constraints
# So just test normal case - combo should work fine
proj_id = fresh_db.add_project("Project")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
dialog.table.selectRow(0)
# Should not crash
# ============================================================================
# Additional TimeCodeManagerDialog Edge Cases
# ============================================================================
def test_time_code_manager_rename_cancelled_does_nothing(qtbot, fresh_db, monkeypatch):
"""Cancelling rename does nothing."""
strings.load_strings("en")
fresh_db.add_project("Original")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_list.setCurrentRow(0)
def mock_get_text(parent, title, label, mode, default):
return "", False
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._rename_project()
# Name should remain unchanged
assert dialog.project_list.item(0).text() == "Original"
def test_time_code_manager_rename_to_same_name_does_nothing(
qtbot, fresh_db, monkeypatch
):
"""Renaming to same name does nothing."""
strings.load_strings("en")
fresh_db.add_project("Same Name")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_list.setCurrentRow(0)
def mock_get_text(parent, title, label, mode, default):
return "Same Name", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._rename_project()
# Should still have one project
assert dialog.project_list.count() == 1
def test_time_code_manager_delete_no_selection_shows_info(qtbot, fresh_db, monkeypatch):
"""Delete without selection shows info."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
info_shown = {"shown": False}
def mock_info(*args):
info_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "information", mock_info)
dialog._delete_project()
assert info_shown["shown"]
def test_time_code_manager_add_empty_activity_cancelled(qtbot, fresh_db, monkeypatch):
"""Adding empty activity name is cancelled."""
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
def mock_get_text(parent, title, label, mode, default):
return "", True # Empty but OK clicked
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._add_activity()
# No activity should be added
assert dialog.activity_list.count() == 0
def test_time_code_manager_rename_activity_no_selection(qtbot, fresh_db, monkeypatch):
"""Rename activity without selection shows info."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
info_shown = {"shown": False}
def mock_info(*args):
info_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "information", mock_info)
dialog._rename_activity()
assert info_shown["shown"]
def test_time_code_manager_delete_activity_no_selection(qtbot, fresh_db, monkeypatch):
"""Delete activity without selection shows info."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
info_shown = {"shown": False}
def mock_info(*args):
info_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "information", mock_info)
dialog._delete_activity()
assert info_shown["shown"]
def test_time_code_manager_delete_activity_cancelled(qtbot, fresh_db, monkeypatch):
"""Cancelling delete activity keeps it."""
strings.load_strings("en")
fresh_db.add_activity("Keep Me")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.activity_list.setCurrentRow(0)
def mock_question(*args, **kwargs):
return QMessageBox.StandardButton.No
monkeypatch.setattr(QMessageBox, "question", mock_question)
dialog._delete_activity()
assert dialog.activity_list.count() == 1
# ============================================================================
# Additional TimeReportDialog Edge Cases
# ============================================================================
def test_time_report_dialog_empty_report_shows_zero(qtbot, fresh_db):
"""Running report with no data shows zero total."""
strings.load_strings("en")
fresh_db.add_project("Project")
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog._run_report()
assert dialog.table.rowCount() == 0
assert "0" in dialog.total_label.text()
def test_time_report_dialog_date_range_filters_correctly(qtbot, fresh_db):
"""Report only includes entries within date range."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entries on different dates
date1 = "2024-01-01"
date2 = "2024-01-15"
date3 = "2024-01-30"
fresh_db.add_time_log(date1, proj_id, act_id, 60)
fresh_db.add_time_log(date2, proj_id, act_id, 60)
fresh_db.add_time_log(date3, proj_id, act_id, 60)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
# Set range to only include middle date
dialog.from_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd"))
dialog._run_report()
# Should only have one entry
assert dialog.table.rowCount() == 1
def test_time_report_dialog_stores_report_state(qtbot, fresh_db):
"""Dialog stores last report state for export."""
strings.load_strings("en")
proj_id = fresh_db.add_project("My Project")
act_id = fresh_db.add_activity("My Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 90)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(1) # week
dialog._run_report()
# Check stored state
assert dialog._last_project_name == "My Project"
assert dialog._last_start == _today()
assert dialog._last_end == _today()
assert "week" in dialog._last_gran_label.lower()
assert len(dialog._last_rows) == 1
assert dialog._last_total_minutes == 90
def test_time_report_dialog_pdf_export_with_multiple_periods(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""PDF export handles multiple time periods with chart."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entries on multiple days
date1 = "2024-01-01"
date2 = "2024-01-02"
date3 = "2024-01-03"
fresh_db.add_time_log(date1, proj_id, act_id, 60)
fresh_db.add_time_log(date2, proj_id, act_id, 90)
fresh_db.add_time_log(date3, proj_id, act_id, 45)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(0) # day
dialog._run_report()
pdf_file = str(tmp_path / "chart_test.pdf")
def mock_get_save_filename(*args, **kwargs):
return pdf_file, "PDF Files (*.pdf)"
monkeypatch.setattr(
"PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename
)
dialog._export_pdf()
import os
assert os.path.exists(pdf_file)
assert os.path.getsize(pdf_file) > 0
def test_time_report_dialog_pdf_export_with_zero_hours(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""PDF export handles entries with zero hours gracefully."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entry with 0 minutes (edge case)
fresh_db.add_time_log(_today(), proj_id, act_id, 0)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog._run_report()
pdf_file = str(tmp_path / "zero_hours.pdf")
def mock_get_save_filename(*args, **kwargs):
return pdf_file, "PDF Files (*.pdf)"
monkeypatch.setattr(
"PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename
)
# Should not crash
dialog._export_pdf()
def test_time_report_dialog_csv_includes_total_row(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""CSV export includes total row at bottom."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 90)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog._run_report()
csv_file = str(tmp_path / "total_test.csv")
def mock_get_save_filename(*args, **kwargs):
return csv_file, "CSV Files (*.csv)"
monkeypatch.setattr(
"PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename
)
dialog._export_csv()
with open(csv_file, "r") as f:
lines = f.readlines()
# Should have header, data row, blank, total row
assert len(lines) >= 4
# Last line should contain total
assert "Total" in lines[-1] or "total" in lines[-1]
def test_time_report_dialog_pdf_chart_with_single_period(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""PDF chart renders correctly with single period."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 120)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog._run_report()
pdf_file = str(tmp_path / "single_period.pdf")
def mock_get_save_filename(*args, **kwargs):
return pdf_file, "PDF Files (*.pdf)"
monkeypatch.setattr(
"PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename
)
dialog._export_pdf()
import os
assert os.path.exists(pdf_file)
# ============================================================================
# Integration Tests
# ============================================================================
def test_full_workflow_add_project_activity_log_report(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""Test full workflow: create project, activity, log time, run report."""
strings.load_strings("en")
# 1. Create project via dialog
manager = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(manager)
def mock_get_text_project(parent, title, label, mode, default):
return "Test Project", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text_project)
manager._add_project()
# 2. Create activity via dialog
def mock_get_text_activity(parent, title, label, mode, default):
return "Test Activity", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text_activity)
manager._add_activity()
manager.accept()
# 3. Log time via dialog
log_dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(log_dialog)
log_dialog.project_combo.setCurrentIndex(0)
log_dialog.activity_edit.setText("Test Activity")
log_dialog.hours_spin.setValue(2.5)
log_dialog._on_add_or_update()
log_dialog.accept()
# 4. Run report
report_dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(report_dialog)
report_dialog.project_combo.setCurrentIndex(0)
report_dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
report_dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
report_dialog._run_report()
# Verify report
assert report_dialog.table.rowCount() == 1
assert "Test Activity" in report_dialog.table.item(0, 1).text()
assert (
"2.5" in report_dialog.table.item(0, 3).text()
or "2.50" in report_dialog.table.item(0, 3).text()
)
# 5. Export CSV
csv_file = str(tmp_path / "workflow_test.csv")
def mock_get_save_filename(*args, **kwargs):
return csv_file, "CSV Files (*.csv)"
monkeypatch.setattr(
"PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename
)
report_dialog._export_csv()
import os
assert os.path.exists(csv_file)
def test_time_log_widget_with_multiple_projects_same_day(qtbot, fresh_db):
"""Widget correctly aggregates multiple projects on same day."""
strings.load_strings("en")
proj1_id = fresh_db.add_project("Alpha")
proj2_id = fresh_db.add_project("Beta")
proj3_id = fresh_db.add_project("Gamma")
act_id = fresh_db.add_activity("Work")
fresh_db.add_time_log(_today(), proj1_id, act_id, 30)
fresh_db.add_time_log(_today(), proj2_id, act_id, 45)
fresh_db.add_time_log(_today(), proj3_id, act_id, 60)
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.toggle_btn.setChecked(True)
widget._on_toggle(True)
widget.set_current_date(_today())
# Total should be 2.25 hours
title = widget.toggle_btn.text()
assert "2.25" in title or "2.2" in title
# Summary should list all three projects
summary = widget.summary_label.text()
assert "Alpha" in summary
assert "Beta" in summary
assert "Gamma" in summary
def test_time_log_dialog_preserves_entry_id_through_update(qtbot, fresh_db):
"""Updating entry preserves entry ID."""
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
# Select and update
dialog.table.selectRow(0)
original_id = dialog._current_entry_id
dialog.hours_spin.setValue(2.0)
dialog._on_add_or_update()
# Should still have same number of entries
entries = fresh_db.time_log_for_date(_today())
assert len(entries) == 1
assert entries[0][0] == original_id
def test_db_time_log_sorting_by_project_activity_id(fresh_db):
"""time_log_for_date returns entries sorted correctly."""
# Create in specific order to test sorting
proj_z = fresh_db.add_project("ZZZ")
proj_a = fresh_db.add_project("AAA")
act_y = fresh_db.add_activity("YYY")
act_x = fresh_db.add_activity("XXX")
# Add in reverse alphabetical order
fresh_db.add_time_log(_today(), proj_z, act_y, 10)
fresh_db.add_time_log(_today(), proj_z, act_x, 20)
fresh_db.add_time_log(_today(), proj_a, act_y, 30)
fresh_db.add_time_log(_today(), proj_a, act_x, 40)
entries = fresh_db.time_log_for_date(_today())
# Should be sorted: AAA/XXX, AAA/YYY, ZZZ/XXX, ZZZ/YYY
assert entries[0][3] == "AAA" and entries[0][5] == "XXX"
assert entries[1][3] == "AAA" and entries[1][5] == "YYY"
assert entries[2][3] == "ZZZ" and entries[2][5] == "XXX"
assert entries[3][3] == "ZZZ" and entries[3][5] == "YYY"
def test_time_code_manager_add_activity_invalid_shows_warning(
qtbot, fresh_db, monkeypatch
):
"""Test adding invalid activity shows warning."""
strings.load_strings("en")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
def mock_get_text(parent, title, label, mode, default):
return "Valid Name", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
# Force add_activity to raise ValueError
original_add = fresh_db.add_activity
def bad_add(name):
raise ValueError("empty activity name")
fresh_db.add_activity = bad_add
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._add_activity()
assert warning_shown["shown"]
fresh_db.add_activity = original_add
def test_time_code_manager_rename_activity_to_duplicate(qtbot, fresh_db, monkeypatch):
"""Test renaming activity to existing name shows warning."""
strings.load_strings("en")
fresh_db.add_activity("Activity1")
fresh_db.add_activity("Activity2")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
dialog.activity_list.setCurrentRow(0)
def mock_get_text(parent, title, label, mode, default):
return "Activity2", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._rename_activity()
assert warning_shown["shown"]
def test_time_code_manager_rename_activity_cancelled(qtbot, fresh_db, monkeypatch):
"""Test cancelling activity rename."""
strings.load_strings("en")
fresh_db.add_activity("Original")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
dialog.activity_list.setCurrentRow(0)
def mock_get_text(parent, title, label, mode, default):
return "", False
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._rename_activity()
# Should remain unchanged
assert dialog.activity_list.item(0).text() == "Original"
def test_time_code_manager_rename_activity_same_name(qtbot, fresh_db, monkeypatch):
"""Test renaming activity to same name does nothing."""
strings.load_strings("en")
fresh_db.add_activity("SameName")
dialog = TimeCodeManagerDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
dialog.activity_list.setCurrentRow(0)
def mock_get_text(parent, title, label, mode, default):
return "SameName", True
monkeypatch.setattr(QInputDialog, "getText", mock_get_text)
dialog._rename_activity()
# Should still have one activity
assert dialog.activity_list.count() == 1
def test_time_report_dialog_pdf_export_error_handling(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""Test PDF export handles exceptions gracefully."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
fresh_db.add_time_log(_today(), proj_id, act_id, 60)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
dialog.project_combo.setCurrentIndex(0)
from PySide6.QtCore import QDate
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog._run_report()
pdf_file = str(tmp_path / "test.pdf")
def mock_get_save_filename(*args, **kwargs):
return pdf_file, "PDF Files (*.pdf)"
monkeypatch.setattr(
"PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename
)
# Mock QTextDocument.print_ to raise exception
from PySide6.QtGui import QTextDocument
original_print = QTextDocument.print_
def bad_print(self, printer):
raise Exception("Print error")
monkeypatch.setattr(QTextDocument, "print_", bad_print)
warning_shown = {"shown": False}
def mock_warning(*args):
warning_shown["shown"] = True
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
dialog._export_pdf()
# Should show warning
assert warning_shown["shown"]
monkeypatch.setattr(QTextDocument, "print_", original_print)
def test_time_log_dialog_hours_conversion_edge_cases(qtbot, fresh_db):
"""Test edge cases in hours to minutes conversion."""
strings.load_strings("en")
fresh_db.add_project("Project")
dialog = TimeLogDialog(fresh_db, _today())
qtbot.addWidget(dialog)
dialog.show()
dialog.project_combo.setCurrentIndex(0)
dialog.activity_edit.setText("Activity")
# Test 0 hours
dialog.hours_spin.setValue(0.0)
dialog._on_add_or_update()
entries = fresh_db.time_log_for_date(_today())
assert entries[-1][6] == 0
# Test fractional hours that round
dialog.hours_spin.setValue(0.333) # ~20 minutes
dialog._on_add_or_update()
entries = fresh_db.time_log_for_date(_today())
# Should round to nearest minute
assert 19 <= entries[-1][6] <= 21
def test_time_report_dialog_pdf_with_no_activity_data(
qtbot, fresh_db, tmp_path, monkeypatch
):
"""Test PDF export with report that has no data in bars (0 minutes)."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entry with 0 minutes
fresh_db.add_time_log(_today(), proj_id, act_id, 0)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
dialog.project_combo.setCurrentIndex(0)
from PySide6.QtCore import QDate
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog._run_report()
pdf_file = str(tmp_path / "zero_data.pdf")
def mock_get_save_filename(*args, **kwargs):
return pdf_file, "PDF Files (*.pdf)"
monkeypatch.setattr(
"PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename
)
# Should handle zero data without crashing
dialog._export_pdf()
import os
assert os.path.exists(pdf_file)
def test_time_report_dialog_very_large_hours(qtbot, fresh_db):
"""Test handling very large hour values."""
strings.load_strings("en")
proj_id = fresh_db.add_project("Project")
act_id = fresh_db.add_activity("Activity")
# Add entry with many hours
large_minutes = 10000 # ~166 hours
fresh_db.add_time_log(_today(), proj_id, act_id, large_minutes)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show()
dialog.project_combo.setCurrentIndex(0)
from PySide6.QtCore import QDate
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
# Should handle large values
dialog._run_report()
# Check total label
assert "166" in dialog.total_label.text() or "167" in dialog.total_label.text()
def test_time_log_widget_creation(qtbot, fresh_db):
"""TimeLogWidget can be created."""
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
assert widget is not None
assert not widget.toggle_btn.isChecked()
assert not widget.body.isVisible()
def test_time_log_set_current_date(qtbot, fresh_db):
"""Test setting the current date on the time log widget."""
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
today = date.today().isoformat()
widget.set_current_date(today)
# Verify the current date was set
assert widget._current_date == today
def test_time_log_with_entry(qtbot, fresh_db):
"""Test time log widget with a time entry."""
# Add a project
proj_id = fresh_db.add_project("Test Project")
# Add activity
act_id = fresh_db.add_activity("Test Activity")
# Add a time log entry
today = date.today().isoformat()
fresh_db.add_time_log(
date_iso=today,
project_id=proj_id,
activity_id=act_id,
minutes=150,
note="Test note",
)
widget = TimeLogWidget(fresh_db)
qtbot.addWidget(widget)
widget.show()
# Set the date to today
widget.set_current_date(today)
# Widget should have been created successfully
assert widget is not None