39 Commits

Author SHA1 Message Date
885196d933 refactor(deploy): remove per-app tunnel in favour of central cloudflared
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 20s
CI / Test (push) Successful in 57s
CI / Release (push) Has been skipped
Routing now handled by central cloudflared + NPM on public-network.
2026-02-03 23:43:29 +00:00
f4c34e4689 fix(deploy): rename cloudflared service to avoid DNS collision
All checks were successful
CI / Lint (push) Successful in 6s
CI / Type Check (push) Successful in 21s
CI / Test (push) Successful in 1m0s
CI / Release (push) Has been skipped
Multiple projects using the same service name on a shared Docker network
causes DNS resolution conflicts. Renamed to 'tunnel' for uniqueness.
2026-02-03 00:15:13 +00:00
aba2cabbbc fix(dashboard): stop server event loop on correct thread
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 20s
CI / Test (push) Successful in 1m3s
CI / Release (push) Has been skipped
When idle shutdown triggered _stop_server(), it was creating a new event
loop and calling server.stop() on it, but the daemon thread was still
running loop.run_forever() on the original event loop. This left sockets
bound, causing "address already in use" on restart.

Fix by storing references to the server's event loop and thread, then
using call_soon_threadsafe(loop.stop) to signal the correct loop to exit.
The thread join ensures sockets are released before the next server starts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:16:47 +00:00
1ec05ea289 feat(dashboard): auto-stop server after 5 minutes idle
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 18s
CI / Test (push) Successful in 56s
CI / Release (push) Has been skipped
Replace pause-on-idle with full server shutdown after IDLE_SHUTDOWN_SECONDS
(default 5 minutes). Next visitor gets a fresh simulation instance.

- Idle checker stops server and clears st.cache_resource
- init_session_state detects stopped server and recreates fresh state
- Clears instruments and history for clean restart

Configurable via IDLE_SHUTDOWN_SECONDS environment variable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:57:23 +00:00
b826337b36 fix(dashboard): use st.cache_resource for server singleton
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 19s
CI / Release (push) Has been skipped
CI / Test (push) Successful in 54s
Replace module-level singleton with @st.cache_resource decorator.
This properly survives Streamlit reruns without losing the server
reference, preventing "port already in use" errors when refreshing
the browser in Docker.

The cache is tied to the Streamlit process lifecycle, so when the
process restarts, both the cache and daemon threads are cleared
together.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:44:37 +00:00
235d668d9f fix(dashboard): handle orphan server on Docker refresh
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 23s
CI / Test (push) Successful in 53s
CI / Release (push) Has been skipped
Check port availability before singleton state to detect orphan servers
from previous processes. When ports are in use but singleton is None,
wait up to 5 seconds for the orphan to shut down before failing with a
clear error message.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:38:31 +00:00
13f93b6739 fix(dashboard): improve server singleton robustness
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 56s
CI / Release (push) Has been skipped
- Add reuse_address=True to TCP server start to allow quick rebind
  after process restart (TIME_WAIT state)
- Add _is_server_responsive() check to verify server is actually
  responding, not just trusting the is_running flag which can be stale
  if the server thread died unexpectedly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:26:56 +00:00
bc15df3051 fix(dashboard): use module-level singleton to prevent port conflicts on refresh
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 20s
CI / Test (push) Successful in 56s
CI / Release (push) Has been skipped
When Streamlit refreshes/reruns, session state is lost but the old
simulation server thread keeps running on ports 5001-5003. This caused
"address already in use" errors when trying to start a new server.

Solution: Use a module-level singleton for the simulation server that
persists across Streamlit reruns. The get_or_create_server() function
checks if a server is already running before creating a new one.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:21:35 +00:00
66cdd4494c fix(docker): include README.md for pyproject.toml metadata
All checks were successful
CI / Test (push) Successful in 53s
CI / Release (push) Has been skipped
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 19s
2026-01-29 22:38:52 +00:00
d1981a3342 fix(docker): use isolated public-network for security
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 54s
CI / Release (push) Has been skipped
2026-01-29 22:31:26 +00:00
45a2d9a6e5 fix(docker): use external apps-network for NPM integration
All checks were successful
CI / Test (push) Successful in 55s
CI / Release (push) Has been skipped
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 18s
2026-01-29 22:27:12 +00:00
51a479c61e fix(docker): use host nginx, fix libgdk-pixbuf package name
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 52s
CI / Release (push) Has been skipped
- Update libgdk-pixbuf2.0-0 to libgdk-pixbuf-2.0-0 for Debian Trixie
- Remove bundled nginx container in favor of host nginx
- Use host networking for cloudflared to reach host nginx
- Expose streamlit on localhost:8080 for host nginx proxy
2026-01-29 22:24:02 +00:00
a6ef649090 Fix CI: install reports dependencies for tests
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 1m8s
CI / Release (push) Has been skipped
2026-01-29 22:05:11 +00:00
45a8f11650 Fix linter errors in CLI and tests
Some checks failed
CI / Lint (push) Successful in 6s
CI / Type Check (push) Successful in 20s
CI / Test (push) Failing after 37s
CI / Release (push) Has been skipped
2026-01-29 22:00:59 +00:00
7e8943ac57 Update deployment: remove Fly.io, add self-hosted Docker setup
Some checks failed
CI / Lint (push) Failing after 4s
CI / Type Check (push) Successful in 30s
CI / Release (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-01-29 21:57:27 +00:00
ddf2c9439d Simplify deployment - use physics pause instead of container shutdown
- IDLE_PAUSE_SECONDS replaces IDLE_TIMEOUT_MINUTES
- Container stays running, physics pauses when idle
- No restart mechanism needed
- Remove wakeup service (no longer needed)
2026-01-29 21:37:18 +00:00
9d6086a4e5 Auto-pause physics engine when no one is viewing
- Physics pauses after IDLE_PAUSE_SECONDS (default 30s) of inactivity
- Resumes instantly when someone views the dashboard
- No container restart needed - just pauses the simulation loop
- CPU usage drops to ~0% when paused
2026-01-29 21:36:38 +00:00
cc5a8191b0 Add idle auto-shutdown configuration to deployment
- IDLE_TIMEOUT_MINUTES env var (default 30 min)
- restart: no policy so container stays stopped
- Optional wakeup service for auto-restart
- Document three restart options in readme
2026-01-29 21:09:53 +00:00
b7663d5a31 Add idle auto-shutdown for self-hosted deployment
- IDLE_TIMEOUT_MINUTES env var to configure shutdown after inactivity
- Background thread monitors activity and exits when timeout reached
- Activity tracked via simulation_display fragment (runs while page open)
- Set to 0 (default) to disable auto-shutdown
2026-01-29 21:08:17 +00:00
6830b3158c Fix mypy type errors in reporting module 2026-01-29 18:06:13 +00:00
c016320b71 Update CHANGELOG with report generation feature 2026-01-29 18:03:39 +00:00
a5a2cf2473 Add report generation integration test 2026-01-29 18:03:23 +00:00
13a4fd16b3 Add reporting unit tests 2026-01-29 18:02:47 +00:00
349663b4e1 Add PDF download button to dashboard 2026-01-29 18:01:22 +00:00
2b92865745 Add export-report CLI command 2026-01-29 18:01:01 +00:00
022223af76 Add list-runs CLI command 2026-01-29 18:00:37 +00:00
bff13cd616 Add reporting configuration to default.yaml 2026-01-29 18:00:19 +00:00
59a5bc1124 Add ReportingConfig to application config 2026-01-29 18:00:04 +00:00
32daff69be Update reporting module public API 2026-01-29 17:59:45 +00:00
d76e610070 Implement ReportGenerator class 2026-01-29 17:59:27 +00:00
50432eaa3d Implement matplotlib chart generator 2026-01-29 17:59:01 +00:00
3b136dca69 Implement PDF renderer with WeasyPrint 2026-01-29 17:58:23 +00:00
5405ceec7f Implement HTML renderer with Jinja2 2026-01-29 17:58:02 +00:00
01d8295512 Add test report HTML template 2026-01-29 17:57:36 +00:00
3a8e6becf1 Add base HTML report template 2026-01-29 17:57:15 +00:00
af3116a025 Add professional CSS stylesheet for reports 2026-01-29 17:57:04 +00:00
f7f2839e65 Add reporting exception classes 2026-01-29 17:56:29 +00:00
5fdb1e6eaf Add report data models 2026-01-29 17:56:15 +00:00
ca7655704e Add matplotlib to reports dependencies 2026-01-29 17:55:55 +00:00
33 changed files with 3422 additions and 23 deletions

50
.dockerignore Normal file
View File

@@ -0,0 +1,50 @@
# Git
.git
.gitignore
.gitea
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info
.eggs
dist
build
.pytest_cache
.mypy_cache
.ruff_cache
.coverage
htmlcov
# Tests
tests/
pytest.ini
# Documentation
docs/
*.md
!README.md
# Development
.vscode
.idea
*.swp
*.swo
*~
# Environment
.env
.env.*
venv
.venv
# Data (will be created fresh in container)
data/
*.db
*.parquet
# CI/CD
.gitea/

View File

@@ -57,10 +57,15 @@ jobs:
with:
python-version: '3.11'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install -e ".[dev,reports]"
- name: Run pytest
run: pytest --cov=src/py_dvt_ate --cov-report=xml

1
.gitignore vendored
View File

@@ -56,3 +56,4 @@ logs/
# OS
.DS_Store
Thumbs.db
CLAUDE.md

View File

@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Docker deployment configuration for public demo
- Dockerfile with Python 3.11-slim and WeasyPrint dependencies
- .dockerignore to exclude tests, docs, and development files
- deploy/ directory with docker-compose, nginx config, and Cloudflare Tunnel setup
- PDF Report Generation (Sprint 18)
- Professional PDF reports from test results with charts and styling
- `ReportGenerator` class orchestrating data gathering, chart generation, and PDF output
- `ReportConfig` for customising company name, logo, charts, and DPI
- HTML templates with Jinja2 for report structure
- CSS stylesheet optimised for A4 PDF output via WeasyPrint
- Matplotlib-based chart generation (voltage vs temperature, results summary)
- New CLI commands:
- `list-runs`: Display recent test runs with IDs
- `export-report`: Generate PDF report from run ID
- Dashboard PDF download button in Results Viewer
- Reporting configuration section in default.yaml
- Unit tests for models, HTML renderer, and chart generator
- Integration test for full report generation pipeline
### Changed
- Added `matplotlib>=3.8` to reports optional dependencies
## [0.1.0] - 2025-12-04
### Added

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for WeasyPrint (PDF generation)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
# Copy project files
COPY pyproject.toml README.md ./
COPY src/ src/
COPY config/ config/
# Install package with reports dependencies (for PDF export)
RUN pip install --no-cache-dir -e ".[reports]"
# Create data directory for SQLite and measurements
RUN mkdir -p /app/data/measurements /app/data/reports
# Streamlit configuration
RUN mkdir -p ~/.streamlit
RUN echo '[server]\n\
headless = true\n\
address = "0.0.0.0"\n\
port = 8080\n\
enableXsrfProtection = false\n\
enableCORS = false\n\
\n\
[browser]\n\
gatherUsageStats = false\n' > ~/.streamlit/config.toml
EXPOSE 8080
CMD ["streamlit", "run", "src/py_dvt_ate/app/dashboard/app.py"]

View File

@@ -147,3 +147,26 @@ api:
# API server port
port: 8000
# =============================================================================
# Report Generation Configuration
# =============================================================================
reporting:
# Company name displayed in report header
# This appears in the title block and footer of generated reports
company_name: py_dvt_ate
# Path to company logo image file (PNG or JPEG)
# If null, no logo is displayed in report header
# Example: ./assets/logo.png
logo_path: null
# Include charts in generated reports
# Charts show voltage vs temperature and results summary
# Set to false for text-only reports (smaller file size)
include_charts: true
# DPI (dots per inch) for chart images
# Higher values produce sharper charts but larger file sizes
# Recommended: 150 for screen viewing, 300 for print quality
chart_dpi: 150

8
deploy/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Cloudflare Tunnel token
# Get this from: Cloudflare Zero Trust > Networks > Tunnels > Create > Docker
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token-here
# Idle auto-pause (seconds)
# Physics engine pauses after this many seconds with no viewers
# CPU drops to ~0% when paused, resumes instantly on visit
IDLE_PAUSE_SECONDS=30

19
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
streamlit:
build:
context: ..
dockerfile: Dockerfile
container_name: py-dvt-ate-streamlit
restart: unless-stopped
environment:
- IDLE_PAUSE_SECONDS=${IDLE_PAUSE_SECONDS:-30} # Pause physics after 30s idle
expose:
- "8080"
volumes:
- ./data:/app/data
networks:
- public-network
networks:
public-network:
external: true

45
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,45 @@
events {
worker_connections 1024;
}
http {
upstream streamlit {
server streamlit:8080;
}
server {
listen 80;
server_name _;
# Remove X-Frame-Options to allow iframe embedding
proxy_hide_header X-Frame-Options;
# Allow embedding from your domain
add_header Content-Security-Policy "frame-ancestors 'self' https://kschappell.com https://*.kschappell.com";
location / {
proxy_pass http://streamlit;
proxy_http_version 1.1;
# WebSocket support (required for Streamlit)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts for long-running connections
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# Health check endpoint
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
}
}

169
deploy/readme.md Normal file
View File

@@ -0,0 +1,169 @@
# py-dvt-ate Deployment
Deploy the DVT Simulation Platform dashboard with Cloudflare Tunnel for public access.
## Idle Auto-Pause
The physics simulation automatically pauses when no one is viewing the dashboard.
**Configuration:**
```bash
# In .env or docker-compose.yml
IDLE_PAUSE_SECONDS=30 # Pause physics after 30s idle
```
**Behaviour:**
- When someone views the dashboard, physics runs normally
- After `IDLE_PAUSE_SECONDS` with no viewers, physics engine pauses
- CPU drops to ~0% while paused
- Physics resumes instantly when someone visits
- Container stays running (no restart needed)
## Architecture
```
Internet
Cloudflare Edge (dvt-demo.kschappell.com)
↓ (tunnel)
cloudflared container
nginx container (WebSocket proxy + header handling)
streamlit container (port 8080)
```
## Directory Structure
```
/mnt/fast-pool/apps/portfolio-demos/py-dvt-ate/
├── docker-compose.yml
├── nginx.conf
├── .env
└── data/ # Persistent storage (created automatically)
├── py_dvt_ate.db # SQLite database
├── measurements/ # Test measurement files
└── reports/ # Generated PDFs
```
## Setup
### 1. Create Cloudflare Tunnel
1. Go to [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/)
2. Navigate to **Networks****Tunnels**
3. Click **Create a tunnel**
4. Select **Cloudflared** as the connector
5. Name it `py-dvt-ate` (or similar)
6. Copy the tunnel token (long string starting with `eyJ...`)
### 2. Configure Public Hostname
Still in the tunnel configuration:
1. Go to the **Public Hostname** tab
2. Add a public hostname:
- **Subdomain:** `dvt-demo`
- **Domain:** `kschappell.com`
- **Service Type:** `HTTP`
- **URL:** `nginx:80`
This routes `dvt-demo.kschappell.com` → nginx container → Streamlit app.
### 3. Deploy
The build requires access to the full py-dvt-ate source code. Two options:
**Option A: Clone repo to TrueNAS (recommended)**
```bash
# Clone repo to apps directory
cd /mnt/fast-pool/apps/portfolio-demos
git clone https://gitea.kschappell.com/kschappell/py-dvt-ate.git
# Create data directory and .env
cd py-dvt-ate/deploy
mkdir -p data
cp .env.example .env
nano .env # Add your CLOUDFLARE_TUNNEL_TOKEN
# Build and start
docker compose up -d --build
```
**Option B: Build image locally, transfer to TrueNAS**
```bash
# On development machine
cd /path/to/py-dvt-ate
docker build -t py-dvt-ate:latest .
docker save py-dvt-ate:latest | gzip > py-dvt-ate.tar.gz
# Transfer to TrueNAS, then:
docker load < py-dvt-ate.tar.gz
# Update docker-compose.yml to use image instead of build:
# streamlit:
# image: py-dvt-ate:latest
```
**Check logs:**
```bash
docker compose logs -f
```
### 4. Verify
```bash
# Check tunnel is connected (in Cloudflare dashboard, tunnel should show "Healthy")
# Test the endpoint
curl https://dvt-demo.kschappell.com/health
# Test iframe headers
curl -I https://dvt-demo.kschappell.com | grep -i frame
# Should NOT show X-Frame-Options (we strip it)
```
## Troubleshooting
### Tunnel not connecting
Check cloudflared logs:
```bash
docker compose logs cloudflared
```
Common issues:
- Invalid token (regenerate in Cloudflare dashboard)
- Network/firewall blocking outbound connections
### Streamlit not loading in iframe
Check nginx is stripping headers:
```bash
curl -I https://dvt-demo.kschappell.com
```
Should see:
- No `X-Frame-Options` header
- `Content-Security-Policy: frame-ancestors 'self' https://kschappell.com ...`
### WebSocket errors
Check browser console for WebSocket connection failures. Ensure nginx WebSocket config is correct and timeouts are sufficient.
## Updating
```bash
cd /mnt/fast-pool/apps/portfolio-demos/py-dvt-ate/deploy
git pull
docker compose up -d --build
```
## Stopping
```bash
docker compose down
```

View File

@@ -0,0 +1,460 @@
# Sprint 18: PDF Report Generation
| Document ID | DEV-002 |
|-------------|---------|
| Version | 1.0.0 |
| Status | Draft |
| Author | Kai Chappell |
| Created | 2026-01-29 |
| Last Updated | 2026-01-29 |
---
## Purpose
This document defines **Sprint 18** of py-dvt-ate development: automated PDF report generation from test results. Reports are designed to be professional and well-presented for recruiters/clients evaluating the simulation platform.
For project context, see:
- `01_requirements.md` - What the system must do
- `02_technical_specification.md` - How to implement
- `03_architecture_decisions.md` - Why decisions were made
- `04_development_plan.md` - Phase 1 MVP sprints (1-17)
---
## Feature Overview
Add automated PDF report generation with:
- Professional, well-presented layout suitable for external stakeholders
- Clean UX with easy download from CLI and dashboard
- Test metadata, results table with pass/fail status, and measurement charts
- Configurable company branding
---
## Design Principles
Following existing project patterns:
1. **Small, Focused Commits** - Each task = 1 commit, ~50-150 lines changed
2. **Stubs First** - Define interfaces/types before implementation
3. **Test Alongside** - Write tests immediately after implementation
4. **UK English** - characterisation, behaviour, colour
5. **Minimal Context** - Each task completable with knowledge of 1-3 files
---
## Task Breakdown
### Task 18.1: Add reporting dependencies to pyproject.toml
- Add `matplotlib>=3.8` to reports optional dependency group
- Verify jinja2 and weasyprint already present
- **Files:** `pyproject.toml`
- **Commit:** "Add matplotlib to reports dependencies"
---
### Task 18.2: Create report data models
- Create `src/py_dvt_ate/reporting/models.py`
- Define `ReportConfig` dataclass:
- `company_name: str` - Company name for header
- `logo_path: Path | None` - Optional logo image path
- `include_charts: bool` - Whether to include charts
- `chart_dpi: int` - Chart resolution
- Define `ReportData` dataclass:
- `run: TestRun` - Test run metadata
- `results: list[TestResult]` - Scalar results with limits
- `measurements: pd.DataFrame | None` - Time-series data
- `charts: dict[str, str]` - Chart name to base64 PNG
- **Files:** `src/py_dvt_ate/reporting/models.py`
- **Commit:** "Add report data models"
---
### Task 18.3: Create reporting exceptions
- Create `src/py_dvt_ate/reporting/exceptions.py`
- Define exception hierarchy:
- `ReportingError` - Base exception
- `ReportGenerationError` - General generation failure
- `TemplateRenderError` - HTML rendering failure
- `PDFConversionError` - HTML to PDF conversion failure
- `ChartGenerationError` - Chart generation failure
- **Files:** `src/py_dvt_ate/reporting/exceptions.py`
- **Commit:** "Add reporting exception classes"
---
### Task 18.4: Create CSS stylesheet for reports
- Create `src/py_dvt_ate/reporting/templates/styles.css`
- Professional styling:
- A4 page setup with margins
- Header with company branding
- Footer with page numbers
- Data tables with borders
- Status badges (pass=green, fail=red, info=blue)
- Summary cards with colour coding
- Chart containers
- Print-optimised with page breaks
- **Files:** `src/py_dvt_ate/reporting/templates/styles.css`
- **Commit:** "Add professional CSS stylesheet for reports"
---
### Task 18.5: Create base HTML template
- Create `src/py_dvt_ate/reporting/templates/base.html`
- Jinja2 base template with:
- `<head>` with CSS include
- Header block with company name, logo, report metadata
- Content block (for child templates)
- Footer block with confidentiality notice, page numbers
- WeasyPrint `@page` rules for PDF pagination
- **Files:** `src/py_dvt_ate/reporting/templates/base.html`
- **Commit:** "Add base HTML report template"
---
### Task 18.6: Create test report template
- Create `src/py_dvt_ate/reporting/templates/test_report.html`
- Extends `base.html` with sections:
- **Test Overview**: name, description, status, timestamps, duration, operator
- **Results Summary**: total/pass/fail cards with counts
- **Results Table**: parameter, value, unit, limits, pass/fail badge
- **Charts**: voltage vs temperature (if available)
- **Configuration**: test config JSON (optional)
- Jinja2 filters for formatting (floats, dates)
- **Files:** `src/py_dvt_ate/reporting/templates/test_report.html`
- **Commit:** "Add test report HTML template"
---
### Task 18.7: Implement HTML renderer
- Create `src/py_dvt_ate/reporting/renderers/__init__.py`
- Create `src/py_dvt_ate/reporting/renderers/html.py`
- `HTMLRenderer` class:
- Constructor takes `ReportConfig`
- Uses `jinja2.Environment` with `PackageLoader`
- `render(report_data: ReportData) -> str` method
- Custom filters for number formatting
- Template loading from `py_dvt_ate.reporting.templates` package
- **Files:** `src/py_dvt_ate/reporting/renderers/html.py`, `src/py_dvt_ate/reporting/renderers/__init__.py`
- **Commit:** "Implement HTML renderer with Jinja2"
---
### Task 18.8: Implement PDF renderer
- Create `src/py_dvt_ate/reporting/renderers/pdf.py`
- `PDFRenderer` class:
- `render_to_file(html: str, output_path: Path) -> None`
- `render_to_bytes(html: str) -> bytes`
- Use WeasyPrint `HTML(string=html).write_pdf()`
- Handle WeasyPrint warnings gracefully
- **Files:** `src/py_dvt_ate/reporting/renderers/pdf.py`
- **Commit:** "Implement PDF renderer with WeasyPrint"
---
### Task 18.9: Implement chart generator
- Create `src/py_dvt_ate/reporting/charts/__init__.py`
- Create `src/py_dvt_ate/reporting/charts/matplotlib_charts.py`
- `ChartGenerator` class:
- Constructor takes `ReportConfig` (for DPI)
- `_setup_style()` - Configure matplotlib for professional appearance
- `generate_voltage_vs_temperature(measurements: DataFrame) -> str`
- Scatter plot with trend line
- Calculate and display slope (ppm/C)
- Return base64-encoded PNG
- `generate_all(run, results, measurements) -> dict[str, str]`
- Dispatch to appropriate chart methods based on test type
- Use `matplotlib.use('Agg')` for non-interactive backend
- **Files:** `src/py_dvt_ate/reporting/charts/matplotlib_charts.py`, `src/py_dvt_ate/reporting/charts/__init__.py`
- **Commit:** "Implement matplotlib chart generator"
---
### Task 18.10: Implement ReportGenerator class
- Create `src/py_dvt_ate/reporting/generator.py`
- `IReportGenerator` Protocol:
- `generate(run_id: UUID, output_path: Path | None) -> Path`
- `generate_bytes(run_id: UUID) -> bytes`
- `ReportGenerator` class:
- Constructor: `repository`, `config`, `output_dir`
- Private: `_html_renderer`, `_pdf_renderer`, `_chart_generator`
- `_gather_data(run_id: UUID) -> ReportData`
- Fetch run, results, measurements from repository
- Generate charts if measurements available
- `_generate_output_path(run: TestRun) -> Path`
- Format: `{test_name}_{run_id_short}_{timestamp}.pdf`
- Error handling with appropriate exception types
- **Files:** `src/py_dvt_ate/reporting/generator.py`
- **Commit:** "Implement ReportGenerator class"
---
### Task 18.11: Update reporting module exports
- Update `src/py_dvt_ate/reporting/__init__.py`
- Export public API:
- `ReportGenerator`, `IReportGenerator`
- `ReportConfig`, `ReportData`
- All exception classes
- Add module docstring with usage example
- Lazy imports to handle missing optional dependencies
- **Files:** `src/py_dvt_ate/reporting/__init__.py`
- **Commit:** "Update reporting module public API"
---
### Task 18.12: Add ReportingConfig to app config
- Update `src/py_dvt_ate/app/config.py`
- Add `ReportingConfig` Pydantic model:
- `company_name: str = "DVT Engineering"`
- `logo_path: str | None = None`
- `include_charts: bool = True`
- `chart_dpi: int = 150`
- Add `reporting: ReportingConfig` to `AppConfig`
- **Files:** `src/py_dvt_ate/app/config.py`
- **Commit:** "Add ReportingConfig to application config"
---
### Task 18.13: Add reporting section to default.yaml
- Update `config/default.yaml`
- Add `reporting:` section with all options
- Document each option with comments
- **Files:** `config/default.yaml`
- **Commit:** "Add reporting configuration to default.yaml"
---
### Task 18.14: Add list-runs CLI command
- Update `src/py_dvt_ate/app/cli.py`
- Add `list-runs` command:
- `--limit` option (default 10)
- `--config` option for config file
- Output format: `{id:8} {test_name:15} {status:8} {timestamp}`
- Load repository from config
- **Files:** `src/py_dvt_ate/app/cli.py`
- **Commit:** "Add list-runs CLI command"
---
### Task 18.15: Add export-report CLI command
- Update `src/py_dvt_ate/app/cli.py`
- Add `export-report` command:
- `run_id` argument (required)
- `--output` / `-o` option for output path
- `--company` option for company name override
- `--config` option for config file
- Support short (8-char) and full UUID lookup
- Display progress and result path
- **Files:** `src/py_dvt_ate/app/cli.py`
- **Commit:** "Add export-report CLI command"
---
### Task 18.16: Add PDF download to dashboard
- Update `src/py_dvt_ate/app/dashboard/app.py`
- In results viewer page, add:
- "Generate PDF Report" button (primary)
- `st.download_button` for PDF download
- Progress spinner during generation
- Error handling for missing dependencies
- Store generated PDF in `st.session_state`
- **Files:** `src/py_dvt_ate/app/dashboard/app.py`
- **Commit:** "Add PDF download button to dashboard"
---
### Task 18.17: Add reporting unit tests
- Create `tests/unit/reporting/__init__.py`
- Create `tests/unit/reporting/test_models.py`
- Test ReportConfig and ReportData creation
- Test default values
- Create `tests/unit/reporting/test_html_renderer.py`
- Test template rendering with mock data
- Test custom filters
- Create `tests/unit/reporting/test_chart_generator.py`
- Test chart generation produces valid base64
- Test with sample DataFrame
- **Files:** `tests/unit/reporting/`
- **Commit:** "Add reporting unit tests"
---
### Task 18.18: Add reporting integration test
- Create `tests/integration/test_report_generation.py`
- End-to-end test:
- Create test run with sample results in repository
- Generate PDF report
- Verify PDF file created and non-empty
- Optionally verify PDF structure (page count)
- Use pytest fixtures for repository setup
- **Files:** `tests/integration/test_report_generation.py`
- **Commit:** "Add report generation integration test"
---
### Task 18.19: Update CHANGELOG
- Update `CHANGELOG.md`
- Add `## [Unreleased]` section if not present
- Document:
- New `export-report` CLI command
- New `list-runs` CLI command
- Dashboard PDF download feature
- Reporting module with PDF/HTML generation
- **Files:** `CHANGELOG.md`
- **Commit:** "Update CHANGELOG with report generation feature"
---
## File Structure (New Files)
```
src/py_dvt_ate/reporting/
├── __init__.py # Task 18.11 - Public API
├── models.py # Task 18.2 - ReportConfig, ReportData
├── exceptions.py # Task 18.3 - Exception hierarchy
├── generator.py # Task 18.10 - ReportGenerator
├── renderers/
│ ├── __init__.py # Task 18.7
│ ├── html.py # Task 18.7 - HTMLRenderer
│ └── pdf.py # Task 18.8 - PDFRenderer
├── charts/
│ ├── __init__.py # Task 18.9
│ └── matplotlib_charts.py # Task 18.9 - ChartGenerator
└── templates/
├── styles.css # Task 18.4 - CSS
├── base.html # Task 18.5 - Base template
└── test_report.html # Task 18.6 - Report template
tests/unit/reporting/
├── __init__.py # Task 18.17
├── test_models.py # Task 18.17
├── test_html_renderer.py # Task 18.17
└── test_chart_generator.py # Task 18.17
tests/integration/
└── test_report_generation.py # Task 18.18
```
---
## Files to Modify
| File | Tasks | Changes |
|------|-------|---------|
| `pyproject.toml` | 18.1 | Add matplotlib to reports deps |
| `src/py_dvt_ate/app/config.py` | 18.12 | Add ReportingConfig |
| `config/default.yaml` | 18.13 | Add reporting section |
| `src/py_dvt_ate/app/cli.py` | 18.14, 18.15 | Add list-runs, export-report |
| `src/py_dvt_ate/app/dashboard/app.py` | 18.16 | Add PDF download |
| `CHANGELOG.md` | 18.19 | Document new features |
---
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| jinja2 | >=3.1 | HTML template rendering |
| weasyprint | >=60.0 | HTML to PDF conversion |
| matplotlib | >=3.8 | Chart generation |
All in `[project.optional-dependencies] reports` group.
Install with: `pip install py-dvt-ate[reports]`
---
## Verification
### CLI Verification
```bash
# List recent test runs
py-dvt-ate list-runs
# Generate PDF report
py-dvt-ate export-report <run_id>
# With options
py-dvt-ate export-report <run_id> -o ./my_report.pdf --company "Acme Corp"
# View generated PDF
xdg-open ./data/reports/*.pdf
```
### Dashboard Verification
```bash
# Start dashboard
streamlit run src/py_dvt_ate/app/dashboard/app.py
# In browser:
# 1. Navigate to Results Viewer
# 2. Select a test run
# 3. Click "Generate PDF Report"
# 4. Click "Download PDF Report"
# 5. Open downloaded PDF
```
### Test Verification
```bash
# Run unit tests
pytest tests/unit/reporting/ -v
# Run integration test
pytest tests/integration/test_report_generation.py -v
# Check coverage
pytest tests/unit/reporting/ --cov=py_dvt_ate.reporting --cov-report=term-missing
```
---
## Task Progress
| Task | Status | Description |
|------|--------|-------------|
| 18.1 | pending | Add matplotlib dependency |
| 18.2 | pending | Report data models |
| 18.3 | pending | Reporting exceptions |
| 18.4 | pending | CSS stylesheet |
| 18.5 | pending | Base HTML template |
| 18.6 | pending | Test report template |
| 18.7 | pending | HTML renderer |
| 18.8 | pending | PDF renderer |
| 18.9 | pending | Chart generator |
| 18.10 | pending | ReportGenerator class |
| 18.11 | pending | Module exports |
| 18.12 | pending | App config update |
| 18.13 | pending | default.yaml update |
| 18.14 | pending | list-runs CLI |
| 18.15 | pending | export-report CLI |
| 18.16 | pending | Dashboard download |
| 18.17 | pending | Unit tests |
| 18.18 | pending | Integration test |
| 18.19 | pending | CHANGELOG update |
---
**End of Sprint 18 Plan**

View File

@@ -30,6 +30,7 @@ api = [
reports = [
"jinja2>=3.1",
"weasyprint>=60.0",
"matplotlib>=3.8",
]
dev = [
"pytest>=7.0",
@@ -83,6 +84,8 @@ disallow_incomplete_defs = true
module = [
"streamlit.*",
"plotly.*",
"weasyprint.*",
"matplotlib.*",
]
ignore_missing_imports = true

View File

@@ -125,6 +125,210 @@ def run_test_cmd(
)
@app.command(name="list-runs")
def list_runs_cmd(
config_file: Annotated[
str | None,
typer.Option("--config", "-c", help="Path to configuration YAML file."),
] = None,
limit: Annotated[
int,
typer.Option("--limit", "-n", help="Maximum number of runs to display."),
] = 20,
) -> None:
"""List recent test runs with their IDs.
Shows a table of recent test runs including the short ID (for use with
export-report), test name, status, and timestamp.
"""
from pathlib import Path
from rich.console import Console
from rich.table import Table
from py_dvt_ate.app.config import load_config
from py_dvt_ate.data.repository import SQLiteRepository
console = Console()
# Load config
if config_file is None:
config_path = Path("config/default.yaml")
if config_path.exists():
config_file = str(config_path)
config = load_config(config_file)
# Create repository
repo = SQLiteRepository(
db_path=config.data.database_path,
measurements_dir=config.data.measurements_dir,
)
try:
runs = repo.get_all_runs()
if not runs:
console.print("[yellow]No test runs found.[/yellow]")
return
# Limit results
runs = runs[:limit]
# Create table
table = Table(title="Recent Test Runs")
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Test Name", style="white")
table.add_column("Status", style="white")
table.add_column("Started", style="dim")
for run in runs:
# Format status with colour
status = run.status.value.upper()
if status == "PASSED":
status_styled = f"[green]{status}[/green]"
elif status == "FAILED":
status_styled = f"[red]{status}[/red]"
elif status == "ERROR":
status_styled = f"[yellow]{status}[/yellow]"
else:
status_styled = status
table.add_row(
run.id[:8],
run.test_name,
status_styled,
run.started_at.strftime("%Y-%m-%d %H:%M:%S"),
)
console.print(table)
console.print(f"\n[dim]Showing {len(runs)} of {len(repo.get_all_runs())} runs[/dim]")
finally:
repo.close()
@app.command(name="export-report")
def export_report_cmd(
run_id: Annotated[
str,
typer.Argument(help="Test run ID (short 8-char or full UUID)."),
],
output: Annotated[
str | None,
typer.Option("--output", "-o", help="Output PDF file path."),
] = None,
company: Annotated[
str | None,
typer.Option("--company", help="Company name for report header."),
] = None,
config_file: Annotated[
str | None,
typer.Option("--config", "-c", help="Path to configuration YAML file."),
] = None,
) -> None:
"""Export a PDF report for a test run.
Generate a professional PDF report from test results. The run_id can be
the short 8-character ID shown by list-runs, or the full UUID.
Examples:
py-dvt-ate export-report abc12345
py-dvt-ate export-report abc12345 --output ./my_report.pdf
py-dvt-ate export-report abc12345 --company "My Company"
"""
from pathlib import Path
from uuid import UUID
from rich.console import Console
from py_dvt_ate.app.config import load_config
from py_dvt_ate.data.repository import SQLiteRepository
console = Console()
# Check for reporting dependencies
try:
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
except ImportError:
console.print(
"[red]Error:[/red] Report generation requires additional dependencies.\n"
"Install with: [cyan]pip install py_dvt_ate[reports][/cyan]"
)
raise typer.Exit(1) from None
# Load config
if config_file is None:
config_path = Path("config/default.yaml")
if config_path.exists():
config_file = str(config_path)
config = load_config(config_file)
# Create repository
repo = SQLiteRepository(
db_path=config.data.database_path,
measurements_dir=config.data.measurements_dir,
)
try:
# Resolve short ID to full UUID
full_run_id: UUID | None = None
if len(run_id) == 8:
# Short ID - need to find full UUID
all_runs = repo.get_all_runs()
matching_runs = [r for r in all_runs if r.id.startswith(run_id)]
if not matching_runs:
console.print(f"[red]Error:[/red] No test run found with ID starting with '{run_id}'")
raise typer.Exit(1)
elif len(matching_runs) > 1:
console.print(f"[red]Error:[/red] Multiple runs match '{run_id}'. Use full UUID.")
for run in matching_runs:
console.print(f" - {run.id} ({run.test_name})")
raise typer.Exit(1)
full_run_id = UUID(matching_runs[0].id)
else:
try:
full_run_id = UUID(run_id)
except ValueError:
console.print(f"[red]Error:[/red] Invalid run ID: '{run_id}'")
raise typer.Exit(1) from None
# Create report config
report_config = ReportConfig(
company_name=company or config.reporting.company_name,
logo_path=Path(config.reporting.logo_path) if config.reporting.logo_path else None,
include_charts=config.reporting.include_charts,
chart_dpi=config.reporting.chart_dpi,
)
# Create generator
generator = ReportGenerator(
repository=repo,
config=report_config,
reports_dir=Path(config.data.reports_dir),
)
# Generate report
console.print(f"[cyan]Generating report for run {str(full_run_id)[:8]}...[/cyan]")
output_path = Path(output) if output else None
pdf_path = generator.generate(full_run_id, output_path)
console.print(f"[green]Report saved to:[/green] {pdf_path}")
except typer.Exit:
raise
except Exception as e:
console.print(f"[red]Error generating report:[/red] {e}")
raise typer.Exit(1) from None
finally:
repo.close()
@app.command(name="query")
def query_cmd(
instrument: Annotated[

View File

@@ -110,6 +110,15 @@ class APIConfig(BaseModel):
port: int = 8000
class ReportingConfig(BaseModel):
"""PDF report generation configuration."""
company_name: str = "py_dvt_ate"
logo_path: str | None = None
include_charts: bool = True
chart_dpi: int = 150
class AppConfig(BaseModel):
"""Root configuration model."""
@@ -120,6 +129,7 @@ class AppConfig(BaseModel):
logging: LoggingConfig = Field(default_factory=LoggingConfig)
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
api: APIConfig = Field(default_factory=APIConfig)
reporting: ReportingConfig = Field(default_factory=ReportingConfig)
def _apply_env_overrides(config_dict: dict[str, Any]) -> None:

View File

@@ -6,7 +6,7 @@ thermal-electrical coupling in real-time using instrument interfaces.
"""
import asyncio
import atexit
import os
import threading
import time
from collections import deque
@@ -27,6 +27,66 @@ from py_dvt_ate.tests.thermal.tempco import TempCoTest
# Thread pool for background test execution
_test_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test_runner")
# Idle shutdown configuration
# Server stops after IDLE_SHUTDOWN_SECONDS of no activity (default: 5 minutes)
# Next visitor gets a fresh simulation instance
IDLE_SHUTDOWN_SECONDS = int(os.environ.get("IDLE_SHUTDOWN_SECONDS", "300"))
_last_activity_time: float = time.time()
_idle_checker_started = False
_server_ref: SimulationServer | None = None # Reference for idle checker thread
_server_loop: asyncio.AbstractEventLoop | None = None # Event loop running the server
_server_thread: threading.Thread | None = None # Thread running the server event loop
def _idle_checker() -> None:
"""Background thread that stops server when idle."""
global _last_activity_time, _server_ref
while True:
time.sleep(10) # Check every 10 seconds
if _server_ref is None:
continue
idle_seconds = time.time() - _last_activity_time
if idle_seconds > IDLE_SHUTDOWN_SECONDS:
print(f"Stopping server (idle for {idle_seconds:.0f}s)")
_stop_server()
break # Exit checker thread - new one starts with new server
def _stop_server() -> None:
"""Stop the server and clear caches for fresh restart."""
global _server_ref, _idle_checker_started, _server_loop, _server_thread
if _server_loop is not None and _server_thread is not None:
# Schedule stop on the correct event loop (the one actually running the server)
# This causes loop.run_forever() to exit in the daemon thread
_server_loop.call_soon_threadsafe(_server_loop.stop)
# Wait for thread to exit (with timeout to avoid hanging)
_server_thread.join(timeout=5.0)
_server_loop = None
_server_thread = None
_server_ref = None
# Clear Streamlit's cached server so next visitor gets fresh instance
get_or_create_server.clear()
# Reset idle checker flag so new one can start
_idle_checker_started = False
def _start_idle_checker(server: SimulationServer) -> None:
"""Start the idle checker thread."""
global _idle_checker_started, _server_ref
_server_ref = server
if not _idle_checker_started:
_idle_checker_started = True
thread = threading.Thread(target=_idle_checker, daemon=True)
thread.start()
# History buffer size for charts
HISTORY_SIZE = 500
@@ -69,12 +129,20 @@ class TestProgress:
return time.time() - self.started_at
def start_embedded_server() -> tuple[SimulationServer, threading.Thread]:
"""Start an embedded simulation server in a background thread.
@st.cache_resource
def get_or_create_server() -> SimulationServer:
"""Get or create the simulation server singleton.
Uses st.cache_resource to ensure only one server instance exists
across all Streamlit reruns, preventing "address already in use" errors.
The cache survives page refreshes and is only invalidated when the
Streamlit process restarts.
Returns:
Tuple of (server instance, thread running the server).
The simulation server instance.
"""
global _server_loop, _server_thread
server = SimulationServer(
ServerConfig(
host="127.0.0.1",
@@ -90,7 +158,9 @@ def start_embedded_server() -> tuple[SimulationServer, threading.Thread]:
def run_server() -> None:
"""Run the async server in a new event loop."""
global _server_loop
loop = asyncio.new_event_loop()
_server_loop = loop # Store reference for _stop_server to use
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(server.start())
@@ -110,39 +180,52 @@ def start_embedded_server() -> tuple[SimulationServer, threading.Thread]:
thread = threading.Thread(target=run_server, daemon=True)
thread.start()
_server_thread = thread # Store reference for _stop_server to use
# Wait for server to be fully started (up to 5 seconds)
if not server_ready.wait(timeout=5.0):
st.error("Server failed to start within timeout")
raise RuntimeError("Server failed to start within timeout")
# Check if there was an error during startup
if server_error:
st.error(f"Server startup error: {server_error[0]}")
raise server_error[0]
return server, thread
return server
def _update_activity() -> None:
"""Update activity timestamp to prevent idle shutdown."""
global _last_activity_time
_last_activity_time = time.time()
def init_session_state() -> None:
"""Initialise Streamlit session state."""
if "server" not in st.session_state:
# Check if existing server was stopped by idle checker
if "server" in st.session_state and st.session_state.server is not None:
if not st.session_state.server.is_running:
# Server was stopped - clear stale state for fresh start
st.session_state.server = None
st.session_state.pop("instruments", None)
st.session_state.pop("history", None)
# Get or create the server singleton (survives Streamlit reruns via st.cache_resource)
if "server" not in st.session_state or st.session_state.server is None:
with st.spinner("Starting simulation server..."):
st.session_state.server, st.session_state.server_thread = start_embedded_server()
try:
server = get_or_create_server()
st.session_state.server = server
except Exception as e:
st.error(f"Failed to start simulation server: {e}")
st.stop()
# Verify server started correctly
if st.session_state.server.physics_engine is None:
st.error("Failed to start simulation server. Please refresh the page.")
st.stop()
# Register cleanup
def cleanup() -> None:
if hasattr(st.session_state, "server") and st.session_state.server is not None:
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(st.session_state.server.stop())
except Exception:
pass
loop.close()
atexit.register(cleanup)
# Start idle checker to stop server when no one's viewing
_start_idle_checker(st.session_state.server)
if "instruments" not in st.session_state:
# Create instruments via HAL using factory
@@ -386,6 +469,8 @@ def display_controls() -> None:
@st.fragment(run_every=0.1)
def simulation_display() -> None:
"""Fragment that displays and updates simulation state."""
_update_activity() # Track activity and resume physics if paused
if "server" not in st.session_state:
st.warning("Initializing simulation server...")
return
@@ -876,6 +961,51 @@ def results_viewer_page() -> None:
if selected_run.description:
st.markdown(f"**Description:** {selected_run.description}")
# PDF Report Generation
st.markdown("### Export Report")
col_btn, col_status = st.columns([1, 3])
with col_btn:
generate_pdf = st.button("Generate PDF Report", type="primary")
if generate_pdf:
try:
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
report_config = ReportConfig(
company_name="py_dvt_ate",
include_charts=True,
chart_dpi=150,
)
generator = ReportGenerator(
repository=repository,
config=report_config,
)
with st.spinner("Generating PDF report..."):
pdf_bytes = generator.generate_bytes(UUID(selected_run.id))
st.session_state.pdf_bytes = pdf_bytes
st.session_state.pdf_filename = f"{selected_run.test_name}_{selected_run.id[:8]}.pdf"
st.success("PDF generated successfully!")
except ImportError:
st.error(
"PDF generation requires additional dependencies. "
"Install with: `pip install py_dvt_ate[reports]`"
)
except Exception as e:
st.error(f"Failed to generate PDF: {e}")
# Show download button if PDF was generated
if "pdf_bytes" in st.session_state and st.session_state.pdf_bytes:
st.download_button(
label="Download PDF",
data=st.session_state.pdf_bytes,
file_name=st.session_state.get("pdf_filename", "report.pdf"),
mime="application/pdf",
)
if selected_run.config_json:
import json
with st.expander("Test Configuration"):

View File

@@ -138,6 +138,7 @@ class InstrumentServer:
handler,
self._host,
port,
reuse_address=True,
)
self._servers.append(server)
logger.info(

View File

@@ -1,5 +1,58 @@
"""Report generation.
"""Report generation module for py_dvt_ate.
Generates test reports from stored data in various formats
including PDF and HTML.
This module provides automated PDF report generation from test results.
Reports include test metadata, results tables, pass/fail status, and charts.
Example usage:
>>> from uuid import UUID
>>> from py_dvt_ate.data.repository import SQLiteRepository
>>> from py_dvt_ate.reporting import ReportGenerator, ReportConfig
>>>
>>> # Create repository and generator
>>> repo = SQLiteRepository("./data/py_dvt_ate.db")
>>> config = ReportConfig(company_name="My Company", include_charts=True)
>>> generator = ReportGenerator(repo, config)
>>>
>>> # Generate PDF report
>>> run_id = UUID("12345678-1234-1234-1234-123456789abc")
>>> pdf_path = generator.generate(run_id)
>>> print(f"Report saved to: {pdf_path}")
>>>
>>> # Or get PDF as bytes (for streaming downloads)
>>> pdf_bytes = generator.generate_bytes(run_id)
Classes:
ReportGenerator: Main class for generating PDF reports.
ReportConfig: Configuration options for report generation.
ReportData: Data container for report content.
Exceptions:
ReportingError: Base exception for reporting errors.
ReportGenerationError: General report generation failures.
TemplateRenderError: HTML template rendering failures.
PDFConversionError: HTML to PDF conversion failures.
ChartGenerationError: Chart generation failures.
"""
from py_dvt_ate.reporting.exceptions import (
ChartGenerationError,
PDFConversionError,
ReportGenerationError,
ReportingError,
TemplateRenderError,
)
from py_dvt_ate.reporting.generator import ReportGenerator
from py_dvt_ate.reporting.models import ReportConfig, ReportData
__all__ = [
# Main classes
"ReportGenerator",
"ReportConfig",
"ReportData",
# Exceptions
"ReportingError",
"ReportGenerationError",
"TemplateRenderError",
"PDFConversionError",
"ChartGenerationError",
]

View File

@@ -0,0 +1,5 @@
"""Chart generation for reports."""
from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator
__all__ = ["ChartGenerator"]

View File

@@ -0,0 +1,233 @@
"""Chart generation using matplotlib.
This module provides chart generation for test reports using matplotlib.
Charts are rendered to base64-encoded PNG images for embedding in HTML/PDF.
"""
import base64
from io import BytesIO
from typing import Any
import pandas as pd
from py_dvt_ate.data.models import TestResult, TestRun
from py_dvt_ate.reporting.exceptions import ChartGenerationError
class ChartGenerator:
"""Generates charts for test reports using matplotlib.
Charts are rendered with professional styling and returned as
base64-encoded PNG images suitable for embedding in HTML.
"""
def __init__(self, dpi: int = 150) -> None:
"""Initialise the chart generator.
Args:
dpi: Resolution for chart images (dots per inch).
"""
self.dpi = dpi
self._plt: tuple[Any, Any] | None = None
def _get_matplotlib(self) -> tuple[Any, Any]:
"""Lazy-load matplotlib to avoid import errors when not installed.
Returns:
Tuple of (pyplot module, matplotlib module).
Raises:
ChartGenerationError: If matplotlib is not installed.
"""
if self._plt is None:
try:
import matplotlib
matplotlib.use("Agg") # Non-interactive backend
import matplotlib.pyplot as plt
self._plt = (plt, matplotlib)
except ImportError as e:
msg = (
"matplotlib is required for chart generation. "
"Install it with: pip install py_dvt_ate[reports]"
)
raise ChartGenerationError(msg) from e
return self._plt
def _apply_style(self) -> None:
"""Apply professional styling to matplotlib charts."""
plt, _ = self._get_matplotlib()
plt.style.use("seaborn-v0_8-whitegrid")
plt.rcParams.update(
{
"font.family": "sans-serif",
"font.sans-serif": ["Helvetica", "Arial", "sans-serif"],
"font.size": 10,
"axes.titlesize": 12,
"axes.labelsize": 10,
"xtick.labelsize": 9,
"ytick.labelsize": 9,
"legend.fontsize": 9,
"figure.figsize": (8, 5),
"figure.dpi": self.dpi,
"axes.spines.top": False,
"axes.spines.right": False,
}
)
def _fig_to_base64(self, fig) -> str: # type: ignore[no-untyped-def]
"""Convert a matplotlib figure to base64-encoded PNG.
Args:
fig: Matplotlib figure object.
Returns:
Base64-encoded PNG image string.
"""
plt, _ = self._get_matplotlib()
buffer = BytesIO()
fig.savefig(buffer, format="png", dpi=self.dpi, bbox_inches="tight")
plt.close(fig)
buffer.seek(0)
return base64.b64encode(buffer.read()).decode("utf-8")
def generate_voltage_vs_temperature(
self, measurements: pd.DataFrame
) -> str:
"""Generate a voltage vs temperature chart.
Args:
measurements: DataFrame with 'temperature' and 'value' columns,
filtered to output voltage measurements.
Returns:
Base64-encoded PNG image string.
Raises:
ChartGenerationError: If chart generation fails.
"""
try:
plt, _ = self._get_matplotlib()
self._apply_style()
# Filter for output voltage measurements
voltage_data = measurements[
measurements["parameter"].str.contains("output_voltage", case=False)
].copy()
if voltage_data.empty:
msg = "No output voltage measurements found in data"
raise ChartGenerationError(msg)
# Group by temperature and get mean voltage at each point
grouped = voltage_data.groupby("temperature")["value"].mean().reset_index()
fig, ax = plt.subplots()
ax.plot(
grouped["temperature"],
grouped["value"],
marker="o",
linewidth=2,
markersize=6,
color="#2563eb",
)
ax.set_xlabel("Temperature (°C)")
ax.set_ylabel("Output Voltage (V)")
ax.set_title("Output Voltage vs Temperature")
ax.grid(True, alpha=0.3)
return self._fig_to_base64(fig)
except ChartGenerationError:
raise
except Exception as e:
msg = f"Failed to generate voltage vs temperature chart: {e}"
raise ChartGenerationError(msg) from e
def generate_results_bar_chart(self, results: list[TestResult]) -> str:
"""Generate a bar chart of test results.
Args:
results: List of test results.
Returns:
Base64-encoded PNG image string.
Raises:
ChartGenerationError: If chart generation fails.
"""
try:
plt, _ = self._get_matplotlib()
self._apply_style()
if not results:
msg = "No results to chart"
raise ChartGenerationError(msg)
# Prepare data
parameters = [r.parameter for r in results]
values = [r.value for r in results]
colours = ["#16a34a" if r.passed else "#dc2626" for r in results]
fig, ax = plt.subplots()
bars = ax.barh(parameters, values, color=colours)
# Add value labels
for bar, value in zip(bars, values, strict=False):
ax.text(
bar.get_width(),
bar.get_y() + bar.get_height() / 2,
f" {value:.4f}",
va="center",
fontsize=8,
)
ax.set_xlabel("Value")
ax.set_title("Test Results by Parameter")
ax.invert_yaxis()
return self._fig_to_base64(fig)
except ChartGenerationError:
raise
except Exception as e:
msg = f"Failed to generate results bar chart: {e}"
raise ChartGenerationError(msg) from e
def generate_all(
self,
run: TestRun,
results: list[TestResult],
measurements: pd.DataFrame | None,
) -> dict[str, str]:
"""Generate all applicable charts for a test run.
Args:
run: Test run metadata.
results: List of test results.
measurements: DataFrame of time-series measurements (optional).
Returns:
Dictionary mapping chart names to base64-encoded PNG images.
"""
charts: dict[str, str] = {}
# Try to generate voltage vs temperature chart if measurements available
if measurements is not None and not measurements.empty:
try:
charts["Voltage vs Temperature"] = self.generate_voltage_vs_temperature(
measurements
)
except ChartGenerationError:
pass # Skip if no voltage data
# Generate results bar chart if we have results
if results:
try:
charts["Results Summary"] = self.generate_results_bar_chart(results)
except ChartGenerationError:
pass # Skip if chart generation fails
return charts

View File

@@ -0,0 +1,41 @@
"""Exception classes for the reporting module.
This module defines a hierarchy of exceptions for report generation errors,
enabling specific error handling for different failure modes.
"""
class ReportingError(Exception):
"""Base exception for all reporting-related errors."""
class ReportGenerationError(ReportingError):
"""Raised when report generation fails.
This is the general error for failures during the report generation
process that don't fit into more specific categories.
"""
class TemplateRenderError(ReportingError):
"""Raised when HTML template rendering fails.
This typically indicates a problem with the Jinja2 template or
the data being passed to it.
"""
class PDFConversionError(ReportingError):
"""Raised when HTML to PDF conversion fails.
This typically indicates a problem with WeasyPrint or the generated
HTML/CSS being incompatible with PDF rendering.
"""
class ChartGenerationError(ReportingError):
"""Raised when chart generation fails.
This typically indicates a problem with matplotlib or the measurement
data being charted.
"""

View File

@@ -0,0 +1,199 @@
"""Report generator orchestrating the full report generation pipeline.
This module provides the main ReportGenerator class that coordinates
data gathering, chart generation, HTML rendering, and PDF conversion.
"""
from datetime import datetime
from pathlib import Path
from typing import Protocol
from uuid import UUID
from py_dvt_ate.data.repository import ITestRepository
from py_dvt_ate.reporting.charts import ChartGenerator
from py_dvt_ate.reporting.exceptions import ReportGenerationError
from py_dvt_ate.reporting.models import ReportConfig, ReportData
from py_dvt_ate.reporting.renderers import HTMLRenderer, PDFRenderer
class IReportGenerator(Protocol):
"""Protocol for report generators."""
def generate(self, run_id: UUID, output_path: Path | None = None) -> Path:
"""Generate a PDF report for a test run.
Args:
run_id: UUID of the test run.
output_path: Optional output path. If None, uses default location.
Returns:
Path to the generated PDF file.
"""
...
def generate_bytes(self, run_id: UUID) -> bytes:
"""Generate a PDF report and return as bytes.
Args:
run_id: UUID of the test run.
Returns:
PDF document as bytes.
"""
...
class ReportGenerator:
"""Generates PDF reports from test run data.
This class orchestrates the full report generation pipeline:
1. Fetch test run data from repository
2. Generate charts from measurements
3. Render HTML from templates
4. Convert HTML to PDF
Example:
>>> from py_dvt_ate.data.repository import SQLiteRepository
>>> from py_dvt_ate.reporting import ReportGenerator, ReportConfig
>>>
>>> repo = SQLiteRepository("./data/py_dvt_ate.db")
>>> config = ReportConfig(company_name="My Company")
>>> generator = ReportGenerator(repo, config)
>>> pdf_path = generator.generate(run_id)
"""
def __init__(
self,
repository: ITestRepository,
config: ReportConfig | None = None,
reports_dir: Path | None = None,
) -> None:
"""Initialise the report generator.
Args:
repository: Test data repository for fetching run data.
config: Report configuration. Uses defaults if not provided.
reports_dir: Directory for generated reports. Defaults to ./data/reports.
"""
self.repository = repository
self.config = config or ReportConfig()
self.reports_dir = reports_dir or Path("./data/reports")
self._html_renderer = HTMLRenderer()
self._pdf_renderer = PDFRenderer()
self._chart_generator = ChartGenerator(dpi=self.config.chart_dpi)
def _gather_data(self, run_id: UUID) -> ReportData:
"""Gather all data needed for the report.
Args:
run_id: UUID of the test run.
Returns:
ReportData containing run, results, measurements, and charts.
Raises:
ReportGenerationError: If data gathering fails.
"""
try:
run = self.repository.get_run(run_id)
results = self.repository.get_results(run_id)
measurements = self.repository.get_measurements_dataframe(run_id)
# Generate charts if enabled
charts: dict[str, str] = {}
if self.config.include_charts:
charts = self._chart_generator.generate_all(run, results, measurements)
return ReportData(
run=run,
results=results,
measurements=measurements,
charts=charts,
config=self.config,
)
except ValueError as e:
msg = f"Failed to gather data for run {run_id}: {e}"
raise ReportGenerationError(msg) from e
def _generate_filename(self, run_id: UUID, test_name: str) -> str:
"""Generate a filename for the report.
Args:
run_id: UUID of the test run.
test_name: Name of the test.
Returns:
Filename string with timestamp.
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = test_name.replace(" ", "_").replace("/", "_")
return f"{safe_name}_{str(run_id)[:8]}_{timestamp}.pdf"
def generate(self, run_id: UUID, output_path: Path | None = None) -> Path:
"""Generate a PDF report for a test run.
Args:
run_id: UUID of the test run.
output_path: Optional output path. If None, uses default location.
Returns:
Path to the generated PDF file.
Raises:
ReportGenerationError: If report generation fails.
"""
try:
# Gather data
data = self._gather_data(run_id)
# Determine output path
if output_path is None:
self.reports_dir.mkdir(parents=True, exist_ok=True)
filename = self._generate_filename(run_id, data.run.test_name)
output_path = self.reports_dir / filename
# Render HTML
html = self._html_renderer.render(data)
# Convert to PDF
self._pdf_renderer.render_to_file(html, output_path)
return output_path
except ReportGenerationError:
raise
except Exception as e:
msg = f"Failed to generate report for run {run_id}: {e}"
raise ReportGenerationError(msg) from e
def generate_bytes(self, run_id: UUID) -> bytes:
"""Generate a PDF report and return as bytes.
Useful for streaming downloads without writing to disk.
Args:
run_id: UUID of the test run.
Returns:
PDF document as bytes.
Raises:
ReportGenerationError: If report generation fails.
"""
try:
# Gather data
data = self._gather_data(run_id)
# Render HTML
html = self._html_renderer.render(data)
# Convert to PDF bytes
return self._pdf_renderer.render_to_bytes(html)
except ReportGenerationError:
raise
except Exception as e:
msg = f"Failed to generate report bytes for run {run_id}: {e}"
raise ReportGenerationError(msg) from e

View File

@@ -0,0 +1,70 @@
"""Data models for report generation.
This module defines dataclasses for report configuration and data structures
used throughout the reporting pipeline.
"""
from dataclasses import dataclass, field
from pathlib import Path
import pandas as pd
from py_dvt_ate.data.models import TestResult, TestRun
@dataclass
class ReportConfig:
"""Configuration for report generation.
Attributes:
company_name: Company name to display in report header.
logo_path: Path to company logo image file (optional).
include_charts: Whether to include charts in the report.
chart_dpi: DPI for chart images (higher = better quality but larger file).
"""
company_name: str = "py_dvt_ate"
logo_path: Path | None = None
include_charts: bool = True
chart_dpi: int = 150
@dataclass
class ReportData:
"""Data container for report generation.
Contains all data needed to generate a test report including
test run metadata, results, measurements, and generated charts.
Attributes:
run: Test run metadata.
results: List of test results with pass/fail status.
measurements: DataFrame of time-series measurements (optional).
charts: Dictionary mapping chart names to base64-encoded PNG images.
config: Report configuration settings.
"""
run: TestRun
results: list[TestResult]
measurements: pd.DataFrame | None = None
charts: dict[str, str] = field(default_factory=dict)
config: ReportConfig = field(default_factory=ReportConfig)
@property
def passed_count(self) -> int:
"""Count of results that passed."""
return sum(1 for r in self.results if r.passed is True)
@property
def failed_count(self) -> int:
"""Count of results that failed."""
return sum(1 for r in self.results if r.passed is False)
@property
def overall_status(self) -> str:
"""Overall test status: PASS, FAIL, or ERROR."""
if self.run.status.value == "error":
return "ERROR"
if self.failed_count > 0:
return "FAIL"
return "PASS"

View File

@@ -0,0 +1,6 @@
"""Report renderers for HTML and PDF output."""
from py_dvt_ate.reporting.renderers.html import HTMLRenderer
from py_dvt_ate.reporting.renderers.pdf import PDFRenderer
__all__ = ["HTMLRenderer", "PDFRenderer"]

View File

@@ -0,0 +1,100 @@
"""HTML renderer using Jinja2 templates.
This module provides HTML rendering for test reports using Jinja2 templating.
Templates are loaded from the package's templates directory.
"""
import base64
import json
from datetime import datetime
from importlib import resources
from pathlib import Path
from jinja2 import Environment, PackageLoader, select_autoescape
from py_dvt_ate import __version__
from py_dvt_ate.reporting.exceptions import TemplateRenderError
from py_dvt_ate.reporting.models import ReportData
class HTMLRenderer:
"""Renders HTML reports from ReportData using Jinja2 templates.
The renderer loads templates from the py_dvt_ate.reporting.templates package
and provides methods for rendering complete HTML reports.
"""
def __init__(self) -> None:
"""Initialise the HTML renderer with Jinja2 environment."""
self._env = Environment(
loader=PackageLoader("py_dvt_ate.reporting", "templates"),
autoescape=select_autoescape(["html", "xml"]),
)
self._css_content: str | None = None
def _load_css(self) -> str:
"""Load CSS content from the templates directory."""
if self._css_content is None:
templates_pkg = resources.files("py_dvt_ate.reporting.templates")
css_file = templates_pkg.joinpath("styles.css")
self._css_content = css_file.read_text()
return self._css_content
def _load_logo(self, logo_path: Path | None) -> str | None:
"""Load and encode logo image as base64.
Args:
logo_path: Path to logo image file.
Returns:
Base64-encoded image data, or None if no logo or file not found.
"""
if logo_path is None or not logo_path.exists():
return None
try:
with logo_path.open("rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except OSError:
return None
def render(self, data: ReportData) -> str:
"""Render a test report to HTML.
Args:
data: Report data containing test run, results, and charts.
Returns:
Complete HTML document as a string.
Raises:
TemplateRenderError: If template rendering fails.
"""
try:
template = self._env.get_template("test_report.html")
# Format config JSON for display
config_formatted = ""
if data.run.config_json:
try:
config_dict = json.loads(data.run.config_json)
config_formatted = json.dumps(config_dict, indent=2)
except json.JSONDecodeError:
config_formatted = data.run.config_json
# Prepare template context
context = {
"data": data,
"css_content": self._load_css(),
"logo_base64": self._load_logo(data.config.logo_path),
"company_name": data.config.company_name,
"version": __version__,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"config_formatted": config_formatted,
}
return template.render(**context)
except Exception as e:
msg = f"Failed to render HTML template: {e}"
raise TemplateRenderError(msg) from e

View File

@@ -0,0 +1,84 @@
"""PDF renderer using WeasyPrint.
This module provides PDF rendering from HTML content using WeasyPrint.
"""
from pathlib import Path
from py_dvt_ate.reporting.exceptions import PDFConversionError
class PDFRenderer:
"""Renders PDF documents from HTML content using WeasyPrint.
WeasyPrint converts HTML/CSS to PDF with support for page layout,
headers/footers, and professional typography.
"""
def __init__(self) -> None:
"""Initialise the PDF renderer."""
self._weasyprint: type | None = None
def _get_weasyprint(self) -> type:
"""Lazy-load WeasyPrint to avoid import errors when not installed.
Returns:
The WeasyPrint HTML class.
Raises:
PDFConversionError: If WeasyPrint is not installed.
"""
if self._weasyprint is None:
try:
from weasyprint import HTML
self._weasyprint = HTML
except ImportError as e:
msg = (
"WeasyPrint is required for PDF generation. "
"Install it with: pip install py_dvt_ate[reports]"
)
raise PDFConversionError(msg) from e
return self._weasyprint
def render_to_file(self, html: str, path: Path) -> None:
"""Render HTML content to a PDF file.
Args:
html: HTML content to convert.
path: Output path for the PDF file.
Raises:
PDFConversionError: If PDF conversion fails.
"""
try:
HTML = self._get_weasyprint()
path.parent.mkdir(parents=True, exist_ok=True)
HTML(string=html).write_pdf(path)
except PDFConversionError:
raise
except Exception as e:
msg = f"Failed to convert HTML to PDF: {e}"
raise PDFConversionError(msg) from e
def render_to_bytes(self, html: str) -> bytes:
"""Render HTML content to PDF bytes.
Args:
html: HTML content to convert.
Returns:
PDF document as bytes.
Raises:
PDFConversionError: If PDF conversion fails.
"""
try:
HTML = self._get_weasyprint()
result: bytes = HTML(string=html).write_pdf()
return result
except PDFConversionError:
raise
except Exception as e:
msg = f"Failed to convert HTML to PDF: {e}"
raise PDFConversionError(msg) from e

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Test Report{% endblock %}</title>
<style>
{{ css_content }}
</style>
</head>
<body>
<header class="report-header">
<div class="logo-block">
{% if logo_base64 %}
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Company Logo" class="logo">
{% endif %}
</div>
<div class="title-block">
<h1>{% block header_title %}Test Report{% endblock %}</h1>
<div class="subtitle">{{ company_name }}</div>
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="report-footer">
<p>Generated by py_dvt_ate v{{ version }} on {{ generated_at }}</p>
<p>{{ company_name }}</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,319 @@
/* Professional report stylesheet for py_dvt_ate
* Optimised for A4 PDF output via WeasyPrint
*/
/* Page setup for A4 */
@page {
size: A4;
margin: 20mm 15mm 25mm 15mm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
color: #666;
}
@bottom-right {
content: "py_dvt_ate Report";
font-size: 9pt;
color: #666;
}
}
/* Base styles */
* {
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 10pt;
line-height: 1.5;
color: #333;
margin: 0;
padding: 0;
}
/* Headers */
h1 {
font-size: 20pt;
color: #1a1a1a;
margin: 0 0 10mm 0;
padding-bottom: 3mm;
border-bottom: 2px solid #2563eb;
}
h2 {
font-size: 14pt;
color: #1a1a1a;
margin: 8mm 0 4mm 0;
padding-bottom: 2mm;
border-bottom: 1px solid #e5e7eb;
}
h3 {
font-size: 12pt;
color: #374151;
margin: 6mm 0 3mm 0;
}
/* Report header section */
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8mm;
padding-bottom: 5mm;
border-bottom: 3px solid #2563eb;
}
.report-header .logo {
max-height: 20mm;
max-width: 50mm;
}
.report-header .title-block {
text-align: right;
}
.report-header .title-block h1 {
border: none;
margin: 0;
padding: 0;
}
.report-header .title-block .subtitle {
font-size: 11pt;
color: #6b7280;
margin-top: 2mm;
}
/* Metadata section */
.metadata {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 3mm;
margin-bottom: 6mm;
padding: 4mm;
background-color: #f9fafb;
border-radius: 2mm;
}
.metadata-item {
display: flex;
}
.metadata-item .label {
font-weight: 600;
color: #4b5563;
min-width: 35mm;
}
.metadata-item .value {
color: #1f2937;
}
/* Summary cards */
.summary-cards {
display: flex;
gap: 4mm;
margin: 6mm 0;
}
.summary-card {
flex: 1;
padding: 5mm;
border-radius: 2mm;
text-align: center;
}
.summary-card.pass {
background-color: #dcfce7;
border: 1px solid #86efac;
}
.summary-card.fail {
background-color: #fee2e2;
border: 1px solid #fca5a5;
}
.summary-card.info {
background-color: #dbeafe;
border: 1px solid #93c5fd;
}
.summary-card .count {
font-size: 24pt;
font-weight: 700;
line-height: 1.2;
}
.summary-card.pass .count {
color: #16a34a;
}
.summary-card.fail .count {
color: #dc2626;
}
.summary-card.info .count {
color: #2563eb;
}
.summary-card .label {
font-size: 9pt;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 1mm 3mm;
border-radius: 1mm;
font-size: 9pt;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.pass {
background-color: #dcfce7;
color: #16a34a;
}
.status-badge.fail {
background-color: #fee2e2;
color: #dc2626;
}
.status-badge.error {
background-color: #fef3c7;
color: #d97706;
}
.status-badge.pending {
background-color: #f3f4f6;
color: #6b7280;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 4mm 0;
font-size: 9pt;
}
th, td {
padding: 2.5mm 3mm;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
th {
background-color: #f9fafb;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #d1d5db;
}
tr:hover {
background-color: #f9fafb;
}
/* Numeric columns right-aligned */
td.numeric {
text-align: right;
font-family: 'Courier New', Courier, monospace;
}
/* Results table specific */
.results-table .status-cell {
text-align: center;
width: 15mm;
}
.results-table .value-cell {
font-family: 'Courier New', Courier, monospace;
text-align: right;
}
.results-table .limit-cell {
font-family: 'Courier New', Courier, monospace;
text-align: right;
color: #6b7280;
font-size: 8pt;
}
/* Charts section */
.chart-container {
margin: 6mm 0;
text-align: center;
page-break-inside: avoid;
}
.chart-container img {
max-width: 100%;
height: auto;
border: 1px solid #e5e7eb;
border-radius: 2mm;
}
.chart-container .caption {
font-size: 9pt;
color: #6b7280;
margin-top: 2mm;
font-style: italic;
}
/* Configuration section */
.config-section {
background-color: #f9fafb;
padding: 4mm;
border-radius: 2mm;
margin: 4mm 0;
}
.config-section pre {
font-family: 'Courier New', Courier, monospace;
font-size: 8pt;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Page break utilities */
.page-break-before {
page-break-before: always;
}
.page-break-after {
page-break-after: always;
}
.avoid-break {
page-break-inside: avoid;
}
/* Footer */
.report-footer {
margin-top: 10mm;
padding-top: 4mm;
border-top: 1px solid #e5e7eb;
font-size: 8pt;
color: #9ca3af;
text-align: center;
}
/* Print optimisations */
@media print {
body {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.no-print {
display: none;
}
}

View File

@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}{{ data.run.test_name }} - Test Report{% endblock %}
{% block header_title %}{{ data.run.test_name }}{% endblock %}
{% block content %}
<section class="test-overview">
<h2>Test Overview</h2>
<div class="metadata">
<div class="metadata-item">
<span class="label">Run ID:</span>
<span class="value">{{ data.run.id }}</span>
</div>
<div class="metadata-item">
<span class="label">Test Name:</span>
<span class="value">{{ data.run.test_name }}</span>
</div>
<div class="metadata-item">
<span class="label">Started:</span>
<span class="value">{{ data.run.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</div>
<div class="metadata-item">
<span class="label">Completed:</span>
<span class="value">{% if data.run.completed_at %}{{ data.run.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}{% else %}N/A{% endif %}</span>
</div>
<div class="metadata-item">
<span class="label">Operator:</span>
<span class="value">{{ data.run.operator or 'N/A' }}</span>
</div>
<div class="metadata-item">
<span class="label">Status:</span>
<span class="value">
<span class="status-badge {{ data.overall_status|lower }}">{{ data.overall_status }}</span>
</span>
</div>
{% if data.run.description %}
<div class="metadata-item" style="grid-column: span 2;">
<span class="label">Description:</span>
<span class="value">{{ data.run.description }}</span>
</div>
{% endif %}
</div>
</section>
<section class="results-summary">
<h2>Results Summary</h2>
<div class="summary-cards">
<div class="summary-card pass">
<div class="count">{{ data.passed_count }}</div>
<div class="label">Passed</div>
</div>
<div class="summary-card fail">
<div class="count">{{ data.failed_count }}</div>
<div class="label">Failed</div>
</div>
<div class="summary-card info">
<div class="count">{{ data.results|length }}</div>
<div class="label">Total Results</div>
</div>
</div>
</section>
<section class="results-table-section avoid-break">
<h2>Test Results</h2>
{% if data.results %}
<table class="results-table">
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Unit</th>
<th>Lower Limit</th>
<th>Upper Limit</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for result in data.results %}
<tr>
<td>{{ result.parameter }}</td>
<td class="value-cell">{{ "%.6f"|format(result.value) }}</td>
<td>{{ result.unit }}</td>
<td class="limit-cell">{% if result.lower_limit is not none %}{{ "%.6f"|format(result.lower_limit) }}{% else %}—{% endif %}</td>
<td class="limit-cell">{% if result.upper_limit is not none %}{{ "%.6f"|format(result.upper_limit) }}{% else %}—{% endif %}</td>
<td class="status-cell">
{% if result.passed is true %}
<span class="status-badge pass">PASS</span>
{% elif result.passed is false %}
<span class="status-badge fail">FAIL</span>
{% else %}
<span class="status-badge pending">N/A</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No results recorded for this test run.</p>
{% endif %}
</section>
{% if data.charts %}
<section class="charts-section page-break-before">
<h2>Charts</h2>
{% for chart_name, chart_base64 in data.charts.items() %}
<div class="chart-container avoid-break">
<h3>{{ chart_name }}</h3>
<img src="data:image/png;base64,{{ chart_base64 }}" alt="{{ chart_name }}">
</div>
{% endfor %}
</section>
{% endif %}
{% if data.run.config_json %}
<section class="configuration-section avoid-break">
<h2>Test Configuration</h2>
<div class="config-section">
<pre>{{ config_formatted }}</pre>
</div>
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,291 @@
"""Integration tests for report generation.
Tests the full report generation pipeline from test run to PDF output.
"""
import tempfile
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from py_dvt_ate.data.models import Measurement, TestStatus
from py_dvt_ate.data.repository import SQLiteRepository
class TestReportGenerationIntegration:
"""Integration tests for the report generation pipeline."""
@pytest.fixture
def temp_dir(self) -> Path:
"""Create a temporary directory for test files."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def repository(self, temp_dir: Path) -> SQLiteRepository:
"""Create a test repository with sample data."""
db_path = temp_dir / "test.db"
measurements_dir = temp_dir / "measurements"
repo = SQLiteRepository(db_path, measurements_dir)
# Create a test run
test_config = {
"temperatures": [-40.0, 25.0, 85.0],
"input_voltage": 5.0,
"load_current": 0.1,
}
run_id = repo.create_run(
test_name="tempco",
config=test_config,
operator="test_operator",
description="Integration test run",
)
# Add some results
repo.save_result(
run_id=run_id,
parameter="tempco",
value=48.5,
unit="ppm/C",
lower_limit=None,
upper_limit=100.0,
)
repo.save_result(
run_id=run_id,
parameter="output_voltage_m40c",
value=3.2965,
unit="V",
lower_limit=3.2,
upper_limit=3.4,
)
repo.save_result(
run_id=run_id,
parameter="output_voltage_25c",
value=3.3000,
unit="V",
lower_limit=3.2,
upper_limit=3.4,
)
repo.save_result(
run_id=run_id,
parameter="output_voltage_85c",
value=3.2901,
unit="V",
lower_limit=3.2,
upper_limit=3.4,
)
# Add measurements
measurements = []
temperatures = [-40.0, 0.0, 25.0, 50.0, 85.0]
voltages = [3.2965, 3.2985, 3.3000, 3.2960, 3.2901]
for i, (temp, voltage) in enumerate(zip(temperatures, voltages, strict=False)):
measurements.append(
Measurement(
timestamp=float(i * 60),
parameter="output_voltage",
value=voltage,
unit="V",
temperature=temp,
input_voltage=5.0,
load_current=0.1,
)
)
repo.save_measurements(run_id, measurements)
# Complete the run
repo.complete_run(run_id, TestStatus.PASSED)
return repo
@pytest.fixture
def run_id(self, repository: SQLiteRepository) -> UUID:
"""Get the test run ID from the repository."""
runs = repository.get_all_runs()
return UUID(runs[0].id)
def test_full_report_generation(
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
) -> None:
"""Test complete report generation pipeline."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
# Create report config
config = ReportConfig(
company_name="Test Company Ltd",
include_charts=True,
chart_dpi=100, # Lower for faster tests
)
# Create generator
reports_dir = temp_dir / "reports"
generator = ReportGenerator(
repository=repository,
config=config,
reports_dir=reports_dir,
)
# Generate report
pdf_path = generator.generate(run_id)
# Verify PDF was created
assert pdf_path.exists()
assert pdf_path.suffix == ".pdf"
assert pdf_path.stat().st_size > 1000 # Should be non-trivial size
# Verify it's in the reports directory
assert pdf_path.parent == reports_dir
def test_report_generation_custom_path(
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
) -> None:
"""Test report generation with custom output path."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
config = ReportConfig(include_charts=False) # No charts for faster test
generator = ReportGenerator(
repository=repository,
config=config,
)
# Generate to custom path
custom_path = temp_dir / "custom_report.pdf"
pdf_path = generator.generate(run_id, output_path=custom_path)
assert pdf_path == custom_path
assert pdf_path.exists()
def test_report_generation_as_bytes(
self, repository: SQLiteRepository, run_id: UUID
) -> None:
"""Test generating report as bytes."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
config = ReportConfig(include_charts=False)
generator = ReportGenerator(
repository=repository,
config=config,
)
# Generate as bytes
pdf_bytes = generator.generate_bytes(run_id)
# Verify it's a valid PDF
assert isinstance(pdf_bytes, bytes)
assert pdf_bytes.startswith(b"%PDF") # PDF magic bytes
assert len(pdf_bytes) > 1000
def test_report_includes_all_data(
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
) -> None:
"""Test that generated report includes all expected data."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
from py_dvt_ate.reporting.renderers.html import HTMLRenderer
config = ReportConfig(
company_name="Test Company",
include_charts=False,
)
generator = ReportGenerator(
repository=repository,
config=config,
)
# Get HTML (intermediate step) to check content
data = generator._gather_data(run_id)
html_renderer = HTMLRenderer()
html = html_renderer.render(data)
# Check for expected content
assert "tempco" in html
assert "Test Company" in html
assert "48.500000" in html # tempco value
assert "3.300000" in html # output voltage
assert "test_operator" in html
assert "Integration test run" in html
assert "PASS" in html
def test_report_with_failed_results(
self, temp_dir: Path
) -> None:
"""Test report generation with failed test results."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
# Create repository with a failed test
db_path = temp_dir / "failed_test.db"
repo = SQLiteRepository(db_path, temp_dir / "measurements")
run_id = repo.create_run(
test_name="failed_test",
config={},
operator="test",
)
# Add failing result
repo.save_result(
run_id=run_id,
parameter="test_param",
value=150.0, # Exceeds limit
unit="X",
lower_limit=0.0,
upper_limit=100.0,
)
repo.complete_run(run_id, TestStatus.FAILED)
# Generate report
config = ReportConfig(include_charts=False)
generator = ReportGenerator(repository=repo, config=config)
pdf_bytes = generator.generate_bytes(run_id)
# Should still generate
assert pdf_bytes.startswith(b"%PDF")
def test_report_generation_invalid_run_id(
self, repository: SQLiteRepository, temp_dir: Path
) -> None:
"""Test that invalid run ID raises appropriate error."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerationError, ReportGenerator
config = ReportConfig()
generator = ReportGenerator(repository=repository, config=config)
invalid_id = uuid4()
with pytest.raises(ReportGenerationError):
generator.generate(invalid_id)
def test_report_charts_generation(
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
) -> None:
"""Test that charts are generated when enabled."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
config = ReportConfig(include_charts=True, chart_dpi=72)
generator = ReportGenerator(
repository=repository,
config=config,
reports_dir=temp_dir / "reports",
)
# Gather data and check charts
data = generator._gather_data(run_id)
# Should have at least one chart (results bar chart)
assert len(data.charts) >= 1
# Voltage vs temperature chart should be present (we have voltage measurements)
assert "Voltage vs Temperature" in data.charts or "Results Summary" in data.charts

View File

@@ -0,0 +1 @@
"""Unit tests for reporting module."""

View File

@@ -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)

View File

@@ -0,0 +1,221 @@
"""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.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("<!DOCTYPE html>")
assert "</html>" 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 "<title>" 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

View File

@@ -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