Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,11 @@ def create_unit_test_dbs() -> None:
logger.info('Created unit test DB: %s', db_name)


def pytest_configure(config: Config) -> None: # noqa: ARG001
def pytest_configure(config: Config) -> None:
"""Setup test databases before test collection."""
# Skip for collect-only (e.g. IDE test discovery) so it can't race a real run for DB locks.
if config.getoption('collectonly'):
return
refresh_databases()
try:
create_unit_test_dbs()
Expand Down
206 changes: 199 additions & 7 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pathlib import Path

import pytest
import tomlkit
from typer.testing import CliRunner

from temoa.cli import _is_writable, app
Expand Down Expand Up @@ -127,7 +128,7 @@ def test_cli_validate_failure_on_invalid_db(tmp_path: Path) -> None:
args = ['validate', str(test_config_path), '--output', str(tmp_path)]
result = runner.invoke(app, args)

assert result.exit_code != 0, 'CLI should exit with a non-zero code on failure'
assert result.exit_code == 1, 'validate should exit 1 on a failed validation'
assert 'Validation failed' in result.stdout
# Check that the log was still created, containing the detailed error
assert (tmp_path / 'temoa-run.log').exists()
Expand All @@ -138,7 +139,7 @@ def test_cli_run_missing_config() -> None:
args = ['run', 'non_existent_file.toml']
result = runner.invoke(app, args)

assert result.exit_code != 0
assert result.exit_code == 2, 'missing config file should be rejected as a bad argument'
# Check that the error mentions the missing file (more robust than exact string match)
assert 'non_existent_file.toml' in result.stderr

Expand Down Expand Up @@ -292,7 +293,7 @@ def mock_is_writable_always_false(_path: Path) -> bool:
args = ['migrate', str(input_file)]
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code != 0, 'Migration should fail with a non-zero exit code'
assert result.exit_code == 1, 'migrate should exit 1 when no writable output location exists'
# Normalize whitespace to handle platform-specific line breaks from rich.print()
normalized_output = ' '.join(result.stdout.split())
assert 'Error: Neither input directory' in normalized_output
Expand All @@ -308,7 +309,7 @@ def test_cli_migrate_invalid_file() -> None:
args = ['migrate', 'non_existent.sql']
result = runner.invoke(app, args)

assert result.exit_code != 0
assert result.exit_code == 2, 'missing input file should be rejected as a bad argument'
# Typer handles file existence check, so error is in stderr
assert 'does not exist' in result.stderr or 'does not exist' in str(result.exception)

Expand All @@ -320,7 +321,7 @@ def test_cli_migrate_unknown_type(tmp_path: Path) -> None:
args = ['migrate', str(unknown_file)]
result = runner.invoke(app, args)

assert result.exit_code != 0
assert result.exit_code == 1, 'migrate should exit 1 for an undeterminable migration type'
assert 'Cannot determine migration type' in result.stdout


Expand Down Expand Up @@ -433,7 +434,7 @@ def test_cli_validate_fails_if_solver_missing(
args = ['validate', str(test_config_path), '--output', str(tmp_path)]
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code != 0, (
assert result.exit_code == 1, (
f'Validate should have failed: {result.exception}\n{result.stderr}\n{result.stdout}'
)
assert isinstance(result.exception, SystemExit)
Expand All @@ -459,7 +460,7 @@ def test_cli_run_fails_if_solver_missing(tmp_path: Path, monkeypatch: pytest.Mon
args = ['run', str(test_config_path), '--output', str(tmp_path)]
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code != 0, (
assert result.exit_code == 1, (
f'Run should have failed: {result.exception}\n{result.stderr}\n{result.stdout}'
)
assert isinstance(result.exception, SystemExit)
Expand All @@ -469,3 +470,194 @@ def test_cli_run_fails_if_solver_missing(tmp_path: Path, monkeypatch: pytest.Mon
# Use the more robust phrase for checking installation instructions
assert 'Please ensure the solver is installed and accessible.' in result.stdout
assert (tmp_path / 'temoa-run.log').exists()


# =============================================================================
# Tests for the `tutorial` command
# =============================================================================


def test_cli_tutorial_creates_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that `temoa tutorial` creates the config, database, and mc_settings files."""
monkeypatch.chdir(tmp_path)

args = ['tutorial', 'my_config', 'my_database']
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code == 0, f'CLI crashed with error: {result.exception}\n{result.stdout}'
assert (tmp_path / 'my_config.toml').exists()
assert (tmp_path / 'my_database.sqlite').exists()
assert (tmp_path / 'mc_settings.csv').exists()
assert 'Tutorial Setup Complete!' in result.stdout


def test_cli_tutorial_default_names(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that `temoa tutorial` uses its default file names when none are given."""
monkeypatch.chdir(tmp_path)

result = runner.invoke(app, ['tutorial'], catch_exceptions=False)

assert result.exit_code == 0, f'CLI crashed with error: {result.exception}\n{result.stdout}'
assert (tmp_path / 'tutorial_config.toml').exists()
assert (tmp_path / 'tutorial_database.sqlite').exists()


def test_cli_tutorial_updates_toml_database_paths(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that the generated config file points at the newly created database."""
monkeypatch.chdir(tmp_path)

result = runner.invoke(app, ['tutorial', 'cfg', 'db_name'], catch_exceptions=False)

assert result.exit_code == 0, f'CLI crashed with error: {result.exception}\n{result.stdout}'
doc = tomlkit.parse((tmp_path / 'cfg.toml').read_text())
assert doc['input_database'] == 'db_name.sqlite'
assert doc['output_database'] == 'db_name.sqlite'


def test_cli_tutorial_verbose_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that `--verbose` prints the extra progress and guidance messages."""
monkeypatch.chdir(tmp_path)

result = runner.invoke(app, ['tutorial', 'cfg', 'db', '--verbose'], catch_exceptions=False)

assert result.exit_code == 0
assert 'Copying tutorial resources...' in result.stdout
assert 'Updating database paths in configuration...' in result.stdout
assert 'Tutorial files created successfully' in result.stdout


def test_cli_tutorial_existing_files_aborts_without_force(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that existing tutorial files trigger a confirmation prompt that can be declined."""
monkeypatch.chdir(tmp_path)
(tmp_path / 'cfg.toml').write_text('placeholder')

result = runner.invoke(app, ['tutorial', 'cfg', 'db'], input='n\n')

# A declined confirmation is a graceful, non-error cancellation.
assert result.exit_code == 0
assert 'Tutorial files already exist' in result.stdout
assert 'Tutorial setup cancelled' in result.stdout
# The placeholder file should be untouched since the user declined.
assert (tmp_path / 'cfg.toml').read_text() == 'placeholder'


def test_cli_tutorial_existing_files_force_overwrite(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that `--force` overwrites existing tutorial files without prompting."""
monkeypatch.chdir(tmp_path)
(tmp_path / 'cfg.toml').write_text('placeholder')

result = runner.invoke(app, ['tutorial', 'cfg', 'db', '--force'], catch_exceptions=False)

assert result.exit_code == 0, f'CLI crashed with error: {result.exception}\n{result.stdout}'
assert (tmp_path / 'db.sqlite').exists()
assert (tmp_path / 'cfg.toml').read_text() != 'placeholder'
assert 'Tutorial setup cancelled' not in result.stdout


# =============================================================================
# Tests for the `check-units` command
# =============================================================================

VALID_UNITS_DB = Path(__file__).parent / 'testing_outputs' / 'utopia_valid_units.sqlite'
INVALID_CURRENCY_DB = Path(__file__).parent / 'testing_outputs' / 'utopia_invalid_currency.sqlite'

requires_unit_dbs = pytest.mark.skipif(
not (VALID_UNITS_DB.exists() and INVALID_CURRENCY_DB.exists()),
reason='Test databases not created. Ensure conftest.py setup completed successfully.',
)


@requires_unit_dbs
def test_cli_check_units_all_clear(tmp_path: Path) -> None:
"""Test `temoa check-units` reports success on a valid database."""
args = ['check-units', str(VALID_UNITS_DB), '--output', str(tmp_path)]
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code == 0, f'CLI crashed with error: {result.exception}\n{result.stdout}'
assert 'All unit checks passed' in result.stdout


@requires_unit_dbs
def test_cli_check_units_all_clear_silent(tmp_path: Path) -> None:
"""Test that `--silent` suppresses the success message."""
args = ['check-units', str(VALID_UNITS_DB), '--output', str(tmp_path), '--silent']
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code == 0
assert 'All unit checks passed' not in result.stdout


@requires_unit_dbs
def test_cli_check_units_detects_issues(tmp_path: Path) -> None:
"""Test that `temoa check-units` fails and writes a report for a bad database."""
args = ['check-units', str(INVALID_CURRENCY_DB), '--output', str(tmp_path)]
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code == 1, 'check-units should exit 1 when issues are found'
assert 'Unit check found issues' in result.stdout
assert 'Detailed report saved to' in result.stdout
assert 'Report Summary:' in result.stdout
reports = list(tmp_path.glob('units_check_*.txt'))
assert len(reports) == 1


@requires_unit_dbs
def test_cli_check_units_detects_issues_silent(tmp_path: Path) -> None:
"""Test that `--silent` suppresses the issue report summary but still fails and writes it."""
args = ['check-units', str(INVALID_CURRENCY_DB), '--output', str(tmp_path), '--silent']
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code == 1, 'check-units should exit 1 when issues are found'
assert 'Unit check found issues' not in result.stdout
reports = list(tmp_path.glob('units_check_*.txt'))
assert len(reports) == 1


@requires_unit_dbs
def test_cli_check_units_default_output_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that omitting `--output` defaults the report to ./unit_check_reports."""
monkeypatch.chdir(tmp_path)

args = ['check-units', str(VALID_UNITS_DB)]
result = runner.invoke(app, args, catch_exceptions=False)

assert result.exit_code == 0
assert (tmp_path / 'unit_check_reports').is_dir()


def test_cli_check_units_missing_database() -> None:
"""Test graceful failure for a missing database file."""
args = ['check-units', 'non_existent_db.sqlite']
result = runner.invoke(app, args)

assert result.exit_code == 2, 'missing database file should be rejected as a bad argument'
assert 'non_existent_db.sqlite' in result.stderr


# =============================================================================
# Tests for `_is_writable`
# =============================================================================


def test_is_writable_true_for_writable_dir(tmp_path: Path) -> None:
"""Test that a normal, writable directory is reported as writable."""
assert _is_writable(tmp_path) is True


def test_is_writable_false_on_oserror(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Test that `_is_writable` returns False when touching the probe file raises OSError."""

def _raise_oserror(*_args: object, **_kwargs: object) -> None:
raise OSError('mocked failure')

monkeypatch.setattr(Path, 'touch', _raise_oserror)

assert _is_writable(tmp_path) is False
17 changes: 17 additions & 0 deletions tests/test_evolution_updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Tests for the myopic evolution_updater template module."""

import logging

import pytest

from temoa.extensions.myopic.evolution_updater import iterate
from temoa.extensions.myopic.myopic_index import MyopicIndex


def test_iterate_logs_base_year(caplog: pytest.LogCaptureFixture) -> None:
idx = MyopicIndex(base_year=2020, step_year=2025, last_demand_year=2024, last_year=2030)

with caplog.at_level(logging.INFO):
iterate(idx=idx, prev_base_year=2015, last_instance_status='optimal', db_con=None)

assert 'base year 2020' in caplog.text
Loading
Loading