1346 lines
42 KiB
Python
1346 lines
42 KiB
Python
from datetime import date, timedelta
|
|
|
|
import pytest
|
|
from bouquin.invoices import (
|
|
_INVOICE_REMINDER_TIME,
|
|
InvoiceDetailMode,
|
|
InvoiceDialog,
|
|
InvoiceLineItem,
|
|
InvoicesDialog,
|
|
_invoice_due_reminder_text,
|
|
)
|
|
from bouquin.reminders import Reminder, ReminderType
|
|
from PySide6.QtCore import QDate, Qt
|
|
from PySide6.QtWidgets import QMessageBox
|
|
|
|
# ============================================================================
|
|
# Tests for InvoiceDetailMode enum
|
|
# ============================================================================
|
|
|
|
|
|
def test_invoice_detail_mode_enum_values(app):
|
|
"""Test InvoiceDetailMode enum has expected values."""
|
|
assert InvoiceDetailMode.DETAILED == "detailed"
|
|
assert InvoiceDetailMode.SUMMARY == "summary"
|
|
|
|
|
|
def test_invoice_detail_mode_is_string(app):
|
|
"""Test InvoiceDetailMode enum inherits from str."""
|
|
assert isinstance(InvoiceDetailMode.DETAILED, str)
|
|
assert isinstance(InvoiceDetailMode.SUMMARY, str)
|
|
|
|
|
|
# ============================================================================
|
|
# Tests for InvoiceLineItem dataclass
|
|
# ============================================================================
|
|
|
|
|
|
def test_invoice_line_item_creation(app):
|
|
"""Test creating an InvoiceLineItem instance."""
|
|
item = InvoiceLineItem(
|
|
description="Development work",
|
|
hours=5.5,
|
|
rate_cents=10000,
|
|
amount_cents=55000,
|
|
)
|
|
|
|
assert item.description == "Development work"
|
|
assert item.hours == 5.5
|
|
assert item.rate_cents == 10000
|
|
assert item.amount_cents == 55000
|
|
|
|
|
|
def test_invoice_line_item_with_zero_values(app):
|
|
"""Test InvoiceLineItem with zero values."""
|
|
item = InvoiceLineItem(
|
|
description="",
|
|
hours=0.0,
|
|
rate_cents=0,
|
|
amount_cents=0,
|
|
)
|
|
|
|
assert item.description == ""
|
|
assert item.hours == 0.0
|
|
assert item.rate_cents == 0
|
|
assert item.amount_cents == 0
|
|
|
|
|
|
# ============================================================================
|
|
# Tests for _invoice_due_reminder_text helper function
|
|
# ============================================================================
|
|
|
|
|
|
def test_invoice_due_reminder_text_normal(app):
|
|
"""Test reminder text generation with normal inputs."""
|
|
result = _invoice_due_reminder_text("Project Alpha", "INV-001")
|
|
assert result == "Invoice INV-001 for Project Alpha is due"
|
|
|
|
|
|
def test_invoice_due_reminder_text_with_whitespace(app):
|
|
"""Test reminder text strips whitespace from inputs."""
|
|
result = _invoice_due_reminder_text(" Project Beta ", " INV-002 ")
|
|
assert result == "Invoice INV-002 for Project Beta is due"
|
|
|
|
|
|
def test_invoice_due_reminder_text_empty_project(app):
|
|
"""Test reminder text with empty project name."""
|
|
result = _invoice_due_reminder_text("", "INV-003")
|
|
assert result == "Invoice INV-003 for (no project) is due"
|
|
|
|
|
|
def test_invoice_due_reminder_text_empty_invoice_number(app):
|
|
"""Test reminder text with empty invoice number."""
|
|
result = _invoice_due_reminder_text("Project Gamma", "")
|
|
assert result == "Invoice ? for Project Gamma is due"
|
|
|
|
|
|
def test_invoice_due_reminder_text_both_empty(app):
|
|
"""Test reminder text with both inputs empty."""
|
|
result = _invoice_due_reminder_text("", "")
|
|
assert result == "Invoice ? for (no project) is due"
|
|
|
|
|
|
# ============================================================================
|
|
# Tests for InvoiceDialog
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def invoice_dialog_setup(qtbot, fresh_db):
|
|
"""Set up a project with time logs for InvoiceDialog testing."""
|
|
# Create a project
|
|
proj_id = fresh_db.add_project("Test Project")
|
|
|
|
# Create an activity
|
|
act_id = fresh_db.add_activity("Development")
|
|
|
|
# Set billing info
|
|
fresh_db.upsert_project_billing(
|
|
proj_id,
|
|
hourly_rate_cents=15000, # $150/hr
|
|
currency="USD",
|
|
tax_label="VAT",
|
|
tax_rate_percent=20.0,
|
|
client_name="John Doe",
|
|
client_company="Acme Corp",
|
|
client_address="123 Main St",
|
|
client_email="john@acme.com",
|
|
)
|
|
|
|
# Create some time logs
|
|
today = date.today()
|
|
start_date = (today - timedelta(days=7)).isoformat()
|
|
end_date = today.isoformat()
|
|
|
|
# Add time logs for testing (2.5 hours = 150 minutes)
|
|
for i in range(3):
|
|
log_date = (today - timedelta(days=i)).isoformat()
|
|
fresh_db.add_time_log(
|
|
log_date,
|
|
proj_id,
|
|
act_id,
|
|
150, # 2.5 hours in minutes
|
|
f"Note {i}",
|
|
)
|
|
|
|
time_rows = fresh_db.time_logs_for_range(proj_id, start_date, end_date)
|
|
|
|
return {
|
|
"db": fresh_db,
|
|
"proj_id": proj_id,
|
|
"act_id": act_id,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"time_rows": time_rows,
|
|
}
|
|
|
|
|
|
def test_invoice_dialog_init(qtbot, invoice_dialog_setup):
|
|
"""Test InvoiceDialog initialization."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
assert dialog._db is setup["db"]
|
|
assert dialog._project_id == setup["proj_id"]
|
|
assert dialog._start == setup["start_date"]
|
|
assert dialog._end == setup["end_date"]
|
|
assert len(dialog._time_rows) == 3
|
|
|
|
|
|
def test_invoice_dialog_init_without_time_rows(qtbot, invoice_dialog_setup):
|
|
"""Test InvoiceDialog initialization without explicit time_rows."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Should fetch time rows from DB
|
|
assert len(dialog._time_rows) == 3
|
|
|
|
|
|
def test_invoice_dialog_loads_billing_defaults(qtbot, invoice_dialog_setup):
|
|
"""Test that InvoiceDialog loads billing defaults from project."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
assert dialog.currency_edit.text() == "USD"
|
|
assert dialog.rate_spin.value() == 150.0
|
|
assert dialog.client_name_edit.text() == "John Doe"
|
|
assert dialog.client_company_combo.currentText() == "Acme Corp"
|
|
|
|
|
|
def test_invoice_dialog_no_billing_defaults(qtbot, fresh_db):
|
|
"""Test InvoiceDialog with project that has no billing info."""
|
|
proj_id = fresh_db.add_project("Test Project No Billing")
|
|
today = date.today()
|
|
start = (today - timedelta(days=1)).isoformat()
|
|
end = today.isoformat()
|
|
|
|
dialog = InvoiceDialog(fresh_db, proj_id, start, end)
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Should use defaults
|
|
assert dialog.currency_edit.text() == "AUD"
|
|
assert dialog.rate_spin.value() == 0.0
|
|
assert dialog.client_name_edit.text() == ""
|
|
|
|
|
|
def test_invoice_dialog_project_name(qtbot, invoice_dialog_setup):
|
|
"""Test _project_name method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
project_name = dialog._project_name()
|
|
assert project_name == "Test Project"
|
|
|
|
|
|
def test_invoice_dialog_suggest_invoice_number(qtbot, invoice_dialog_setup):
|
|
"""Test _suggest_invoice_number method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
invoice_number = dialog._suggest_invoice_number()
|
|
# Should be in format YYYY-001 for first invoice (3 digits)
|
|
current_year = date.today().year
|
|
assert invoice_number.startswith(str(current_year))
|
|
assert invoice_number.endswith("-001")
|
|
|
|
|
|
def test_invoice_dialog_suggest_invoice_number_increments(qtbot, invoice_dialog_setup):
|
|
"""Test that invoice number suggestions increment."""
|
|
setup = invoice_dialog_setup
|
|
|
|
# Create an invoice first
|
|
dialog1 = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog1)
|
|
|
|
# Save an invoice to increment the counter
|
|
invoice_number_1 = dialog1._suggest_invoice_number()
|
|
setup["db"].create_invoice(
|
|
project_id=setup["proj_id"],
|
|
invoice_number=invoice_number_1,
|
|
issue_date=date.today().isoformat(),
|
|
due_date=(date.today() + timedelta(days=14)).isoformat(),
|
|
currency="USD",
|
|
tax_label=None,
|
|
tax_rate_percent=None,
|
|
detail_mode=InvoiceDetailMode.DETAILED,
|
|
line_items=[],
|
|
time_log_ids=[],
|
|
)
|
|
|
|
# Create another dialog and check the number increments
|
|
dialog2 = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog2)
|
|
|
|
invoice_number_2 = dialog2._suggest_invoice_number()
|
|
current_year = date.today().year
|
|
assert invoice_number_2 == f"{current_year}-002"
|
|
|
|
|
|
def test_invoice_dialog_populate_detailed_rows(qtbot, invoice_dialog_setup):
|
|
"""Test _populate_detailed_rows method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog._populate_detailed_rows(15000) # $150/hr in cents
|
|
|
|
# Check that table has rows
|
|
assert dialog.table.rowCount() == 3
|
|
|
|
# Check that hours are displayed (COL_HOURS uses cellWidget, not item)
|
|
for row in range(3):
|
|
hours_widget = dialog.table.cellWidget(row, dialog.COL_HOURS)
|
|
assert hours_widget is not None
|
|
assert hours_widget.value() == 2.5
|
|
|
|
|
|
def test_invoice_dialog_total_hours_from_table(qtbot, invoice_dialog_setup):
|
|
"""Test _total_hours_from_table method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog._populate_detailed_rows(15000)
|
|
|
|
total_hours = dialog._total_hours_from_table()
|
|
# 3 rows * 2.5 hours = 7.5 hours
|
|
assert total_hours == 7.5
|
|
|
|
|
|
def test_invoice_dialog_detail_line_items(qtbot, invoice_dialog_setup):
|
|
"""Test _detail_line_items method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog.rate_spin.setValue(150.0)
|
|
dialog._populate_detailed_rows(15000)
|
|
|
|
line_items = dialog._detail_line_items()
|
|
assert len(line_items) == 3
|
|
|
|
for item in line_items:
|
|
assert isinstance(item, InvoiceLineItem)
|
|
assert item.hours == 2.5
|
|
assert item.rate_cents == 15000
|
|
assert item.amount_cents == 37500 # 2.5 * 15000
|
|
|
|
|
|
def test_invoice_dialog_summary_line_items(qtbot, invoice_dialog_setup):
|
|
"""Test _summary_line_items method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog.rate_spin.setValue(150.0)
|
|
dialog._populate_detailed_rows(15000)
|
|
|
|
line_items = dialog._summary_line_items()
|
|
assert len(line_items) == 1 # Summary should have one line
|
|
|
|
item = line_items[0]
|
|
assert isinstance(item, InvoiceLineItem)
|
|
# The description comes from summary_desc_edit which has a localized default
|
|
# Just check it's not empty
|
|
assert len(item.description) > 0
|
|
assert item.hours == 7.5 # Total of 3 * 2.5
|
|
assert item.rate_cents == 15000
|
|
assert item.amount_cents == 112500 # 7.5 * 15000
|
|
|
|
|
|
def test_invoice_dialog_recalc_amounts(qtbot, invoice_dialog_setup):
|
|
"""Test _recalc_amounts method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog._populate_detailed_rows(15000)
|
|
dialog.rate_spin.setValue(200.0) # Change rate to $200/hr
|
|
|
|
dialog._recalc_amounts()
|
|
|
|
# Check that amounts were recalculated
|
|
for row in range(3):
|
|
amount_item = dialog.table.item(row, dialog.COL_AMOUNT)
|
|
assert amount_item is not None
|
|
# 2.5 hours * $200 = $500
|
|
assert amount_item.text() == "500.00"
|
|
|
|
|
|
def test_invoice_dialog_recalc_totals(qtbot, invoice_dialog_setup):
|
|
"""Test _recalc_totals method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog.rate_spin.setValue(100.0)
|
|
dialog._populate_detailed_rows(10000)
|
|
|
|
# Enable tax
|
|
dialog.tax_checkbox.setChecked(True)
|
|
dialog.tax_rate_spin.setValue(10.0)
|
|
|
|
dialog._recalc_totals()
|
|
|
|
# 7.5 hours * $100 = $750
|
|
# Tax: $750 * 10% = $75
|
|
# Total: $750 + $75 = $825
|
|
assert "750.00" in dialog.subtotal_label.text()
|
|
assert "75.00" in dialog.tax_label_total.text()
|
|
assert "825.00" in dialog.total_label.text()
|
|
|
|
|
|
def test_invoice_dialog_on_tax_toggled(qtbot, invoice_dialog_setup):
|
|
"""Test _on_tax_toggled method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
dialog.show()
|
|
|
|
# Initially unchecked (from fixture setup with tax)
|
|
dialog.tax_checkbox.setChecked(False)
|
|
dialog._on_tax_toggled(False)
|
|
|
|
# Tax fields should be hidden
|
|
assert not dialog.tax_label.isVisible()
|
|
assert not dialog.tax_label_edit.isVisible()
|
|
assert not dialog.tax_rate_label.isVisible()
|
|
assert not dialog.tax_rate_spin.isVisible()
|
|
|
|
# Check the box
|
|
dialog.tax_checkbox.setChecked(True)
|
|
dialog._on_tax_toggled(True)
|
|
|
|
# Tax fields should be visible
|
|
assert dialog.tax_label.isVisible()
|
|
assert dialog.tax_label_edit.isVisible()
|
|
assert dialog.tax_rate_label.isVisible()
|
|
assert dialog.tax_rate_spin.isVisible()
|
|
|
|
|
|
def test_invoice_dialog_on_client_company_changed(qtbot, invoice_dialog_setup):
|
|
"""Test _on_client_company_changed method for autofill."""
|
|
setup = invoice_dialog_setup
|
|
|
|
# Create another project with different client
|
|
proj_id_2 = setup["db"].add_project("Project 2")
|
|
setup["db"].upsert_project_billing(
|
|
proj_id_2,
|
|
hourly_rate_cents=20000,
|
|
currency="EUR",
|
|
tax_label="GST",
|
|
tax_rate_percent=15.0,
|
|
client_name="Jane Smith",
|
|
client_company="Tech Industries",
|
|
client_address="456 Oak Ave",
|
|
client_email="jane@tech.com",
|
|
)
|
|
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Initially should have first project's client
|
|
assert dialog.client_name_edit.text() == "John Doe"
|
|
|
|
# Change to second company
|
|
dialog.client_company_combo.setCurrentText("Tech Industries")
|
|
dialog._on_client_company_changed("Tech Industries")
|
|
|
|
# Should autofill with second client's info
|
|
assert dialog.client_name_edit.text() == "Jane Smith"
|
|
assert dialog.client_addr_edit.toPlainText() == "456 Oak Ave"
|
|
assert dialog.client_email_edit.text() == "jane@tech.com"
|
|
|
|
|
|
def test_invoice_dialog_create_due_date_reminder(qtbot, invoice_dialog_setup):
|
|
"""Test _create_due_date_reminder method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
due_date = (date.today() + timedelta(days=14)).isoformat()
|
|
invoice_number = "INV-TEST-001"
|
|
invoice_id = 999 # Fake invoice ID for testing
|
|
|
|
dialog._create_due_date_reminder(invoice_id, invoice_number, due_date)
|
|
|
|
# Check that reminder was created
|
|
reminders = setup["db"].get_all_reminders()
|
|
assert len(reminders) > 0
|
|
|
|
# Find our reminder
|
|
expected_text = _invoice_due_reminder_text("Test Project", invoice_number)
|
|
matching_reminders = [r for r in reminders if r.text == expected_text]
|
|
assert len(matching_reminders) == 1
|
|
|
|
reminder = matching_reminders[0]
|
|
assert reminder.reminder_type == ReminderType.ONCE
|
|
assert reminder.date_iso == due_date
|
|
assert reminder.time_str == _INVOICE_REMINDER_TIME
|
|
|
|
|
|
# ============================================================================
|
|
# Tests for InvoicesDialog
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def invoices_dialog_setup(qtbot, fresh_db):
|
|
"""Set up projects with invoices for InvoicesDialog testing."""
|
|
# Create projects
|
|
proj_id_1 = fresh_db.add_project("Project Alpha")
|
|
proj_id_2 = fresh_db.add_project("Project Beta")
|
|
|
|
# Create invoices for project 1
|
|
today = date.today()
|
|
for i in range(3):
|
|
issue_date = (today - timedelta(days=i * 7)).isoformat()
|
|
due_date = (today - timedelta(days=i * 7) + timedelta(days=14)).isoformat()
|
|
paid_at = today.isoformat() if i == 0 else None # First one is paid
|
|
|
|
fresh_db.create_invoice(
|
|
project_id=proj_id_1,
|
|
invoice_number=f"ALPHA-{i+1}",
|
|
issue_date=issue_date,
|
|
due_date=due_date,
|
|
currency="USD",
|
|
tax_label="VAT",
|
|
tax_rate_percent=20.0,
|
|
detail_mode=InvoiceDetailMode.DETAILED,
|
|
line_items=[("Development work", 10.0, 15000)], # 10 hours at $150/hr
|
|
time_log_ids=[],
|
|
)
|
|
|
|
# Update paid_at separately if needed
|
|
if paid_at:
|
|
invoice_rows = fresh_db.get_all_invoices(proj_id_1)
|
|
if invoice_rows:
|
|
inv_id = invoice_rows[0]["id"]
|
|
fresh_db.set_invoice_field_by_id(inv_id, "paid_at", paid_at)
|
|
|
|
# Create invoices for project 2
|
|
for i in range(2):
|
|
issue_date = (today - timedelta(days=i * 10)).isoformat()
|
|
due_date = (today - timedelta(days=i * 10) + timedelta(days=30)).isoformat()
|
|
|
|
fresh_db.create_invoice(
|
|
project_id=proj_id_2,
|
|
invoice_number=f"BETA-{i+1}",
|
|
issue_date=issue_date,
|
|
due_date=due_date,
|
|
currency="EUR",
|
|
tax_label=None,
|
|
tax_rate_percent=None,
|
|
detail_mode=InvoiceDetailMode.SUMMARY,
|
|
line_items=[("Consulting services", 10.0, 20000)], # 10 hours at $200/hr
|
|
time_log_ids=[],
|
|
)
|
|
|
|
return {
|
|
"db": fresh_db,
|
|
"proj_id_1": proj_id_1,
|
|
"proj_id_2": proj_id_2,
|
|
}
|
|
|
|
|
|
def test_invoices_dialog_init(qtbot, invoices_dialog_setup):
|
|
"""Test InvoicesDialog initialization."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
assert dialog._db is setup["db"]
|
|
assert dialog.project_combo.count() >= 2 # 2 projects
|
|
|
|
|
|
def test_invoices_dialog_init_with_project_id(qtbot, invoices_dialog_setup):
|
|
"""Test InvoicesDialog initialization with specific project."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Should select the specified project
|
|
current_proj = dialog._current_project()
|
|
assert current_proj == setup["proj_id_1"]
|
|
|
|
|
|
def test_invoices_dialog_reload_projects(qtbot, invoices_dialog_setup):
|
|
"""Test _reload_projects method."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
initial_count = dialog.project_combo.count()
|
|
assert initial_count >= 2 # Should have 2 projects from setup
|
|
|
|
# Create a new project
|
|
setup["db"].add_project("Project Gamma")
|
|
|
|
# Reload projects
|
|
dialog._reload_projects()
|
|
|
|
# Should have one more project
|
|
assert dialog.project_combo.count() == initial_count + 1
|
|
|
|
|
|
def test_invoices_dialog_current_project_specific(qtbot, invoices_dialog_setup):
|
|
"""Test _current_project method when specific project is selected."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
current_proj = dialog._current_project()
|
|
assert current_proj == setup["proj_id_1"]
|
|
|
|
|
|
def test_invoices_dialog_reload_invoices_all_projects(qtbot, invoices_dialog_setup):
|
|
"""Test _reload_invoices with first project selected by default."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# First project should be selected by default (Project Alpha with 3 invoices)
|
|
# The exact project depends on creation order, so just check we have some invoices
|
|
assert dialog.table.rowCount() in [2, 3] # Either proj1 (3) or proj2 (2)
|
|
|
|
|
|
def test_invoices_dialog_reload_invoices_single_project(qtbot, invoices_dialog_setup):
|
|
"""Test _reload_invoices with single project selected."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog._reload_invoices()
|
|
|
|
# Should show only 3 invoices from proj1
|
|
assert dialog.table.rowCount() == 3
|
|
|
|
|
|
def test_invoices_dialog_on_project_changed(qtbot, invoices_dialog_setup):
|
|
"""Test _on_project_changed method."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_2"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Start with project 2 (2 invoices)
|
|
assert dialog.table.rowCount() == 2
|
|
|
|
# Find the index of project 1
|
|
for i in range(dialog.project_combo.count()):
|
|
if dialog.project_combo.itemData(i) == setup["proj_id_1"]:
|
|
dialog.project_combo.setCurrentIndex(i)
|
|
break
|
|
|
|
dialog._on_project_changed(dialog.project_combo.currentIndex())
|
|
|
|
# Should now show 3 invoices from proj1
|
|
assert dialog.table.rowCount() == 3
|
|
|
|
|
|
def test_invoices_dialog_remove_invoice_due_reminder(qtbot, invoices_dialog_setup):
|
|
"""Test _remove_invoice_due_reminder method."""
|
|
setup = invoices_dialog_setup
|
|
|
|
# Create a reminder for an invoice
|
|
due_date = (date.today() + timedelta(days=7)).isoformat()
|
|
invoice_number = "TEST-REMINDER-001"
|
|
project_name = "Project Alpha"
|
|
|
|
reminder_text = _invoice_due_reminder_text(project_name, invoice_number)
|
|
reminder = Reminder(
|
|
id=None,
|
|
text=reminder_text,
|
|
time_str=_INVOICE_REMINDER_TIME,
|
|
reminder_type=ReminderType.ONCE,
|
|
date_iso=due_date,
|
|
active=True,
|
|
)
|
|
reminder.id = setup["db"].save_reminder(reminder)
|
|
|
|
# Verify reminder exists
|
|
reminders = setup["db"].get_all_reminders()
|
|
assert len(reminders) == 1
|
|
|
|
# Create dialog and populate with invoices
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Manually add a row to test the removal (simulating the invoice row)
|
|
row = dialog.table.rowCount()
|
|
dialog.table.insertRow(row)
|
|
|
|
# Set the project and invoice number items
|
|
from PySide6.QtWidgets import QTableWidgetItem
|
|
|
|
proj_item = QTableWidgetItem(project_name)
|
|
num_item = QTableWidgetItem(invoice_number)
|
|
dialog.table.setItem(row, dialog.COL_PROJECT, proj_item)
|
|
dialog.table.setItem(row, dialog.COL_NUMBER, num_item)
|
|
|
|
# Mock invoice_id
|
|
num_item.setData(Qt.ItemDataRole.UserRole, 999)
|
|
|
|
# Call the removal method
|
|
dialog._remove_invoice_due_reminder(row, 999)
|
|
|
|
# Reminder should be deleted
|
|
reminders_after = setup["db"].get_all_reminders()
|
|
assert len(reminders_after) == 0
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_invoice_number(qtbot, invoices_dialog_setup):
|
|
"""Test _on_item_changed for invoice number editing."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Get the first row's invoice ID
|
|
num_item = dialog.table.item(0, dialog.COL_NUMBER)
|
|
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
|
|
|
|
# Change the invoice number
|
|
num_item.setText("ALPHA-MODIFIED")
|
|
|
|
# Trigger the change handler
|
|
dialog._on_item_changed(num_item)
|
|
|
|
# Verify the change was saved to DB
|
|
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number")
|
|
assert invoice_data["invoice_number"] == "ALPHA-MODIFIED"
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_empty_invoice_number(
|
|
qtbot, invoices_dialog_setup, monkeypatch
|
|
):
|
|
"""Test _on_item_changed rejects empty invoice number."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Mock QMessageBox to auto-close
|
|
def mock_warning(*args, **kwargs):
|
|
return QMessageBox.Ok
|
|
|
|
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
|
|
|
# Get the first row's invoice number item
|
|
num_item = dialog.table.item(0, dialog.COL_NUMBER)
|
|
original_number = num_item.text()
|
|
|
|
# Try to set empty invoice number
|
|
num_item.setText("")
|
|
dialog._on_item_changed(num_item)
|
|
|
|
# Should be reset to original
|
|
assert num_item.text() == original_number
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_issue_date(qtbot, invoices_dialog_setup):
|
|
"""Test _on_item_changed for issue date editing."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Get the first row
|
|
num_item = dialog.table.item(0, dialog.COL_NUMBER)
|
|
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
|
|
|
|
issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
|
|
new_date = "2024-01-15"
|
|
issue_item.setText(new_date)
|
|
|
|
dialog._on_item_changed(issue_item)
|
|
|
|
# Verify change was saved
|
|
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "issue_date")
|
|
assert invoice_data["issue_date"] == new_date
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_invalid_date(
|
|
qtbot, invoices_dialog_setup, monkeypatch
|
|
):
|
|
"""Test _on_item_changed rejects invalid date format."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Mock QMessageBox
|
|
def mock_warning(*args, **kwargs):
|
|
return QMessageBox.Ok
|
|
|
|
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
|
|
|
issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
|
|
original_date = issue_item.text()
|
|
|
|
# Try to set invalid date
|
|
issue_item.setText("not-a-date")
|
|
dialog._on_item_changed(issue_item)
|
|
|
|
# Should be reset to original
|
|
assert issue_item.text() == original_date
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_due_before_issue(
|
|
qtbot, invoices_dialog_setup, monkeypatch
|
|
):
|
|
"""Test _on_item_changed rejects due date before issue date."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Mock QMessageBox
|
|
def mock_warning(*args, **kwargs):
|
|
return QMessageBox.Ok
|
|
|
|
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
|
|
|
# Set issue date
|
|
issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
|
|
issue_item.setText("2024-02-01")
|
|
dialog._on_item_changed(issue_item)
|
|
|
|
# Try to set due date before issue date
|
|
due_item = dialog.table.item(0, dialog.COL_DUE_DATE)
|
|
original_due = due_item.text()
|
|
due_item.setText("2024-01-01")
|
|
dialog._on_item_changed(due_item)
|
|
|
|
# Should be reset
|
|
assert due_item.text() == original_due
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_currency(qtbot, invoices_dialog_setup):
|
|
"""Test _on_item_changed for currency editing."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Get the first row
|
|
num_item = dialog.table.item(0, dialog.COL_NUMBER)
|
|
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
|
|
|
|
currency_item = dialog.table.item(0, dialog.COL_CURRENCY)
|
|
currency_item.setText("gbp") # lowercase
|
|
|
|
dialog._on_item_changed(currency_item)
|
|
|
|
# Should be normalized to uppercase
|
|
assert currency_item.text() == "GBP"
|
|
|
|
# Verify change was saved
|
|
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "currency")
|
|
assert invoice_data["currency"] == "GBP"
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_tax_rate(qtbot, invoices_dialog_setup):
|
|
"""Test _on_item_changed for tax rate editing."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Get the first row
|
|
num_item = dialog.table.item(0, dialog.COL_NUMBER)
|
|
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
|
|
|
|
tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE)
|
|
tax_rate_item.setText("15.5")
|
|
|
|
dialog._on_item_changed(tax_rate_item)
|
|
|
|
# Verify change was saved
|
|
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "tax_rate_percent")
|
|
assert invoice_data["tax_rate_percent"] == 15.5
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_invalid_tax_rate(
|
|
qtbot, invoices_dialog_setup, monkeypatch
|
|
):
|
|
"""Test _on_item_changed rejects invalid tax rate."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Mock QMessageBox
|
|
def mock_warning(*args, **kwargs):
|
|
return QMessageBox.Ok
|
|
|
|
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
|
|
|
tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE)
|
|
original_rate = tax_rate_item.text()
|
|
|
|
# Try to set invalid tax rate
|
|
tax_rate_item.setText("not-a-number")
|
|
dialog._on_item_changed(tax_rate_item)
|
|
|
|
# Should be reset to original
|
|
assert tax_rate_item.text() == original_rate
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_subtotal(qtbot, invoices_dialog_setup):
|
|
"""Test _on_item_changed for subtotal editing."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Get the first row
|
|
num_item = dialog.table.item(0, dialog.COL_NUMBER)
|
|
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
|
|
|
|
subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL)
|
|
subtotal_item.setText("1234.56")
|
|
|
|
dialog._on_item_changed(subtotal_item)
|
|
|
|
# Verify change was saved (in cents)
|
|
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "subtotal_cents")
|
|
assert invoice_data["subtotal_cents"] == 123456
|
|
|
|
# Should be normalized to 2 decimals
|
|
assert subtotal_item.text() == "1234.56"
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_invalid_amount(
|
|
qtbot, invoices_dialog_setup, monkeypatch
|
|
):
|
|
"""Test _on_item_changed rejects invalid amount."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Mock QMessageBox
|
|
def mock_warning(*args, **kwargs):
|
|
return QMessageBox.Ok
|
|
|
|
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
|
|
|
|
subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL)
|
|
original_subtotal = subtotal_item.text()
|
|
|
|
# Try to set invalid amount
|
|
subtotal_item.setText("not-a-number")
|
|
dialog._on_item_changed(subtotal_item)
|
|
|
|
# Should be reset to original
|
|
assert subtotal_item.text() == original_subtotal
|
|
|
|
|
|
def test_invoices_dialog_on_item_changed_paid_at_removes_reminder(
|
|
qtbot, invoices_dialog_setup
|
|
):
|
|
"""Test that marking invoice as paid removes due date reminder."""
|
|
setup = invoices_dialog_setup
|
|
|
|
# Create a reminder for an invoice
|
|
due_date = (date.today() + timedelta(days=7)).isoformat()
|
|
invoice_number = "ALPHA-1"
|
|
project_name = "Project Alpha"
|
|
|
|
reminder_text = _invoice_due_reminder_text(project_name, invoice_number)
|
|
reminder = Reminder(
|
|
id=None,
|
|
text=reminder_text,
|
|
time_str=_INVOICE_REMINDER_TIME,
|
|
reminder_type=ReminderType.ONCE,
|
|
date_iso=due_date,
|
|
active=True,
|
|
)
|
|
reminder.id = setup["db"].save_reminder(reminder)
|
|
|
|
# Verify reminder exists
|
|
reminders = setup["db"].get_all_reminders()
|
|
assert any(r.text == reminder_text for r in reminders)
|
|
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Find the ALPHA-1 invoice row
|
|
for row in range(dialog.table.rowCount()):
|
|
num_item = dialog.table.item(row, dialog.COL_NUMBER)
|
|
if num_item and num_item.text() == "ALPHA-1":
|
|
# Mark as paid
|
|
paid_item = dialog.table.item(row, dialog.COL_PAID_AT)
|
|
paid_item.setText(date.today().isoformat())
|
|
dialog._on_item_changed(paid_item)
|
|
break
|
|
|
|
# Reminder should be removed
|
|
reminders_after = setup["db"].get_all_reminders()
|
|
assert not any(r.text == reminder_text for r in reminders_after)
|
|
|
|
|
|
def test_invoices_dialog_ignores_changes_while_reloading(qtbot, invoices_dialog_setup):
|
|
"""Test that _on_item_changed is ignored during reload."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Set reloading flag
|
|
dialog._reloading_invoices = True
|
|
|
|
# Try to change an item
|
|
num_item = dialog.table.item(0, dialog.COL_NUMBER)
|
|
original_number = num_item.text()
|
|
inv_id = num_item.data(Qt.ItemDataRole.UserRole)
|
|
|
|
num_item.setText("SHOULD-BE-IGNORED")
|
|
dialog._on_item_changed(num_item)
|
|
|
|
# Change should not be saved to DB
|
|
invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number")
|
|
assert invoice_data["invoice_number"] == original_number
|
|
|
|
|
|
def test_invoice_dialog_update_mode_enabled(qtbot, invoice_dialog_setup):
|
|
"""Test _update_mode_enabled method."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
dialog.show()
|
|
|
|
# Initially detailed mode should be selected
|
|
assert dialog.rb_detailed.isChecked()
|
|
|
|
# Table should be enabled in detailed mode
|
|
assert dialog.table.isEnabled()
|
|
|
|
# Switch to summary mode
|
|
dialog.rb_summary.setChecked(True)
|
|
dialog._update_mode_enabled()
|
|
|
|
# Table should be disabled in summary mode
|
|
assert not dialog.table.isEnabled()
|
|
|
|
|
|
def test_invoice_dialog_with_no_time_logs(qtbot, fresh_db):
|
|
"""Test InvoiceDialog with project that has no time logs."""
|
|
proj_id = fresh_db.add_project("Empty Project")
|
|
today = date.today()
|
|
start = (today - timedelta(days=7)).isoformat()
|
|
end = today.isoformat()
|
|
|
|
dialog = InvoiceDialog(fresh_db, proj_id, start, end)
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Should handle empty time logs gracefully
|
|
assert len(dialog._time_rows) == 0
|
|
assert dialog.table.rowCount() == 0
|
|
|
|
|
|
def test_invoice_dialog_loads_client_company_list(qtbot, invoice_dialog_setup):
|
|
"""Test that InvoiceDialog loads existing client companies."""
|
|
setup = invoice_dialog_setup
|
|
|
|
# Create another project with a different client company
|
|
proj_id_2 = setup["db"].add_project("Project 2")
|
|
setup["db"].upsert_project_billing(
|
|
proj_id_2,
|
|
hourly_rate_cents=10000,
|
|
currency="EUR",
|
|
tax_label="VAT",
|
|
tax_rate_percent=19.0,
|
|
client_name="Jane Doe",
|
|
client_company="Beta Corp",
|
|
client_address="456 Main St",
|
|
client_email="jane@beta.com",
|
|
)
|
|
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Should have both companies in the combo
|
|
companies = [
|
|
dialog.client_company_combo.itemText(i)
|
|
for i in range(dialog.client_company_combo.count())
|
|
]
|
|
assert "Acme Corp" in companies
|
|
assert "Beta Corp" in companies
|
|
|
|
|
|
def test_invoice_line_item_equality(app):
|
|
"""Test InvoiceLineItem equality."""
|
|
item1 = InvoiceLineItem("Work", 5.0, 10000, 50000)
|
|
item2 = InvoiceLineItem("Work", 5.0, 10000, 50000)
|
|
item3 = InvoiceLineItem("Other", 5.0, 10000, 50000)
|
|
|
|
assert item1 == item2
|
|
assert item1 != item3
|
|
|
|
|
|
def test_invoices_dialog_empty_database(qtbot, fresh_db):
|
|
"""Test InvoicesDialog with no projects or invoices."""
|
|
dialog = InvoicesDialog(fresh_db)
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Should have no projects in combo
|
|
assert dialog.project_combo.count() == 0
|
|
assert dialog.table.rowCount() == 0
|
|
|
|
|
|
def test_invoice_dialog_tax_initially_disabled(qtbot, fresh_db):
|
|
"""Test that tax fields are hidden when tax_rate_percent is None."""
|
|
proj_id = fresh_db.add_project("No Tax Project")
|
|
fresh_db.upsert_project_billing(
|
|
proj_id,
|
|
hourly_rate_cents=10000,
|
|
currency="USD",
|
|
tax_label="Tax",
|
|
tax_rate_percent=None, # No tax
|
|
client_name="Client",
|
|
client_company="Company",
|
|
client_address="Address",
|
|
client_email="email@test.com",
|
|
)
|
|
|
|
today = date.today()
|
|
start = (today - timedelta(days=1)).isoformat()
|
|
end = today.isoformat()
|
|
|
|
dialog = InvoiceDialog(fresh_db, proj_id, start, end)
|
|
qtbot.addWidget(dialog)
|
|
dialog.show()
|
|
|
|
# Tax checkbox should be unchecked
|
|
assert not dialog.tax_checkbox.isChecked()
|
|
|
|
# Tax fields should be hidden
|
|
assert not dialog.tax_label.isVisible()
|
|
assert not dialog.tax_label_edit.isVisible()
|
|
assert not dialog.tax_rate_label.isVisible()
|
|
assert not dialog.tax_rate_spin.isVisible()
|
|
|
|
|
|
def test_invoice_dialog_dates_default_values(qtbot, invoice_dialog_setup):
|
|
"""Test that issue and due dates have correct default values."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Issue date should be today
|
|
assert dialog.issue_date_edit.date() == QDate.currentDate()
|
|
|
|
# Due date should be 14 days from today
|
|
QDate.currentDate().addDays(14)
|
|
assert dialog.issue_date_edit.date() == QDate.currentDate()
|
|
|
|
|
|
def test_invoice_dialog_checkbox_toggle_updates_totals(qtbot, invoice_dialog_setup):
|
|
"""Test that unchecking a line item updates the total cost."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog.rate_spin.setValue(100.0)
|
|
dialog._populate_detailed_rows(10000)
|
|
dialog.tax_checkbox.setChecked(False)
|
|
|
|
# Initial total: 3 rows * 2.5 hours * $100 = $750
|
|
dialog._recalc_totals()
|
|
assert "750.00" in dialog.subtotal_label.text()
|
|
assert "750.00" in dialog.total_label.text()
|
|
|
|
# Uncheck the first row
|
|
include_item = dialog.table.item(0, dialog.COL_INCLUDE)
|
|
include_item.setCheckState(Qt.Unchecked)
|
|
|
|
# Wait for signal processing
|
|
qtbot.wait(10)
|
|
|
|
# New total: 2 rows * 2.5 hours * $100 = $500
|
|
assert "500.00" in dialog.subtotal_label.text()
|
|
assert "500.00" in dialog.total_label.text()
|
|
|
|
|
|
def test_invoice_dialog_checkbox_toggle_with_tax(qtbot, invoice_dialog_setup):
|
|
"""Test that checkbox toggling works correctly with tax enabled."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog.rate_spin.setValue(100.0)
|
|
dialog._populate_detailed_rows(10000)
|
|
dialog.tax_checkbox.setChecked(True)
|
|
dialog.tax_rate_spin.setValue(10.0)
|
|
|
|
# Initial: 3 rows * 2.5 hours * $100 = $750
|
|
# Tax: $750 * 10% = $75
|
|
# Total: $825
|
|
dialog._recalc_totals()
|
|
assert "750.00" in dialog.subtotal_label.text()
|
|
assert "75.00" in dialog.tax_label_total.text()
|
|
assert "825.00" in dialog.total_label.text()
|
|
|
|
# Uncheck two rows
|
|
dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
|
|
dialog.table.item(1, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
|
|
|
|
# Wait for signal processing
|
|
qtbot.wait(10)
|
|
|
|
# New total: 1 row * 2.5 hours * $100 = $250
|
|
# Tax: $250 * 10% = $25
|
|
# Total: $275
|
|
assert "250.00" in dialog.subtotal_label.text()
|
|
assert "25.00" in dialog.tax_label_total.text()
|
|
assert "275.00" in dialog.total_label.text()
|
|
|
|
|
|
def test_invoice_dialog_rechecking_items_updates_totals(qtbot, invoice_dialog_setup):
|
|
"""Test that rechecking a previously unchecked item updates totals."""
|
|
setup = invoice_dialog_setup
|
|
dialog = InvoiceDialog(
|
|
setup["db"],
|
|
setup["proj_id"],
|
|
setup["start_date"],
|
|
setup["end_date"],
|
|
setup["time_rows"],
|
|
)
|
|
qtbot.addWidget(dialog)
|
|
|
|
dialog.rate_spin.setValue(100.0)
|
|
dialog._populate_detailed_rows(10000)
|
|
dialog.tax_checkbox.setChecked(False)
|
|
|
|
# Uncheck all items
|
|
for row in range(dialog.table.rowCount()):
|
|
dialog.table.item(row, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
|
|
|
|
qtbot.wait(10)
|
|
|
|
# Total should be 0
|
|
assert "0.00" in dialog.total_label.text()
|
|
|
|
# Re-check first item
|
|
dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Checked)
|
|
qtbot.wait(10)
|
|
|
|
# Total should be 1 row * 2.5 hours * $100 = $250
|
|
assert "250.00" in dialog.total_label.text()
|
|
|
|
|
|
def test_invoices_dialog_select_initial_project(qtbot, invoices_dialog_setup):
|
|
"""Test _select_initial_project method."""
|
|
setup = invoices_dialog_setup
|
|
dialog = InvoicesDialog(setup["db"])
|
|
qtbot.addWidget(dialog)
|
|
|
|
# Initially should have first project selected (either proj1 or proj2)
|
|
initial_proj = dialog._current_project()
|
|
assert initial_proj in [setup["proj_id_1"], setup["proj_id_2"]]
|
|
|
|
# Select specific project
|
|
dialog._select_initial_project(setup["proj_id_2"])
|
|
|
|
# Should now have proj_id_2 selected
|
|
assert dialog._current_project() == setup["proj_id_2"]
|