from unittest.mock import patch from PySide6.QtCore import QDate from PySide6.QtWidgets import QDialog, QMessageBox from bouquin.projects import ( ProjectsDialog, format_bucket_status, hours_from_minutes, minutes_from_hours, ) from bouquin.time_log import TimeLogDialog, TimeReportDialog def _add_project(fresh_db, project_name: str) -> int: project_id = fresh_db.add_project(project_name) fresh_db.upsert_project_billing( project_id, hourly_rate_cents=15000, currency="AUD", tax_label="GST", tax_rate_percent=10.0, client_name=f"{project_name} Contact", client_company=project_name, client_address="1 Example Street", client_email="client@example.test", ) return project_id def _add_minutes(fresh_db, project_id: int, minutes: int, note: str = "Work") -> int: activity_id = fresh_db.add_activity("Support") return fresh_db.add_time_log("2026-01-01", project_id, activity_id, minutes, note) # ============================================================================ # Unit helpers # ============================================================================ def test_project_hour_minute_helpers_round_trip(): assert hours_from_minutes(None) == 0.0 assert hours_from_minutes(90) == 1.5 assert minutes_from_hours(None) == 0 assert minutes_from_hours(1.25) == 75 assert minutes_from_hours(1.333) == 80 def test_format_bucket_status_handles_none_unconfigured_and_reached(fresh_db): assert "Select a project" in format_bucket_status(None) project_id = _add_project(fresh_db, "No Bucket Project") _add_minutes(fresh_db, project_id, 30) unconfigured = fresh_db.project_bucket_status(project_id) text = format_bucket_status(unconfigured) assert "No Bucket Project" in text assert "No bucket ceiling" in text assert "0.50h used" in text fresh_db.upsert_project_bucket(project_id, 30, 60, 50.0) reached = fresh_db.project_bucket_status(project_id) text = format_bucket_status(reached) assert "1.00h / 1.00h" in text assert "Bucket ceiling reached" in text # ============================================================================ # DB behaviour # ============================================================================ def test_project_bucket_status_includes_baseline_and_logged_time(fresh_db): project_id = _add_project(fresh_db, "Support Retainer") _add_minutes(fresh_db, project_id, 90) fresh_db.upsert_project_bucket( project_id, baseline_minutes=60, bucket_ceiling_minutes=180, warn_at_percent=80.0, ) status = fresh_db.project_bucket_status(project_id) assert status["project_id"] == project_id assert status["project_name"] == "Support Retainer" assert status["baseline_minutes"] == 60 assert status["logged_minutes"] == 90 assert status["used_minutes"] == 150 assert status["remaining_minutes"] == 30 assert status["percent_used"] == 150 / 180 * 100.0 assert status["state"] == "warning" fresh_db.add_to_project_bucket_ceiling(project_id, 60) status = fresh_db.project_bucket_status(project_id) assert status["bucket_ceiling_minutes"] == 240 assert status["state"] == "ok" def test_project_bucket_status_state_boundaries(fresh_db): project_id = _add_project(fresh_db, "Boundary Project") _add_minutes(fresh_db, project_id, 30) status = fresh_db.project_bucket_status(project_id) assert status["state"] == "unconfigured" assert status["bucket_ceiling_minutes"] == 0 assert status["remaining_minutes"] is None assert status["percent_used"] is None fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0) assert fresh_db.project_bucket_status(project_id)["state"] == "ok" fresh_db.upsert_project_bucket(project_id, 66, 120, 80.0) status = fresh_db.project_bucket_status(project_id) assert status["used_minutes"] == 96 assert status["percent_used"] == 80.0 assert status["state"] == "warning" fresh_db.upsert_project_bucket(project_id, 90, 120, 80.0) status = fresh_db.project_bucket_status(project_id) assert status["used_minutes"] == 120 assert status["remaining_minutes"] == 0 assert status["state"] == "reached" fresh_db.upsert_project_bucket(project_id, 91, 120, 80.0) status = fresh_db.project_bucket_status(project_id) assert status["used_minutes"] == 121 assert status["remaining_minutes"] == -1 assert status["state"] == "exceeded" def test_project_bucket_input_sanitisation_and_invalid_projects(fresh_db): project_id = _add_project(fresh_db, "Sanitised Project") fresh_db.upsert_project_bucket( project_id, baseline_minutes=-60, bucket_ceiling_minutes=-120, warn_at_percent=150.0, ) bucket = fresh_db.get_project_bucket(project_id) assert bucket["project_id"] == project_id assert bucket["baseline_minutes"] == 0 assert bucket["bucket_ceiling_minutes"] == 0 assert bucket["warn_at_percent"] == 100.0 fresh_db.upsert_project_bucket(project_id, 10, 60, -5.0) bucket = fresh_db.get_project_bucket(project_id) assert bucket["warn_at_percent"] == 0.0 fresh_db.add_to_project_bucket_ceiling(project_id, -60) assert fresh_db.get_project_bucket(project_id)["bucket_ceiling_minutes"] == 60 for bad_project_id in (0, -1): try: fresh_db.upsert_project_bucket(bad_project_id, 0, 60, 80.0) except ValueError as exc: assert "invalid project id" in str(exc) else: raise AssertionError("invalid project id should raise") assert fresh_db.get_project_bucket(0) is None assert fresh_db.logged_minutes_for_project(0) == 0 assert fresh_db.project_bucket_status(0) is None assert fresh_db.time_logs_for_project(0) == [] assert fresh_db.invoices_for_project_with_documents(0) == [] def test_project_summaries_include_all_projects_and_are_sorted(fresh_db): alpha = _add_project(fresh_db, "alpha project") zulu = _add_project(fresh_db, "Zulu Project") fresh_db.upsert_project_bucket(zulu, 15, 120, 80.0) rows = fresh_db.list_project_summaries() names = [r["project_name"] for r in rows] assert names == ["alpha project", "Zulu Project"] alpha_row = next(r for r in rows if r["project_id"] == alpha) assert alpha_row["document_count"] == 0 assert alpha_row["invoice_count"] == 0 assert alpha_row["time_log_count"] == 0 assert alpha_row["logged_minutes"] == 0 assert alpha_row["baseline_minutes"] == 0 zulu_row = next(r for r in rows if r["project_id"] == zulu) assert zulu_row["baseline_minutes"] == 15 assert zulu_row["bucket_ceiling_minutes"] == 120 def test_project_documents_time_logs_and_invoices_are_isolated(fresh_db, tmp_path): project_id = _add_project(fresh_db, "Project With Docs") other_project = _add_project(fresh_db, "Other Project") _add_minutes(fresh_db, project_id, 120, "Build work") _add_minutes(fresh_db, other_project, 300, "Other work") fresh_db.upsert_project_bucket(project_id, 0, 240, 80.0) doc_path = tmp_path / "invoice.pdf" doc_path.write_bytes(b"not really a pdf") doc_id = fresh_db.add_document_from_path( project_id, str(doc_path), description="Invoice document", uploaded_at="2026-02-02", ) other_doc = tmp_path / "other.pdf" other_doc.write_bytes(b"other") fresh_db.add_document_from_path(other_project, str(other_doc)) invoice_id = fresh_db.create_invoice( project_id=project_id, invoice_number="INV-1", issue_date="2026-02-03", due_date="2026-02-17", currency="AUD", tax_label=None, tax_rate_percent=None, detail_mode="summary", line_items=[("Prepaid bucket", 2.0, 10000)], time_log_ids=[], ) fresh_db.set_invoice_document(invoice_id, doc_id) fresh_db.create_invoice( project_id=other_project, invoice_number="INV-OTHER", issue_date="2026-02-03", due_date="2026-02-17", currency="AUD", tax_label=None, tax_rate_percent=None, detail_mode="summary", line_items=[("Other", 1.0, 10000)], time_log_ids=[], ) row = next( r for r in fresh_db.list_project_summaries() if r["project_id"] == project_id ) assert row["logged_minutes"] == 120 assert row["time_log_count"] == 1 assert row["document_count"] == 1 assert row["invoice_count"] == 1 logs = fresh_db.time_logs_for_project(project_id) assert len(logs) == 1 assert logs[0]["note"] == "Build work" docs = fresh_db.documents_for_project(project_id) assert len(docs) == 1 assert docs[0][3] == "invoice.pdf" invoices = fresh_db.invoices_for_project_with_documents(project_id) assert len(invoices) == 1 assert invoices[0]["invoice_number"] == "INV-1" assert invoices[0]["document_id"] == doc_id assert invoices[0]["document_file_name"] == "invoice.pdf" def test_prepaid_invoice_can_exist_without_logged_time_links(fresh_db): project_id = _add_project(fresh_db, "Prepaid Project") invoice_id = fresh_db.create_invoice( project_id=project_id, invoice_number="PREPAID-1", issue_date="2026-02-10", due_date="2026-02-24", currency="AUD", tax_label=None, tax_rate_percent=None, detail_mode="summary", line_items=[("Prepaid support bucket", 40.0, 15000)], time_log_ids=[], ) invoice = fresh_db.invoices_for_project_with_documents(project_id)[0] assert invoice["id"] == invoice_id assert invoice["total_cents"] == 600000 linked = fresh_db.conn.execute( "SELECT COUNT(*) AS c FROM invoice_time_log WHERE invoice_id = ?", (invoice_id,), ).fetchone() assert linked["c"] == 0 # ============================================================================ # UI behaviour # ============================================================================ def test_projects_dialog_loads_summary_time_logs_documents_and_invoices( qtbot, fresh_db, tmp_path ): project_id = _add_project(fresh_db, "UI Project") _add_minutes(fresh_db, project_id, 60, "Initial support") fresh_db.upsert_project_bucket(project_id, 30, 120, 75.0) doc_path = tmp_path / "ui-invoice.pdf" doc_path.write_bytes(b"ui") doc_id = fresh_db.add_document_from_path( project_id, str(doc_path), description="UI invoice", uploaded_at="2026-03-01", ) invoice_id = fresh_db.create_invoice( project_id, "UI-1", "2026-03-02", "2026-03-16", "AUD", None, None, "summary", [("UI work", 1.0, 10000)], [], ) fresh_db.set_invoice_document(invoice_id, doc_id) dialog = ProjectsDialog(fresh_db) qtbot.addWidget(dialog) assert dialog.project_combo.currentData() == project_id assert dialog.summary_table.rowCount() == 1 assert dialog.time_logs_table.rowCount() == 1 assert dialog.documents_table.rowCount() == 1 assert dialog.invoices_table.rowCount() == 1 assert dialog.summary_table.item(0, dialog.SUM_USED).text() == "1.50" assert ( dialog.summary_table.item(0, dialog.SUM_STATE).text() == "Approaching bucket ceiling" ) assert dialog.time_logs_table.item(0, dialog.LOG_NOTE).text() == "Initial support" assert dialog.documents_table.item(0, dialog.DOC_FILE).text() == "ui-invoice.pdf" assert dialog.invoices_table.item(0, dialog.INV_NUMBER).text() == "UI-1" assert "UI Project" in dialog.status_label.text() assert "1.50h / 2.00h" in dialog.status_label.text() assert ( dialog.status_label.minimumHeight() >= dialog.status_label.fontMetrics().lineSpacing() * 3 + 18 ) def test_projects_dialog_saves_and_replenishes_bucket(qtbot, fresh_db): project_id = _add_project(fresh_db, "Editable Project") dialog = ProjectsDialog(fresh_db) qtbot.addWidget(dialog) assert dialog.project_combo.currentData() == project_id dialog.baseline_spin.setValue(1.5) dialog.ceiling_spin.setValue(10.0) dialog.warn_spin.setValue(75.0) dialog._save_bucket() bucket = fresh_db.get_project_bucket(project_id) assert bucket["baseline_minutes"] == 90 assert bucket["bucket_ceiling_minutes"] == 600 assert bucket["warn_at_percent"] == 75.0 dialog.topup_spin.setValue(40.0) dialog._add_to_ceiling() bucket = fresh_db.get_project_bucket(project_id) assert bucket["bucket_ceiling_minutes"] == 3000 assert "48.50h remaining" in dialog.status_label.text() class _FakeRadioButton: def __init__(self): self.checked = False def setChecked(self, checked): self.checked = checked class _FakeLineEdit: def __init__(self): self.value = "" def setText(self, text): self.value = text class _FakeSpinBox: def __init__(self): self.value_set = None def setValue(self, value): self.value_set = value class _FakeInvoiceDialog: instances = [] def __init__( self, db, project_id, start_date_iso, end_date_iso, time_rows=None, parent=None ): self.db = db self.project_id = project_id self.start_date_iso = start_date_iso self.end_date_iso = end_date_iso self.time_rows = time_rows self.parent = parent self.rb_summary = _FakeRadioButton() self.summary_desc_edit = _FakeLineEdit() self.summary_hours_spin = _FakeSpinBox() self.recalculated = False self.executed = False _FakeInvoiceDialog.instances.append(self) def _recalc_totals(self): self.recalculated = True def exec(self): self.executed = True return QDialog.Accepted def test_projects_dialog_can_open_prepaid_invoice_for_unspent_hours(qtbot, fresh_db): project_id = _add_project(fresh_db, "Prepaid UI Project") dialog = ProjectsDialog(fresh_db) qtbot.addWidget(dialog) dialog.topup_spin.setValue(40.0) _FakeInvoiceDialog.instances.clear() with patch("bouquin.projects.InvoiceDialog", _FakeInvoiceDialog): dialog._invoice_prepaid_hours() assert len(_FakeInvoiceDialog.instances) == 1 invoice_dialog = _FakeInvoiceDialog.instances[0] assert invoice_dialog.db is fresh_db assert invoice_dialog.project_id == project_id assert invoice_dialog.time_rows == [] assert invoice_dialog.parent is dialog assert invoice_dialog.rb_summary.checked is True assert invoice_dialog.summary_hours_spin.value_set == 40.0 assert invoice_dialog.summary_desc_edit.value == ( "Prepaid support bucket (40.00 hours)" ) assert invoice_dialog.recalculated is True assert invoice_dialog.executed is True def test_projects_dialog_warns_when_prepaid_invoice_hours_are_zero(qtbot, fresh_db): _add_project(fresh_db, "Zero Project") dialog = ProjectsDialog(fresh_db) qtbot.addWidget(dialog) dialog.topup_spin.setValue(0.0) with patch.object(QMessageBox, "warning") as warning: dialog._invoice_prepaid_hours() assert warning.called assert "greater than zero" in warning.call_args.args[2] def test_projects_dialog_opens_selected_document_and_invoice_document( qtbot, fresh_db, tmp_path ): project_id = _add_project(fresh_db, "Open Project") doc_path = tmp_path / "open.pdf" doc_path.write_bytes(b"open") doc_id = fresh_db.add_document_from_path(project_id, str(doc_path)) invoice_id = fresh_db.create_invoice( project_id, "OPEN-1", "2026-05-01", None, "AUD", None, None, "summary", [("Open work", 1.0, 10000)], [], ) fresh_db.set_invoice_document(invoice_id, doc_id) dialog = ProjectsDialog(fresh_db) qtbot.addWidget(dialog) dialog.documents_table.selectRow(0) with patch("bouquin.projects.open_document_from_db") as open_doc: dialog._open_selected_document() open_doc.assert_called_once_with( fresh_db, doc_id, "open.pdf", parent_widget=dialog ) dialog.invoices_table.selectRow(0) with patch("bouquin.projects.open_document_from_db") as open_doc: dialog._open_invoice_document() open_doc.assert_called_once_with( fresh_db, doc_id, "open.pdf", parent_widget=dialog ) def test_projects_dialog_reports_missing_invoice_document(qtbot, fresh_db): project_id = _add_project(fresh_db, "No Doc Project") fresh_db.create_invoice( project_id, "NO-DOC-1", "2026-05-01", None, "AUD", None, None, "summary", [("Work", 1.0, 10000)], [], ) dialog = ProjectsDialog(fresh_db) qtbot.addWidget(dialog) dialog.invoices_table.selectRow(0) with patch.object(QMessageBox, "information") as info: dialog._open_invoice_document() assert info.called def test_time_log_dialog_updates_bucket_indicator_and_alerts_on_reach(qtbot, fresh_db): project_id = _add_project(fresh_db, "Logging Project") fresh_db.upsert_project_bucket(project_id, 0, 60, 80.0) dialog = TimeLogDialog(fresh_db, "2026-06-01") qtbot.addWidget(dialog) idx = dialog.project_combo.findData(project_id) dialog.project_combo.setCurrentIndex(idx) assert "0.00h / 1.00h" in dialog.bucket_label.text() assert "Status: OK" in dialog.bucket_label.text() dialog.activity_edit.setText("Support") dialog.hours_spin.setValue(1.0) with patch.object(QMessageBox, "warning") as warning: dialog._on_add_or_update() assert warning.called assert "Bucket ceiling reached" in warning.call_args.args[2] status = fresh_db.project_bucket_status(project_id) assert status["state"] == "reached" assert "Bucket ceiling reached" in dialog.bucket_label.text() def test_time_log_dialog_does_not_alert_before_reaching_bucket(qtbot, fresh_db): project_id = _add_project(fresh_db, "Safe Logging") fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0) dialog = TimeLogDialog(fresh_db, "2026-06-02") qtbot.addWidget(dialog) dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id)) dialog.activity_edit.setText("Support") dialog.hours_spin.setValue(0.5) with patch.object(QMessageBox, "warning") as warning: dialog._on_add_or_update() warning.assert_not_called() assert fresh_db.project_bucket_status(project_id)["state"] == "ok" def test_time_report_dialog_shows_bucket_for_selected_project_and_clears_for_all( qtbot, fresh_db ): project_id = _add_project(fresh_db, "Report Project") _add_minutes(fresh_db, project_id, 90) fresh_db.upsert_project_bucket(project_id, 0, 120, 75.0) dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) dialog.from_date.setDate(QDate.fromString("2026-01-01", "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString("2026-01-31", "yyyy-MM-dd")) dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id)) dialog._run_report() assert "Report Project" in dialog.bucket_label.text() assert "Approaching bucket ceiling" in dialog.bucket_label.text() dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(None)) dialog._run_report() assert dialog.bucket_label.text() == ""