PowerTesting Docs
Back to PowerTesting

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

ProductRoleWhat PowerTesting checks
PowerSolverConstraint OptimizationScore quality, hard-constraint satisfaction, solve time, deterministic output for fixed seeds.
PowerForecastTime-Series ForecastingMAPE / RMSE thresholds, horizon coverage, model selection, response shape.
PowerDecisionBusiness Rules EngineDMN evaluation results, rule firing, decision-table coverage.
PowerAIAI Analysis & ValidationSchema-conformance of LLM output, latency, prompt regressions.
PowerOrchestratorPipeline OrchestrationEnd-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.

Rule of thumb: if you broke a product, L1 will tell you. If you broke how the platform chooses a product, only L2 will catch it.

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:

Submit
Poll
Adapt
Validate
Record

Run States

StateMeaning
pendingSubmitted, waiting for the product to accept it.
runningProduct is processing; we're polling its status endpoint.
passedAdapter and all validators returned ok.
failedAt least one validator returned not-ok.
errorNetwork / HTTP / adapter exception — no validator ever ran.
cancelledUser 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:

BucketYesterdayTodayWhat it means
regressionspassedfailedSomething we shipped broke this case — investigate first.
fixesfailedpassedA previously-broken case now passes — confirm it's intentional.
newFailuresfailedA new case that didn't pass on first run.
newPassespassedA new case that passed on first run.
removedanyCase was deleted or renamed.
stableFailfailedfailedKnown-broken; tracked but not new.
stablePasspassedpassedHealthy — 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