"""Tests for the output XLSX writer (src/writer.py).""" from __future__ import annotations import os import tempfile from io import BytesIO from openpyxl import load_workbook from src.enums import TransferType from src.types import CalculationResult, ContractRef, TransferRecord from src.writer import ( _fmt, _month_abbr, _none_to_empty, _pct, _zero_or_empty, build_adjustment_rows, write_output, write_output_to_buffer, ) from tests.unit.helpers import make_record # ─── Test helpers ──────────────────────────────────────────────────────────── def _contract( account_name: str = '', account_id: int | None = None, contract_name: str = '', contract_id: int | None = None, ) -> ContractRef: return ContractRef( account_name=account_name, account_id=account_id, contract_name=contract_name, contract_id=contract_id, ) def _record(**overrides: object) -> TransferRecord: defaults: dict[str, object] = { 'currency': 'USD', } defaults.update(overrides) return make_record(**defaults) def _result( calculated_amount: float = 500.0, **record_overrides: object, ) -> CalculationResult: rec = _record(**record_overrides) return CalculationResult(record=rec, calculated_amount=calculated_amount) # ═══════════════════════════════════════════════════════════════════════════════ # build_adjustment_rows # ═══════════════════════════════════════════════════════════════════════════════ class TestBuildAdjustmentRows: """Tests for the build_adjustment_rows function.""" def test_produces_from_and_to_rows(self) -> None: rows = build_adjustment_rows([_result()]) assert len(rows) == 2 sides = [r.side for r in rows] assert sides == ['FROM', 'TO'] def test_from_amount_is_negative(self) -> None: rows = build_adjustment_rows([_result(calculated_amount=300.0)]) from_row = rows[0] assert from_row.side == 'FROM' assert from_row.amount == -300.0 def test_to_amount_is_positive(self) -> None: rows = build_adjustment_rows([_result(calculated_amount=300.0)]) to_row = rows[1] assert to_row.side == 'TO' assert to_row.amount == 300.0 def test_zero_calculated_amount_is_skipped(self) -> None: rows = build_adjustment_rows([_result(calculated_amount=0.0)]) assert len(rows) == 0 def test_nonzero_calculated_amount_is_kept(self) -> None: rows = build_adjustment_rows([_result(calculated_amount=100.0)]) assert len(rows) == 2 def test_error_produces_single_error_row(self) -> None: rows = build_adjustment_rows( [ _result( error='Contract not found', error_contract_id=999, calculated_amount=0.0, ), ] ) assert len(rows) == 1 row = rows[0] assert row.side == 'FROM' assert row.amount is None assert row.error == 'Contract not found' assert row.error_contract_id == 999 assert 'ERROR:' in row.client_facing_comments def test_default_dates_used_when_zero(self) -> None: rows = build_adjustment_rows([_result()]) from datetime import datetime now = datetime.now() assert rows[0].activity_month == now.month assert rows[0].activity_year == now.year assert rows[0].statement_month == now.month assert rows[0].statement_year == now.year def test_explicit_dates_preserved(self) -> None: rows = build_adjustment_rows( [ _result( activity_month=6, activity_year=2025, statement_month=7, statement_year=2025, ), ] ) assert rows[0].activity_month == 6 assert rows[0].activity_year == 2025 assert rows[0].statement_month == 7 assert rows[0].statement_year == 2025 def test_base_fields_propagated(self) -> None: rows = build_adjustment_rows( [ _result( transfer_type=TransferType.OVERRIDE, currency='EUR', ), ] ) for row in rows: assert row.transfer_type == 'Override' assert row.currency == 'EUR' def test_earnings_transfer_id_propagated(self) -> None: rows = build_adjustment_rows([_result(earnings_transfer_id=42)]) for row in rows: assert row.earnings_transfer_id == 42 def test_to_row_uses_to_contract_data(self) -> None: rows = build_adjustment_rows([_result()]) to_row = rows[1] assert to_row.account_name == 'To Acct' assert to_row.contract_id == 200 def test_to_row_falls_back_to_from_account_name(self) -> None: rows = build_adjustment_rows( [ _result( to_contract=_contract( account_name='', contract_id=200, ), ), ] ) to_row = rows[1] assert to_row.account_name == 'From Acct' def test_from_comment_generated_when_missing(self) -> None: rows = build_adjustment_rows([_result()]) from_row = rows[0] assert 'To Acct' in from_row.client_facing_comments def test_explicit_from_comment_preserved(self) -> None: rows = build_adjustment_rows([_result(from_comment='Custom comment')]) assert rows[0].client_facing_comments == 'Custom comment' def test_explicit_to_comment_preserved(self) -> None: rows = build_adjustment_rows([_result(to_comment='Custom TO comment')]) assert rows[1].client_facing_comments == 'Custom TO comment' def test_input_field_includes_balance_value(self) -> None: rows = build_adjustment_rows( [_result(input='net_revenue', net_revenue=800.0, to_net_revenue=200.0)] ) assert rows[0].input_field == 'net_revenue = 800.00' assert rows[1].input_field == 'net_revenue = 200.00' def test_input_field_shows_na_when_balance_missing(self) -> None: rows = build_adjustment_rows([_result(closing_balance=1000.0)]) to_row = rows[1] assert to_row.input_field == 'closing_balance = N/A' def test_from_projected_balance(self) -> None: rows = build_adjustment_rows( [_result(calculated_amount=300.0, closing_balance=1000.0)] ) from_row = rows[0] assert from_row.projected_balance == 700.0 def test_to_projected_balance_with_to_balance(self) -> None: rows = build_adjustment_rows( [_result(calculated_amount=300.0, to_closing_balance=500.0)] ) to_row = rows[1] assert to_row.projected_balance == 800.0 def test_to_projected_balance_none_when_no_to_balance(self) -> None: rows = build_adjustment_rows([_result()]) to_row = rows[1] assert to_row.projected_balance is None def test_to_projected_balance_uses_net_revenue(self) -> None: rows = build_adjustment_rows( [ _result( calculated_amount=200.0, input='net_revenue', to_net_revenue=600.0 ) ] ) to_row = rows[1] assert to_row.projected_balance == 800.0 def test_to_projected_balance_uses_gross_revenue(self) -> None: rows = build_adjustment_rows( [ _result( calculated_amount=150.0, input='gross_revenue', to_gross_revenue=400.0, ) ] ) to_row = rows[1] assert to_row.projected_balance == 550.0 def test_projected_balance_none_when_balance_missing(self) -> None: rows = build_adjustment_rows( [ _result( calculated_amount=100.0, closing_balance=None, transfer_amount=100.0 ) ] ) from_row = rows[0] assert from_row.projected_balance is None # ═══════════════════════════════════════════════════════════════════════════════ # write_output_to_buffer # ═══════════════════════════════════════════════════════════════════════════════ class TestWriteOutputToBuffer: """Tests for the write_output_to_buffer function.""" def test_produces_valid_xlsx(self) -> None: buf = write_output_to_buffer([_result()]) assert isinstance(buf, bytes) wb = load_workbook(BytesIO(buf)) assert len(wb.sheetnames) >= 2 def test_adjustments_and_summary_always_present(self) -> None: buf = write_output_to_buffer([_result()]) wb = load_workbook(BytesIO(buf)) assert 'Adjustments' in wb.sheetnames assert 'Summary' in wb.sheetnames def test_groups_sheet_absent(self) -> None: """Groups sheet is no longer produced.""" buf = write_output_to_buffer([_result()]) wb = load_workbook(BytesIO(buf)) assert 'Groups' not in wb.sheetnames def test_errors_absent_when_none(self) -> None: buf = write_output_to_buffer([_result()]) wb = load_workbook(BytesIO(buf)) assert 'Errors' not in wb.sheetnames def test_errors_present_when_errors_exist(self) -> None: buf = write_output_to_buffer( [ _result( error='Contract not found', error_contract_id=999, calculated_amount=0.0, ), ] ) wb = load_workbook(BytesIO(buf)) assert 'Errors' in wb.sheetnames def test_adjustments_header_row(self) -> None: buf = write_output_to_buffer([_result()]) wb = load_workbook(BytesIO(buf)) ws = wb['Adjustments'] headers = [cell.value for cell in ws[1]] assert headers[0] == 'Account Name' assert headers[3] == 'Contract ID' assert headers[5] == 'Amount' assert headers[11] == 'Adjustment Type' assert headers[12] == 'Client Facing Comments' assert headers[18] == 'Input' assert headers[19] == 'Projected Balance' def test_adjustments_data_rows(self) -> None: buf = write_output_to_buffer([_result(calculated_amount=250.0)]) wb = load_workbook(BytesIO(buf)) ws = wb['Adjustments'] # Row 2 = FROM, Row 3 = TO assert ws.max_row == 3 # header + 2 data rows # FROM row amount (col F = 6) should be -250 assert ws.cell(row=2, column=6).value == -250.0 # TO row amount should be +250 assert ws.cell(row=3, column=6).value == 250.0 def test_summary_header_row(self) -> None: buf = write_output_to_buffer([_result()]) wb = load_workbook(BytesIO(buf)) ws = wb['Summary'] headers = [cell.value for cell in ws[1]] assert headers[0] == 'Earnings Transfer ID' assert headers[1] == 'Transfer Type' assert headers[2] == 'Rate Type' assert headers[4] == 'Input' assert headers[5] == 'Negative' assert headers[11] == 'Closing Balance' assert headers[17] == 'Selected Balance' assert headers[18] == 'Calculated Amount' assert headers[21] == 'Error' assert headers[22] == 'Block Reason' def test_summary_block_reason_populated_when_blocked(self) -> None: from src.calculator import evaluate rec = _record(closing_balance=-500.0, transfer_amount=0.5) results = evaluate([rec]) buf = write_output_to_buffer(results) wb = load_workbook(BytesIO(buf)) ws = wb['Summary'] block_reason = ws.cell(row=2, column=23).value assert block_reason assert 'negative' in block_reason def test_summary_block_reason_empty_when_not_blocked(self) -> None: from src.calculator import evaluate rec = _record(closing_balance=1000.0, transfer_amount=0.5) results = evaluate([rec]) buf = write_output_to_buffer(results) wb = load_workbook(BytesIO(buf)) ws = wb['Summary'] assert ws.cell(row=2, column=23).value in (None, '') def test_adjustments_error_rows_excluded(self) -> None: buf = write_output_to_buffer( [ _result( error='Missing contract', calculated_amount=0.0, ), ] ) wb = load_workbook(BytesIO(buf)) ws = wb['Adjustments'] # Error rows must not appear in Adjustments — only header row assert ws.max_row == 1 def test_errors_sheet_all_rows_red(self) -> None: buf = write_output_to_buffer( [ _result( error='Contract not found', error_contract_id=999, calculated_amount=0.0, ), ] ) wb = load_workbook(BytesIO(buf)) ws = wb['Errors'] # Row 2 is the first data row for cell in ws[2]: assert cell.fill.start_color.rgb == 'FFFFC7CE' def test_header_has_blue_fill(self) -> None: buf = write_output_to_buffer([_result()]) wb = load_workbook(BytesIO(buf)) ws = wb['Adjustments'] header_fill = ws.cell(row=1, column=1).fill assert header_fill.start_color.rgb == 'FFD9E1F2' def test_header_is_bold(self) -> None: buf = write_output_to_buffer([_result()]) wb = load_workbook(BytesIO(buf)) ws = wb['Adjustments'] assert ws.cell(row=1, column=1).font.bold is True def test_empty_results_produce_headers_only(self) -> None: buf = write_output_to_buffer([]) wb = load_workbook(BytesIO(buf)) assert 'Adjustments' in wb.sheetnames assert 'Summary' in wb.sheetnames ws = wb['Adjustments'] assert ws.max_row == 1 # header only def test_multiple_records(self) -> None: buf = write_output_to_buffer( [ _result(calculated_amount=100.0), _result(calculated_amount=200.0, earnings_transfer_id=2), ] ) wb = load_workbook(BytesIO(buf)) ws = wb['Adjustments'] # header + 4 data rows assert ws.max_row == 5 # ═══════════════════════════════════════════════════════════════════════════════ # write_output (filesystem variant) # ═══════════════════════════════════════════════════════════════════════════════ class TestWriteOutput: """Tests for write_output (filesystem).""" def test_write_output_creates_file(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, 'test_output.xlsx') returned_path = write_output( [_result()], source_file_name='test.xlsx', output_path=path, ) assert returned_path == path assert os.path.exists(path) wb = load_workbook(path) assert 'Adjustments' in wb.sheetnames def test_write_output_default_path(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: original_cwd = os.getcwd() try: os.chdir(tmpdir) os.makedirs('output', exist_ok=True) returned_path = write_output( [_result()], source_file_name='my_workbook.xlsx', ) assert returned_path.endswith('my_workbook_output.xlsx') assert os.path.exists(returned_path) finally: os.chdir(original_cwd) # ═══════════════════════════════════════════════════════════════════════════════ # Writer helper functions # ═══════════════════════════════════════════════════════════════════════════════ class TestPct: """Tests for _pct helper.""" def test_pct_none(self) -> None: assert _pct(None) == 'N/A' def test_pct_zero(self) -> None: assert _pct(0.0) == '0%' def test_pct_full(self) -> None: assert _pct(1.0) == '100%' class TestFmt: """Tests for _fmt helper.""" def test_fmt_none(self) -> None: assert _fmt(None) == 'N/A' def test_fmt_zero(self) -> None: assert _fmt(0.0) == 0.0 class TestMonthAbbr: """Tests for _month_abbr helper.""" def test_month_abbr_zero_empty(self) -> None: assert _month_abbr(0) == '' def test_month_abbr_13_empty(self) -> None: assert _month_abbr(13) == '' def test_month_abbr_valid(self) -> None: assert _month_abbr(1) == 'Jan' class TestNoneToEmpty: """Tests for _none_to_empty helper.""" def test_none_to_empty_zero_stays(self) -> None: assert _none_to_empty(0) == 0 def test_none_to_empty_none(self) -> None: assert _none_to_empty(None) == '' class TestZeroOrEmpty: """Tests for _zero_or_empty helper.""" def test_zero_or_empty_zero(self) -> None: assert _zero_or_empty(0) == '' def test_zero_or_empty_nonzero(self) -> None: assert _zero_or_empty(5) == 5