Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
885196d933
|
|||
|
f4c34e4689
|
|||
|
aba2cabbbc
|
|||
|
1ec05ea289
|
|||
|
b826337b36
|
|||
|
235d668d9f
|
|||
|
13f93b6739
|
|||
|
bc15df3051
|
|||
|
66cdd4494c
|
|||
|
d1981a3342
|
|||
|
45a2d9a6e5
|
|||
|
51a479c61e
|
|||
|
a6ef649090
|
|||
|
45a8f11650
|
|||
|
7e8943ac57
|
|||
| ddf2c9439d | |||
| 9d6086a4e5 | |||
| cc5a8191b0 | |||
| b7663d5a31 | |||
| 6830b3158c | |||
| c016320b71 | |||
| a5a2cf2473 | |||
| 13a4fd16b3 | |||
| 349663b4e1 | |||
| 2b92865745 | |||
| 022223af76 | |||
| bff13cd616 | |||
| 59a5bc1124 | |||
| 32daff69be | |||
| d76e610070 | |||
| 50432eaa3d | |||
| 3b136dca69 | |||
| 5405ceec7f | |||
| 01d8295512 | |||
| 3a8e6becf1 | |||
| af3116a025 | |||
| f7f2839e65 | |||
| 5fdb1e6eaf | |||
| ca7655704e |
50
.dockerignore
Normal file
50
.dockerignore
Normal 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/
|
||||
@@ -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
1
.gitignore
vendored
@@ -56,3 +56,4 @@ logs/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
CLAUDE.md
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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
39
Dockerfile
Normal 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"]
|
||||
@@ -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
8
deploy/.env.example
Normal 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
19
deploy/docker-compose.yml
Normal 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
45
deploy/nginx.conf
Normal 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
169
deploy/readme.md
Normal 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
|
||||
```
|
||||
460
docs/05_sprint_18_report_generation.md
Normal file
460
docs/05_sprint_18_report_generation.md
Normal 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**
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -138,6 +138,7 @@ class InstrumentServer:
|
||||
handler,
|
||||
self._host,
|
||||
port,
|
||||
reuse_address=True,
|
||||
)
|
||||
self._servers.append(server)
|
||||
logger.info(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
5
src/py_dvt_ate/reporting/charts/__init__.py
Normal file
5
src/py_dvt_ate/reporting/charts/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Chart generation for reports."""
|
||||
|
||||
from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator
|
||||
|
||||
__all__ = ["ChartGenerator"]
|
||||
233
src/py_dvt_ate/reporting/charts/matplotlib_charts.py
Normal file
233
src/py_dvt_ate/reporting/charts/matplotlib_charts.py
Normal 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
|
||||
41
src/py_dvt_ate/reporting/exceptions.py
Normal file
41
src/py_dvt_ate/reporting/exceptions.py
Normal 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.
|
||||
"""
|
||||
199
src/py_dvt_ate/reporting/generator.py
Normal file
199
src/py_dvt_ate/reporting/generator.py
Normal 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
|
||||
70
src/py_dvt_ate/reporting/models.py
Normal file
70
src/py_dvt_ate/reporting/models.py
Normal 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"
|
||||
6
src/py_dvt_ate/reporting/renderers/__init__.py
Normal file
6
src/py_dvt_ate/reporting/renderers/__init__.py
Normal 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"]
|
||||
100
src/py_dvt_ate/reporting/renderers/html.py
Normal file
100
src/py_dvt_ate/reporting/renderers/html.py
Normal 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
|
||||
84
src/py_dvt_ate/reporting/renderers/pdf.py
Normal file
84
src/py_dvt_ate/reporting/renderers/pdf.py
Normal 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
|
||||
33
src/py_dvt_ate/reporting/templates/base.html
Normal file
33
src/py_dvt_ate/reporting/templates/base.html
Normal 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>
|
||||
319
src/py_dvt_ate/reporting/templates/styles.css
Normal file
319
src/py_dvt_ate/reporting/templates/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
125
src/py_dvt_ate/reporting/templates/test_report.html
Normal file
125
src/py_dvt_ate/reporting/templates/test_report.html
Normal 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 %}
|
||||
291
tests/integration/test_report_generation.py
Normal file
291
tests/integration/test_report_generation.py
Normal 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
|
||||
1
tests/unit/reporting/__init__.py
Normal file
1
tests/unit/reporting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests for reporting module."""
|
||||
220
tests/unit/reporting/test_chart_generator.py
Normal file
220
tests/unit/reporting/test_chart_generator.py
Normal 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)
|
||||
221
tests/unit/reporting/test_html_renderer.py
Normal file
221
tests/unit/reporting/test_html_renderer.py
Normal 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
|
||||
208
tests/unit/reporting/test_models.py
Normal file
208
tests/unit/reporting/test_models.py
Normal 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
|
||||
Reference in New Issue
Block a user