Concepts Guide
PowerTesting is the regression and integration testing layer for the Planning Decision Platform. It runs validated test suites against every PDP product, records every run in a durable history, and surfaces regressions before they ship.
This guide covers the building blocks: test cases vs runs, the L1 / L2 testing levels, the validator system, the envelope adapter that normalises product responses, and the end-to-end lifecycle of a single run.
The Planning Decision Platform
PowerTesting watches over the five products that make up the PDP. Each product exposes its own API contract; PowerTesting's job is to exercise those contracts and confirm the responses match expectations.
Product Suite
| Product | Role | What PowerTesting checks |
|---|---|---|
| PowerSolver | Constraint Optimization | Score quality, hard-constraint satisfaction, solve time, deterministic output for fixed seeds. |
| PowerForecast | Time-Series Forecasting | MAPE / RMSE thresholds, horizon coverage, model selection, response shape. |
| PowerDecision | Business Rules Engine | DMN evaluation results, rule firing, decision-table coverage. |
| PowerAI | AI Analysis & Validation | Schema-conformance of LLM output, latency, prompt regressions. |
| PowerOrchestrator | Pipeline Orchestration | End-to-end pipeline runs, stage timings, audit completeness. |
Test Cases vs Test Runs
The most important distinction in PowerTesting is between a test case (a reusable definition) and a test run (an execution against a specific environment at a specific moment).
Test Case
A test case is a JSON document that captures everything needed to exercise an endpoint:
{
"id": "solver-task-assignment-small",
"product": "PowerSolver",
"level": "L1",
"endpoint": "/api/solve",
"method": "POST",
"payload": { ... },
"validators": [
{ "name": "status", "config": { "expected": 200 } },
{ "name": "field-match", "config": { "path": "score.hardScore", "expected": 0 } },
{ "name": "response-time", "config": { "maxMs": 5000 } }
],
"tags": ["regression", "small"]
}
Test cases are stored in the testing API's SQLite database and seeded from JSON on the server side. They are immutable from the UI's perspective — the UI lists them, runs them, and shows their results.
Test Run
A run is what happens when you click Run on the dashboard or a product tab. The run controller submits the case's payload to the target product, polls until the product is done, normalises the response through the envelope adapter, and walks every validator.
Each run produces a summary (pass / fail counts, duration, environment) and a per-case result (status, validator outcomes, captured response, error trace). Both are persisted so History and Diff can replay them later.
L1 vs L2 Testing
PowerTesting runs at two levels, distinguished by how the product is reached:
L1 — Direct API
L1 cases call the product's own HTTP API directly. There is no intermediary, no AI, no pipeline. L1 is the cleanest way to verify a single product's contract: it isolates failures to that product alone.
L2 — Orchestrated AI Pathway
L2 cases go through PowerOrchestrator's /ai/generate entry point. The same
business intent is expressed in natural language; the orchestrator routes it through the
AI router which picks the right product and constructs the call. L2 is what catches
regressions in the AI routing layer, prompt templates, and end-to-end pipeline glue.
Validator Architecture
A validator is a small, named function that takes a normalised response and returns pass / fail with a reason. Validators are composable: a single test case may attach several, and the case fails if any one of them fails.
Validator Shape
export const responseTimeValidator = {
name: 'response-time',
scope: 'generic', // 'generic' or a product name
validate(response, config) {
const ok = response.elapsedMs <= config.maxMs;
return {
ok,
message: ok
? `OK (${response.elapsedMs}ms <= ${config.maxMs}ms)`
: `Slow (${response.elapsedMs}ms > ${config.maxMs}ms)`,
};
},
};
Per-Product vs Generic
Generic validators (status, field-match,
schema, response-time) are registered centrally and work
against the canonical envelope. They cover ~80% of assertions.
Per-product validators live inside that product's module
(js/products/powersolver.js etc.) and inspect product-specific shape
— e.g. PowerSolver's hard-score, PowerForecast's MAPE, PowerDecision's DMN trace.
The Registry
On startup, main.js registers all generic validators first, then loads each
product module which contributes its product-scoped validators. The registry is the single
place test cases look up validators by name. If a case references a name that does not
exist, it surfaces in the Coverage view as an orphan reference.
Envelope Adapter
Every product returns a different shape: PowerSolver gives you a Timefold solution, PowerForecast a frame of points, PowerDecision a DMN trace. To keep validators portable, each product module ships an adapter that converts the raw response to a canonical envelope:
{
"result": { ... }, // product-specific payload, untouched
"startedAt": "2026-04-25T10:00:00Z",
"finishedAt": "2026-04-25T10:00:03Z",
"elapsedMs": 3142,
"trace": [ ... ] // optional product trace events
}
Generic validators read elapsedMs, result.status, and so on,
which is why they work across products. Product-specific validators reach into
result directly.
Run Lifecycle
The run controller drives every test case through the same five-step lifecycle:
Run States
| State | Meaning |
|---|---|
pending | Submitted, waiting for the product to accept it. |
running | Product is processing; we're polling its status endpoint. |
passed | Adapter and all validators returned ok. |
failed | At least one validator returned not-ok. |
error | Network / HTTP / adapter exception — no validator ever ran. |
cancelled | User aborted the run via the cancel button. |
Coverage Analysis
The Coverage view answers: are we testing what we said we'd test, with the validators we said we'd use? It surfaces two kinds of gaps:
- Orphan validators — registered in code but never referenced by any test case. Either dead code, or a missing case.
- Orphan references — named in a test case but missing from the registry. Will fail at runtime; fix before shipping the case.
Coverage is computed per product and at the platform level so you can see at a glance where the gaps are.
Run Diffing
Diffing two runs (typically yesterday's vs today's, or local vs prod) is how you spot regressions before they ship. Every case is bucketed:
| Bucket | Yesterday | Today | What it means |
|---|---|---|---|
| regressions | passed | failed | Something we shipped broke this case — investigate first. |
| fixes | failed | passed | A previously-broken case now passes — confirm it's intentional. |
| newFailures | — | failed | A new case that didn't pass on first run. |
| newPasses | — | passed | A new case that passed on first run. |
| removed | any | — | Case was deleted or renamed. |
| stableFail | failed | failed | Known-broken; tracked but not new. |
| stablePass | passed | passed | Healthy — the bulk of any well-tested suite. |
Environments
PowerTesting always runs against a specific environment. The two built-in choices are local (the developer's machine, ports 80xx) and prod (the deployed Hetzner cluster). The env switcher in the top-right of the UI controls which base URL the run controller hits.
The choice is persisted twice: in localStorage for instant boot, and on the
server keyed by a stable per-browser client_id so it survives a cache clear.
API Architecture
PowerTesting itself has two halves: a Spring Boot testing-api that owns the SQLite database of cases and runs, and a static testing-ui that talks to it. The UI is open access; the per-product calls it triggers carry whatever auth the target product expects.
# Test cases
GET /api/test-cases # List all cases
GET /api/test-cases?product=Solver # Filter by product
GET /api/test-cases/{id} # Get one case
# Runs
POST /api/runs # Submit a new run
GET /api/runs # List recent runs
GET /api/runs/{id} # Get full run summary
GET /api/runs/{id}/results # Per-case results
# Diffs & coverage
GET /api/runs/{a}/diff/{b} # Bucketed diff
GET /api/coverage # Orphan validators / refs
# Products & environments
GET /api/products # Product registry + base URLs
GET /api/preferences/{clientId} # Saved env preference
PUT /api/preferences/{clientId} # Persist env preference