From 089f31257523aad31c9aaf9d0b35d82b5d0a7196 Mon Sep 17 00:00:00 2001 From: Bruno Domingues Date: Mon, 20 Oct 2025 22:20:26 -0300 Subject: [PATCH] SIENTIAPDE-1255: Add unit tests for the reports module --- tests/sientia/test_reports.py | 361 ++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 tests/sientia/test_reports.py diff --git a/tests/sientia/test_reports.py b/tests/sientia/test_reports.py new file mode 100644 index 0000000..b4edfe2 --- /dev/null +++ b/tests/sientia/test_reports.py @@ -0,0 +1,361 @@ +import os +from unittest.mock import MagicMock + +import pytest +from bs4 import BeautifulSoup + +from model_manager.sientia import reports + + +@pytest.fixture +def stub_color_options(monkeypatch): + def fake_color_options(**kwargs): + return dict(kwargs) + + monkeypatch.setattr(reports, 'ColorOptions', fake_color_options) + + +def test_load_html_from_file_success(tmp_path): + sample_file = tmp_path / 'sample.html' + sample_file.write_text('

Hello

', encoding='utf-8') + + content = reports.load_html_from_file(str(sample_file)) + + assert content == '

Hello

' + + +def test_load_html_from_file_missing_file(capsys): + result = reports.load_html_from_file('non-existent.html') + + captured = capsys.readouterr() + assert result is None + assert 'File not found: non-existent.html' in captured.out + + +def test_load_html_from_file_os_error(monkeypatch, capsys): + def fake_open(*_args, **_kwargs): + raise OSError('boom') + + monkeypatch.setattr('builtins.open', fake_open) + + result = reports.load_html_from_file('path.html') + + captured = capsys.readouterr() + assert result is None + assert 'Error reading file: boom' in captured.out + + +def test_inject_content_replaces_section(): + main_html = "
old
" + content = 'new' + + result = reports.inject_content(main_html, 'target', content) + + soup = BeautifulSoup(result, 'html.parser') + section = soup.find(id='target') + assert section is not None + assert section.find('span').text == 'new' + + +def test_inject_content_missing_section(capsys): + main_html = "
keep
" + + result = reports.inject_content(main_html, 'missing', '

ignored

') + + captured = capsys.readouterr() + assert "Section with id 'missing' not found" in captured.out + soup = BeautifulSoup(result, 'html.parser') + assert soup.find(id='other') is not None + + +def test_reports_init_sets_defaults(stub_color_options): + report = reports.Reports(reference_data='ref', current_data='cur') + + assert report.metrics == [] + assert isinstance(report.options, list) and len(report.options) == 1 + assert report.sections == {} + assert report.base_path is None + + +def test_add_data_quality_section_without_run(monkeypatch, stub_color_options): + monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: 'summary') + monkeypatch.setattr( + reports, + 'generate_column_metrics', + lambda *args, **kwargs: ('columns', kwargs), + ) + monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: 'conflict') + monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: 'correlations') + + report = reports.Reports(reference_data='ref', current_data='cur') + report.add_data_quality_section(columns=['col'], run=False) + + assert report.metrics[-4:] == [ + 'summary', + ('columns', {'columns': ['col'], 'skip_id_column': True}), + 'conflict', + 'correlations', + ] + assert 'data_quality' not in report.sections + + +def test_add_data_quality_section_with_run(monkeypatch, tmp_path, stub_color_options): + summary = object() + column_metrics = object() + conflict = object() + correlations = object() + monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: summary) + + def fake_generate_column_metrics(*_args, **kwargs): + return column_metrics + + monkeypatch.setattr(reports, 'generate_column_metrics', fake_generate_column_metrics) + monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: conflict) + monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: correlations) + + report_instance = MagicMock() + report_instance.as_dict.return_value = {'result': 'data_quality'} + ReportMock = MagicMock(return_value=report_instance) + monkeypatch.setattr(reports, 'Report', ReportMock) + + report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path)) + report.add_data_quality_section(columns=['c1'], run=True) + + assert report.metrics[-4:] == [summary, column_metrics, conflict, correlations] + assert report.sections['data_quality'] == {'result': 'data_quality'} + ReportMock.assert_called_once_with( + metrics=[summary, column_metrics, conflict, correlations], options=report.options + ) + report_instance.run.assert_called_once_with(reference_data='ref', current_data='cur') + report_instance.save_html.assert_called_once_with( + os.path.join(str(tmp_path), 'data_quality.html') + ) + + +def test_add_data_quality_section_run_without_base_path(monkeypatch, stub_color_options): + summary = object() + column_metrics = object() + conflict = object() + correlations = object() + monkeypatch.setattr(reports, 'DatasetSummaryMetric', lambda: summary) + monkeypatch.setattr( + reports, + 'generate_column_metrics', + lambda *args, **kwargs: column_metrics, + ) + monkeypatch.setattr(reports, 'ConflictTargetMetric', lambda: conflict) + monkeypatch.setattr(reports, 'DatasetCorrelationsMetric', lambda: correlations) + + report_instance = MagicMock() + report_instance.as_dict.return_value = {'result': 'quality'} + ReportMock = MagicMock(return_value=report_instance) + monkeypatch.setattr(reports, 'Report', ReportMock) + + report = reports.Reports(reference_data='ref', current_data='cur') + report.add_data_quality_section(run=True) + + assert report.sections['data_quality'] == {'result': 'quality'} + report_instance.save_html.assert_not_called() + + +def test_add_data_drift_section_paths(monkeypatch, tmp_path, stub_color_options): + drift_instances = [object(), object(), object()] + DataDriftPresetMock = MagicMock(side_effect=drift_instances) + monkeypatch.setattr(reports, 'DataDriftPreset', DataDriftPresetMock) + + report_instance = MagicMock() + report_instance.as_dict.return_value = {'result': 'data_drift'} + ReportMock = MagicMock(return_value=report_instance) + monkeypatch.setattr(reports, 'Report', ReportMock) + + report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path)) + report.add_data_drift_section(columns=['c1'], run=False) + assert report.metrics[-1] == drift_instances[0] + assert 'data_drift' not in report.sections + + report.add_data_drift_section(columns=['c1'], run=True) + assert report.sections['data_drift'] == {'result': 'data_drift'} + ReportMock.assert_called_with(metrics=[drift_instances[2]], options=report.options) + report_instance.run.assert_called_with(reference_data='ref', current_data='cur') + report_instance.save_html.assert_called_with(os.path.join(str(tmp_path), 'data_drift.html')) + + +def test_add_data_drift_section_run_without_base_path(monkeypatch, stub_color_options): + drift_instances = [object(), object(), object()] + DataDriftPresetMock = MagicMock(side_effect=drift_instances) + monkeypatch.setattr(reports, 'DataDriftPreset', DataDriftPresetMock) + + report_instance = MagicMock() + report_instance.as_dict.return_value = {'result': 'drift'} + ReportMock = MagicMock(return_value=report_instance) + monkeypatch.setattr(reports, 'Report', ReportMock) + + report = reports.Reports(reference_data='ref', current_data='cur') + report.add_data_drift_section(run=True) + + assert report.sections['data_drift'] == {'result': 'drift'} + report_instance.save_html.assert_not_called() + + +def test_add_regression_section(monkeypatch, tmp_path, stub_color_options): + regression_metrics = [object() for _ in range(7)] + monkeypatch.setattr(reports, 'RegressionPerformanceMetrics', lambda: regression_metrics[0]) + monkeypatch.setattr(reports, 'RegressionDummyMetric', lambda: regression_metrics[1]) + monkeypatch.setattr( + reports, 'RegressionPredictedVsActualScatter', lambda: regression_metrics[2] + ) + monkeypatch.setattr(reports, 'RegressionPredictedVsActualPlot', lambda: regression_metrics[3]) + monkeypatch.setattr(reports, 'RegressionErrorPlot', lambda: regression_metrics[4]) + monkeypatch.setattr(reports, 'RegressionAbsPercentageErrorPlot', lambda: regression_metrics[5]) + monkeypatch.setattr(reports, 'RegressionErrorDistribution', lambda: regression_metrics[6]) + + report_instance = MagicMock() + report_instance.as_dict.return_value = {'result': 'regression'} + ReportMock = MagicMock(return_value=report_instance) + monkeypatch.setattr(reports, 'Report', ReportMock) + + report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(tmp_path)) + + report.add_regression_section(run=False) + assert report.metrics[-7:] == regression_metrics + assert 'regression' not in report.sections + + report.add_regression_section(run=True) + assert report.sections['regression'] == {'result': 'regression'} + ReportMock.assert_called_with(metrics=regression_metrics, options=report.options) + report_instance.run.assert_called_with(reference_data='ref', current_data='cur') + report_instance.save_html.assert_called_with(os.path.join(str(tmp_path), 'regression.html')) + + +def test_add_regression_section_run_without_base_path(monkeypatch, stub_color_options): + regression_metrics = [object() for _ in range(7)] + monkeypatch.setattr(reports, 'RegressionPerformanceMetrics', lambda: regression_metrics[0]) + monkeypatch.setattr(reports, 'RegressionDummyMetric', lambda: regression_metrics[1]) + monkeypatch.setattr( + reports, 'RegressionPredictedVsActualScatter', lambda: regression_metrics[2] + ) + monkeypatch.setattr(reports, 'RegressionPredictedVsActualPlot', lambda: regression_metrics[3]) + monkeypatch.setattr(reports, 'RegressionErrorPlot', lambda: regression_metrics[4]) + monkeypatch.setattr(reports, 'RegressionAbsPercentageErrorPlot', lambda: regression_metrics[5]) + monkeypatch.setattr(reports, 'RegressionErrorDistribution', lambda: regression_metrics[6]) + + report_instance = MagicMock() + report_instance.as_dict.return_value = {'result': 'reg'} + ReportMock = MagicMock(return_value=report_instance) + monkeypatch.setattr(reports, 'Report', ReportMock) + + report = reports.Reports(reference_data='ref', current_data='cur') + report.add_regression_section(run=True) + + assert report.sections['regression'] == {'result': 'reg'} + report_instance.save_html.assert_not_called() + + +def test_set_color_options_appends(monkeypatch): + calls = [] + + def color_options_mock(**kwargs): + calls.append(kwargs) + return kwargs + + monkeypatch.setattr(reports, 'ColorOptions', color_options_mock) + + report = reports.Reports(reference_data='ref', current_data='cur') + report.set_color_options(primary_color='#111', secondary_color='#222') + + assert len(report.options) == 2 + assert calls[0]['primary_color'] == '#0F4C81' + assert calls[1]['primary_color'] == '#111' + assert report.options[1]['secondary_color'] == '#222' + + +def test_save_all_sections_html_requires_base_path(stub_color_options): + report = reports.Reports(reference_data='ref', current_data='cur') + + with pytest.raises(ValueError): + report.save_all_sections_html('output/report.html') + + +def test_save_all_sections_html_writes_output(tmp_path, stub_color_options): + base_dir = tmp_path / 'templates' + base_dir.mkdir() + (base_dir / 'header.html').write_text( + "
", + encoding='utf-8', + ) + (base_dir / 'data_drift.html').write_text('

Drift

', encoding='utf-8') + (base_dir / 'data_quality.html').write_text('

Quality

', encoding='utf-8') + (base_dir / 'regression.html').write_text('

Regression

', encoding='utf-8') + + report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(base_dir)) + output_path = tmp_path / 'reports' / 'combined.html' + + report.save_all_sections_html(str(output_path)) + + assert output_path.exists() + content = output_path.read_text(encoding='utf-8') + assert '

Drift

' in content + assert '

Quality

' in content + assert '

Regression

' in content + + +def test_save_all_sections_html_creates_directory(monkeypatch, tmp_path, stub_color_options): + base_dir = tmp_path / 'templates' + base_dir.mkdir() + (base_dir / 'header.html').write_text( + "
", + encoding='utf-8', + ) + (base_dir / 'data_drift.html').write_text('

Drift

', encoding='utf-8') + (base_dir / 'data_quality.html').write_text('

Quality

', encoding='utf-8') + (base_dir / 'regression.html').write_text('

Regression

', encoding='utf-8') + + make_dirs_called = [] + report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(base_dir)) + output_path = tmp_path / 'nested' / 'report.html' + output_dir = str(output_path.parent) + + original_exists = os.path.exists + original_makedirs = os.makedirs + + def fake_exists(path): + if path == output_dir: + return False + return original_exists(path) + + def fake_makedirs(path, exist_ok=False): + make_dirs_called.append((path, exist_ok)) + return original_makedirs(path, exist_ok=exist_ok) + + monkeypatch.setattr(os.path, 'exists', fake_exists) + monkeypatch.setattr(os, 'makedirs', fake_makedirs) + + report.save_all_sections_html(str(output_path)) + + assert make_dirs_called == [(str(output_path.parent), True)] + + +def test_save_all_sections_html_no_directory_needed(monkeypatch, tmp_path, stub_color_options): + base_dir = tmp_path / 'templates' + base_dir.mkdir() + (base_dir / 'header.html').write_text( + "
", + encoding='utf-8', + ) + (base_dir / 'data_drift.html').write_text('

Drift

', encoding='utf-8') + (base_dir / 'data_quality.html').write_text('

Quality

', encoding='utf-8') + (base_dir / 'regression.html').write_text('

Regression

', encoding='utf-8') + + mk_calls = [] + + def fake_makedirs(path, exist_ok=False): + mk_calls.append((path, exist_ok)) + + monkeypatch.setattr(os, 'makedirs', fake_makedirs) + monkeypatch.chdir(tmp_path) + + report = reports.Reports(reference_data='ref', current_data='cur', base_path=str(base_dir)) + report.save_all_sections_html('report.html') + + assert mk_calls == [] + assert (tmp_path / 'report.html').exists()