diff --git a/tests/unit/reporting/__init__.py b/tests/unit/reporting/__init__.py new file mode 100644 index 0000000..d5687ff --- /dev/null +++ b/tests/unit/reporting/__init__.py @@ -0,0 +1 @@ +"""Unit tests for reporting module.""" diff --git a/tests/unit/reporting/test_chart_generator.py b/tests/unit/reporting/test_chart_generator.py new file mode 100644 index 0000000..e1bda96 --- /dev/null +++ b/tests/unit/reporting/test_chart_generator.py @@ -0,0 +1,220 @@ +"""Unit tests for chart generator.""" + +import base64 +from datetime import datetime + +import pandas as pd +import pytest + +from py_dvt_ate.data.models import TestResult, TestRun, TestStatus +from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator +from py_dvt_ate.reporting.exceptions import ChartGenerationError + + +class TestChartGenerator: + """Tests for ChartGenerator class.""" + + @pytest.fixture + def generator(self) -> ChartGenerator: + """Create a chart generator instance.""" + return ChartGenerator(dpi=100) # Lower DPI for faster tests + + @pytest.fixture + def sample_run(self) -> TestRun: + """Create a sample test run.""" + return TestRun( + id="12345678-1234-1234-1234-123456789abc", + test_name="tempco", + started_at=datetime(2024, 1, 15, 10, 30, 0), + status=TestStatus.PASSED, + config_json="{}", + ) + + @pytest.fixture + def sample_results(self) -> list[TestResult]: + """Create sample test results.""" + return [ + TestResult( + id="result-1", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="tempco", + value=45.0, + unit="ppm/C", + measured_at=datetime(2024, 1, 15, 10, 35, 0), + ), + TestResult( + id="result-2", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="output_voltage_25c", + value=3.3001, + unit="V", + measured_at=datetime(2024, 1, 15, 10, 33, 0), + ), + ] + + @pytest.fixture + def voltage_measurements(self) -> pd.DataFrame: + """Create sample voltage measurements.""" + return pd.DataFrame( + { + "timestamp": [0.0, 100.0, 200.0, 300.0, 400.0], + "parameter": [ + "output_voltage", + "output_voltage", + "output_voltage", + "output_voltage", + "output_voltage", + ], + "value": [3.300, 3.298, 3.295, 3.290, 3.285], + "unit": ["V", "V", "V", "V", "V"], + "temperature": [-40.0, 0.0, 25.0, 50.0, 85.0], + } + ) + + def test_generator_initialisation(self) -> None: + """Test chart generator initialisation.""" + generator = ChartGenerator(dpi=200) + + assert generator.dpi == 200 + + def test_generate_voltage_vs_temperature( + self, generator: ChartGenerator, voltage_measurements: pd.DataFrame + ) -> None: + """Test generating voltage vs temperature chart.""" + chart_b64 = generator.generate_voltage_vs_temperature(voltage_measurements) + + # Should be valid base64 + assert isinstance(chart_b64, str) + assert len(chart_b64) > 100 # Should have meaningful content + + # Should decode to PNG image + decoded = base64.b64decode(chart_b64) + assert decoded[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic bytes + + def test_generate_voltage_vs_temperature_no_data( + self, generator: ChartGenerator + ) -> None: + """Test that error is raised with no voltage data.""" + empty_df = pd.DataFrame( + { + "timestamp": [0.0], + "parameter": ["other_param"], + "value": [1.0], + "unit": ["X"], + "temperature": [25.0], + } + ) + + with pytest.raises(ChartGenerationError): + generator.generate_voltage_vs_temperature(empty_df) + + def test_generate_results_bar_chart( + self, generator: ChartGenerator, sample_results: list[TestResult] + ) -> None: + """Test generating results bar chart.""" + chart_b64 = generator.generate_results_bar_chart(sample_results) + + # Should be valid base64 + assert isinstance(chart_b64, str) + assert len(chart_b64) > 100 + + # Should decode to PNG image + decoded = base64.b64decode(chart_b64) + assert decoded[:8] == b"\x89PNG\r\n\x1a\n" + + def test_generate_results_bar_chart_empty( + self, generator: ChartGenerator + ) -> None: + """Test that error is raised with no results.""" + with pytest.raises(ChartGenerationError): + generator.generate_results_bar_chart([]) + + def test_generate_all_with_measurements( + self, + generator: ChartGenerator, + sample_run: TestRun, + sample_results: list[TestResult], + voltage_measurements: pd.DataFrame, + ) -> None: + """Test generate_all produces expected charts.""" + charts = generator.generate_all(sample_run, sample_results, voltage_measurements) + + # Should have both chart types + assert "Voltage vs Temperature" in charts + assert "Results Summary" in charts + + # All should be valid base64 + for name, b64 in charts.items(): + assert isinstance(b64, str) + decoded = base64.b64decode(b64) + assert decoded[:8] == b"\x89PNG\r\n\x1a\n", f"Chart {name} is not valid PNG" + + def test_generate_all_no_measurements( + self, + generator: ChartGenerator, + sample_run: TestRun, + sample_results: list[TestResult], + ) -> None: + """Test generate_all with no measurements.""" + charts = generator.generate_all(sample_run, sample_results, None) + + # Should only have results chart + assert "Voltage vs Temperature" not in charts + assert "Results Summary" in charts + + def test_generate_all_no_results( + self, + generator: ChartGenerator, + sample_run: TestRun, + voltage_measurements: pd.DataFrame, + ) -> None: + """Test generate_all with no results.""" + charts = generator.generate_all(sample_run, [], voltage_measurements) + + # Should only have voltage chart + assert "Voltage vs Temperature" in charts + assert "Results Summary" not in charts + + def test_generate_all_empty( + self, generator: ChartGenerator, sample_run: TestRun + ) -> None: + """Test generate_all with no data.""" + charts = generator.generate_all(sample_run, [], None) + + # Should be empty + assert charts == {} + + def test_matplotlib_lazy_load(self) -> None: + """Test that matplotlib is lazy loaded.""" + generator = ChartGenerator() + + # _plt should be None before first use + assert generator._plt is None + + # After calling _get_matplotlib, it should be loaded + plt, mpl = generator._get_matplotlib() + + assert generator._plt is not None + assert plt is not None + + def test_dpi_affects_output_size(self) -> None: + """Test that higher DPI produces larger output.""" + low_dpi = ChartGenerator(dpi=50) + high_dpi = ChartGenerator(dpi=150) + + results = [ + TestResult( + id="result-1", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="test", + value=1.0, + unit="X", + measured_at=datetime(2024, 1, 15, 10, 35, 0), + ), + ] + + low_chart = low_dpi.generate_results_bar_chart(results) + high_chart = high_dpi.generate_results_bar_chart(results) + + # Higher DPI should produce larger image + assert len(high_chart) > len(low_chart) diff --git a/tests/unit/reporting/test_html_renderer.py b/tests/unit/reporting/test_html_renderer.py new file mode 100644 index 0000000..bb1d8ff --- /dev/null +++ b/tests/unit/reporting/test_html_renderer.py @@ -0,0 +1,222 @@ +"""Unit tests for HTML renderer.""" + +from datetime import datetime + +import pytest + +from py_dvt_ate.data.models import TestResult, TestRun, TestStatus +from py_dvt_ate.reporting.exceptions import TemplateRenderError +from py_dvt_ate.reporting.models import ReportConfig, ReportData +from py_dvt_ate.reporting.renderers.html import HTMLRenderer + + +class TestHTMLRenderer: + """Tests for HTMLRenderer class.""" + + @pytest.fixture + def renderer(self) -> HTMLRenderer: + """Create an HTML renderer instance.""" + return HTMLRenderer() + + @pytest.fixture + def sample_run(self) -> TestRun: + """Create a sample test run.""" + return TestRun( + id="12345678-1234-1234-1234-123456789abc", + test_name="tempco", + started_at=datetime(2024, 1, 15, 10, 30, 0), + completed_at=datetime(2024, 1, 15, 10, 35, 0), + status=TestStatus.PASSED, + config_json='{"temperatures": [-40, 25, 85], "input_voltage": 5.0}', + operator="test_user", + description="Temperature coefficient characterisation test", + ) + + @pytest.fixture + def sample_results(self) -> list[TestResult]: + """Create sample test results.""" + return [ + TestResult( + id="result-1", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="tempco", + value=45.0, + unit="ppm/C", + measured_at=datetime(2024, 1, 15, 10, 35, 0), + lower_limit=None, + upper_limit=100.0, + ), + TestResult( + id="result-2", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="output_voltage_25c", + value=3.3001, + unit="V", + measured_at=datetime(2024, 1, 15, 10, 33, 0), + lower_limit=3.2, + upper_limit=3.4, + ), + ] + + @pytest.fixture + def sample_report_data( + self, sample_run: TestRun, sample_results: list[TestResult] + ) -> ReportData: + """Create sample report data.""" + return ReportData( + run=sample_run, + results=sample_results, + config=ReportConfig(company_name="Test Company"), + ) + + def test_render_produces_html( + self, renderer: HTMLRenderer, sample_report_data: ReportData + ) -> None: + """Test that render produces valid HTML output.""" + html = renderer.render(sample_report_data) + + assert isinstance(html, str) + assert html.startswith("") + assert "" in html + + def test_render_includes_title( + self, renderer: HTMLRenderer, sample_report_data: ReportData + ) -> None: + """Test that render includes the test name in title.""" + html = renderer.render(sample_report_data) + + assert "" in html + assert "tempco" in html + + def test_render_includes_company_name( + self, renderer: HTMLRenderer, sample_report_data: ReportData + ) -> None: + """Test that render includes the company name.""" + html = renderer.render(sample_report_data) + + assert "Test Company" in html + + def test_render_includes_results_table( + self, renderer: HTMLRenderer, sample_report_data: ReportData + ) -> None: + """Test that render includes results in a table.""" + html = renderer.render(sample_report_data) + + # Check for parameter names in output + assert "tempco" in html + assert "output_voltage_25c" in html + + # Check for values + assert "45.000000" in html + assert "3.300100" in html + + def test_render_includes_pass_status( + self, renderer: HTMLRenderer, sample_report_data: ReportData + ) -> None: + """Test that render shows pass status badges.""" + html = renderer.render(sample_report_data) + + # Check for PASS badge (results should pass) + assert "PASS" in html + + def test_render_includes_run_metadata( + self, renderer: HTMLRenderer, sample_report_data: ReportData + ) -> None: + """Test that render includes run metadata.""" + html = renderer.render(sample_report_data) + + assert "12345678-1234-1234-1234-123456789abc" in html + assert "test_user" in html + assert "Temperature coefficient characterisation test" in html + + def test_render_includes_css( + self, renderer: HTMLRenderer, sample_report_data: ReportData + ) -> None: + """Test that render includes CSS styles.""" + html = renderer.render(sample_report_data) + + # Check for some CSS from styles.css + assert "<style>" in html + assert "@page" in html or "font-family" in html + + def test_render_includes_configuration( + self, renderer: HTMLRenderer, sample_report_data: ReportData + ) -> None: + """Test that render includes test configuration.""" + html = renderer.render(sample_report_data) + + # Check for config values (formatted JSON) + assert "temperatures" in html + assert "input_voltage" in html + + def test_render_with_charts( + self, renderer: HTMLRenderer, sample_run: TestRun, sample_results: list[TestResult] + ) -> None: + """Test that render includes chart images.""" + # Create sample base64 chart data + charts = { + "Test Chart": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + } + + data = ReportData( + run=sample_run, + results=sample_results, + charts=charts, + config=ReportConfig(), + ) + + html = renderer.render(data) + + # Check for chart section and base64 image + assert "Charts" in html + assert "data:image/png;base64," in html + + def test_render_empty_results( + self, renderer: HTMLRenderer, sample_run: TestRun + ) -> None: + """Test rendering with no results.""" + data = ReportData( + run=sample_run, + results=[], + config=ReportConfig(), + ) + + html = renderer.render(data) + + # Should still produce valid HTML + assert "<!DOCTYPE html>" in html + assert "No results recorded" in html + + def test_css_is_cached(self, renderer: HTMLRenderer) -> None: + """Test that CSS content is cached after first load.""" + # Access CSS twice + css1 = renderer._load_css() + css2 = renderer._load_css() + + # Should be the same object (cached) + assert css1 is css2 + assert len(css1) > 0 + + def test_render_formats_limits( + self, renderer: HTMLRenderer, sample_run: TestRun + ) -> None: + """Test that limits are properly formatted.""" + results = [ + TestResult( + id="result-1", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="test_param", + value=50.0, + unit="units", + measured_at=datetime(2024, 1, 15, 10, 35, 0), + lower_limit=10.0, + upper_limit=100.0, + ), + ] + + data = ReportData(run=sample_run, results=results, config=ReportConfig()) + html = renderer.render(data) + + # Check limits are formatted + assert "10.000000" in html + assert "100.000000" in html diff --git a/tests/unit/reporting/test_models.py b/tests/unit/reporting/test_models.py new file mode 100644 index 0000000..d87bfa9 --- /dev/null +++ b/tests/unit/reporting/test_models.py @@ -0,0 +1,208 @@ +"""Unit tests for reporting data models.""" + +from datetime import datetime +from pathlib import Path + +import pandas as pd +import pytest + +from py_dvt_ate.data.models import TestResult, TestRun, TestStatus +from py_dvt_ate.reporting.models import ReportConfig, ReportData + + +class TestReportConfig: + """Tests for ReportConfig dataclass.""" + + def test_default_values(self) -> None: + """Test default configuration values.""" + config = ReportConfig() + + assert config.company_name == "py_dvt_ate" + assert config.logo_path is None + assert config.include_charts is True + assert config.chart_dpi == 150 + + def test_custom_values(self) -> None: + """Test configuration with custom values.""" + config = ReportConfig( + company_name="Test Company", + logo_path=Path("/path/to/logo.png"), + include_charts=False, + chart_dpi=300, + ) + + assert config.company_name == "Test Company" + assert config.logo_path == Path("/path/to/logo.png") + assert config.include_charts is False + assert config.chart_dpi == 300 + + +class TestReportData: + """Tests for ReportData dataclass.""" + + @pytest.fixture + def sample_run(self) -> TestRun: + """Create a sample test run.""" + return TestRun( + id="12345678-1234-1234-1234-123456789abc", + test_name="tempco", + started_at=datetime(2024, 1, 15, 10, 30, 0), + completed_at=datetime(2024, 1, 15, 10, 35, 0), + status=TestStatus.PASSED, + config_json='{"temperatures": [-40, 25, 85]}', + operator="test_user", + description="Test description", + ) + + @pytest.fixture + def sample_results(self) -> list[TestResult]: + """Create sample test results.""" + return [ + TestResult( + id="result-1", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="tempco", + value=45.0, + unit="ppm/C", + measured_at=datetime(2024, 1, 15, 10, 35, 0), + lower_limit=None, + upper_limit=100.0, + ), + TestResult( + id="result-2", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="output_voltage_25c", + value=3.3001, + unit="V", + measured_at=datetime(2024, 1, 15, 10, 33, 0), + lower_limit=3.2, + upper_limit=3.4, + ), + ] + + def test_basic_report_data( + self, sample_run: TestRun, sample_results: list[TestResult] + ) -> None: + """Test creating basic report data.""" + data = ReportData(run=sample_run, results=sample_results) + + assert data.run == sample_run + assert data.results == sample_results + assert data.measurements is None + assert data.charts == {} + + def test_passed_count( + self, sample_run: TestRun, sample_results: list[TestResult] + ) -> None: + """Test passed_count property.""" + data = ReportData(run=sample_run, results=sample_results) + + # Both results should pass (within limits) + assert data.passed_count == 2 + + def test_failed_count(self, sample_run: TestRun) -> None: + """Test failed_count property with failed results.""" + failed_results = [ + TestResult( + id="result-1", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="tempco", + value=150.0, # Exceeds upper limit + unit="ppm/C", + measured_at=datetime(2024, 1, 15, 10, 35, 0), + lower_limit=None, + upper_limit=100.0, + ), + ] + + data = ReportData(run=sample_run, results=failed_results) + + assert data.failed_count == 1 + assert data.passed_count == 0 + + def test_overall_status_pass( + self, sample_run: TestRun, sample_results: list[TestResult] + ) -> None: + """Test overall_status when all tests pass.""" + data = ReportData(run=sample_run, results=sample_results) + + assert data.overall_status == "PASS" + + def test_overall_status_fail(self, sample_run: TestRun) -> None: + """Test overall_status when tests fail.""" + failed_results = [ + TestResult( + id="result-1", + test_run_id="12345678-1234-1234-1234-123456789abc", + parameter="tempco", + value=150.0, # Exceeds upper limit + unit="ppm/C", + measured_at=datetime(2024, 1, 15, 10, 35, 0), + lower_limit=None, + upper_limit=100.0, + ), + ] + + data = ReportData(run=sample_run, results=failed_results) + + assert data.overall_status == "FAIL" + + def test_overall_status_error(self) -> None: + """Test overall_status when run status is error.""" + error_run = TestRun( + id="12345678-1234-1234-1234-123456789abc", + test_name="tempco", + started_at=datetime(2024, 1, 15, 10, 30, 0), + status=TestStatus.ERROR, + config_json="{}", + ) + + data = ReportData(run=error_run, results=[]) + + assert data.overall_status == "ERROR" + + def test_with_measurements( + self, sample_run: TestRun, sample_results: list[TestResult] + ) -> None: + """Test report data with measurements DataFrame.""" + measurements = pd.DataFrame( + { + "timestamp": [0.0, 1.0, 2.0], + "parameter": ["output_voltage", "output_voltage", "output_voltage"], + "value": [3.30, 3.31, 3.30], + "unit": ["V", "V", "V"], + "temperature": [25.0, 25.0, 25.0], + } + ) + + data = ReportData( + run=sample_run, results=sample_results, measurements=measurements + ) + + assert data.measurements is not None + assert len(data.measurements) == 3 + + def test_with_charts( + self, sample_run: TestRun, sample_results: list[TestResult] + ) -> None: + """Test report data with chart images.""" + charts = { + "Voltage vs Temperature": "base64_encoded_image_data", + "Results Summary": "another_base64_image", + } + + data = ReportData(run=sample_run, results=sample_results, charts=charts) + + assert len(data.charts) == 2 + assert "Voltage vs Temperature" in data.charts + + def test_with_custom_config( + self, sample_run: TestRun, sample_results: list[TestResult] + ) -> None: + """Test report data with custom configuration.""" + config = ReportConfig(company_name="Test Company", include_charts=False) + + data = ReportData(run=sample_run, results=sample_results, config=config) + + assert data.config.company_name == "Test Company" + assert data.config.include_charts is False