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:
|
with:
|
||||||
python-version: '3.11'
|
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
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev,reports]"
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
run: pytest --cov=src/py_dvt_ate --cov-report=xml
|
run: pytest --cov=src/py_dvt_ate --cov-report=xml
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ logs/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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]
|
## [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
|
## [0.1.0] - 2025-12-04
|
||||||
|
|
||||||
### Added
|
### 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
|
# API server port
|
||||||
port: 8000
|
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 = [
|
reports = [
|
||||||
"jinja2>=3.1",
|
"jinja2>=3.1",
|
||||||
"weasyprint>=60.0",
|
"weasyprint>=60.0",
|
||||||
|
"matplotlib>=3.8",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
@@ -83,6 +84,8 @@ disallow_incomplete_defs = true
|
|||||||
module = [
|
module = [
|
||||||
"streamlit.*",
|
"streamlit.*",
|
||||||
"plotly.*",
|
"plotly.*",
|
||||||
|
"weasyprint.*",
|
||||||
|
"matplotlib.*",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
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")
|
@app.command(name="query")
|
||||||
def query_cmd(
|
def query_cmd(
|
||||||
instrument: Annotated[
|
instrument: Annotated[
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ class APIConfig(BaseModel):
|
|||||||
port: int = 8000
|
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):
|
class AppConfig(BaseModel):
|
||||||
"""Root configuration model."""
|
"""Root configuration model."""
|
||||||
|
|
||||||
@@ -120,6 +129,7 @@ class AppConfig(BaseModel):
|
|||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
|
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
|
||||||
api: APIConfig = Field(default_factory=APIConfig)
|
api: APIConfig = Field(default_factory=APIConfig)
|
||||||
|
reporting: ReportingConfig = Field(default_factory=ReportingConfig)
|
||||||
|
|
||||||
|
|
||||||
def _apply_env_overrides(config_dict: dict[str, Any]) -> None:
|
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 asyncio
|
||||||
import atexit
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -27,6 +27,66 @@ from py_dvt_ate.tests.thermal.tempco import TempCoTest
|
|||||||
# Thread pool for background test execution
|
# Thread pool for background test execution
|
||||||
_test_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test_runner")
|
_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 buffer size for charts
|
||||||
HISTORY_SIZE = 500
|
HISTORY_SIZE = 500
|
||||||
|
|
||||||
@@ -69,12 +129,20 @@ class TestProgress:
|
|||||||
return time.time() - self.started_at
|
return time.time() - self.started_at
|
||||||
|
|
||||||
|
|
||||||
def start_embedded_server() -> tuple[SimulationServer, threading.Thread]:
|
@st.cache_resource
|
||||||
"""Start an embedded simulation server in a background thread.
|
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:
|
Returns:
|
||||||
Tuple of (server instance, thread running the server).
|
The simulation server instance.
|
||||||
"""
|
"""
|
||||||
|
global _server_loop, _server_thread
|
||||||
|
|
||||||
server = SimulationServer(
|
server = SimulationServer(
|
||||||
ServerConfig(
|
ServerConfig(
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
@@ -90,7 +158,9 @@ def start_embedded_server() -> tuple[SimulationServer, threading.Thread]:
|
|||||||
|
|
||||||
def run_server() -> None:
|
def run_server() -> None:
|
||||||
"""Run the async server in a new event loop."""
|
"""Run the async server in a new event loop."""
|
||||||
|
global _server_loop
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
|
_server_loop = loop # Store reference for _stop_server to use
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(server.start())
|
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 = threading.Thread(target=run_server, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
_server_thread = thread # Store reference for _stop_server to use
|
||||||
|
|
||||||
# Wait for server to be fully started (up to 5 seconds)
|
# Wait for server to be fully started (up to 5 seconds)
|
||||||
if not server_ready.wait(timeout=5.0):
|
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
|
# Check if there was an error during startup
|
||||||
if server_error:
|
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:
|
def init_session_state() -> None:
|
||||||
"""Initialise Streamlit session state."""
|
"""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..."):
|
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
|
# Verify server started correctly
|
||||||
if st.session_state.server.physics_engine is None:
|
if st.session_state.server.physics_engine is None:
|
||||||
st.error("Failed to start simulation server. Please refresh the page.")
|
st.error("Failed to start simulation server. Please refresh the page.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
# Register cleanup
|
# Start idle checker to stop server when no one's viewing
|
||||||
def cleanup() -> None:
|
_start_idle_checker(st.session_state.server)
|
||||||
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)
|
|
||||||
|
|
||||||
if "instruments" not in st.session_state:
|
if "instruments" not in st.session_state:
|
||||||
# Create instruments via HAL using factory
|
# Create instruments via HAL using factory
|
||||||
@@ -386,6 +469,8 @@ def display_controls() -> None:
|
|||||||
@st.fragment(run_every=0.1)
|
@st.fragment(run_every=0.1)
|
||||||
def simulation_display() -> None:
|
def simulation_display() -> None:
|
||||||
"""Fragment that displays and updates simulation state."""
|
"""Fragment that displays and updates simulation state."""
|
||||||
|
_update_activity() # Track activity and resume physics if paused
|
||||||
|
|
||||||
if "server" not in st.session_state:
|
if "server" not in st.session_state:
|
||||||
st.warning("Initializing simulation server...")
|
st.warning("Initializing simulation server...")
|
||||||
return
|
return
|
||||||
@@ -876,6 +961,51 @@ def results_viewer_page() -> None:
|
|||||||
if selected_run.description:
|
if selected_run.description:
|
||||||
st.markdown(f"**Description:** {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:
|
if selected_run.config_json:
|
||||||
import json
|
import json
|
||||||
with st.expander("Test Configuration"):
|
with st.expander("Test Configuration"):
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class InstrumentServer:
|
|||||||
handler,
|
handler,
|
||||||
self._host,
|
self._host,
|
||||||
port,
|
port,
|
||||||
|
reuse_address=True,
|
||||||
)
|
)
|
||||||
self._servers.append(server)
|
self._servers.append(server)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,5 +1,58 @@
|
|||||||
"""Report generation.
|
"""Report generation module for py_dvt_ate.
|
||||||
|
|
||||||
Generates test reports from stored data in various formats
|
This module provides automated PDF report generation from test results.
|
||||||
including PDF and HTML.
|
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