Automate RC8 release journeys
This commit is contained in:
@@ -34,6 +34,21 @@
|
|||||||
download.
|
download.
|
||||||
- Passed the RC-7 release gate with 1,008 backend tests, frontend
|
- Passed the RC-7 release gate with 1,008 backend tests, frontend
|
||||||
typecheck/build, one Alembic head and offline migration SQL generation.
|
typecheck/build, one Alembic head and offline migration SQL generation.
|
||||||
|
- Added 12 executable frontend unit tests for map selection, coverage,
|
||||||
|
temporal comparison and workbench bootstrap behavior.
|
||||||
|
- Added deterministic provisioning for seven Belgium/North Sea golden areas:
|
||||||
|
Mol, Kempen, Wallonia, Brussels, the language boundary, the coast and the
|
||||||
|
offshore multi-zone scope.
|
||||||
|
- Added a Playwright release runner that proves governed metrics/provenance,
|
||||||
|
historical comparison, no-data, partial/unsupported coverage, provider
|
||||||
|
failure, persisted export, real local Ollama context and an explicit
|
||||||
|
configured-YOLO run against live PostGIS.
|
||||||
|
- Fixed explicit demo provisioning so an archived technical demo project is
|
||||||
|
reactivated and remains selectable instead of being silently reused while
|
||||||
|
hidden.
|
||||||
|
- Passed the RC-8 release gate with 1,012 backend tests, 12 frontend unit
|
||||||
|
tests, frontend typecheck/build, one Alembic head and a clean live E2E
|
||||||
|
console/request audit.
|
||||||
- Added a read-only release-evidence manifest command with Git, migration,
|
- Added a read-only release-evidence manifest command with Git, migration,
|
||||||
dependency, configuration checksum and optional live endpoint evidence.
|
dependency, configuration checksum and optional live endpoint evidence.
|
||||||
- Replaced the obsolete pre-build status with the current implemented
|
- Replaced the obsolete pre-build status with the current implemented
|
||||||
|
|||||||
@@ -1889,3 +1889,34 @@ python scripts/verify_python_lock.py
|
|||||||
|
|
||||||
The complete gate and vulnerability/SBOM policy are documented in
|
The complete gate and vulnerability/SBOM policy are documented in
|
||||||
`docs/CI_SUPPLY_CHAIN.md`.
|
`docs/CI_SUPPLY_CHAIN.md`.
|
||||||
|
|
||||||
|
## Release golden areas
|
||||||
|
|
||||||
|
The RC browser suite uses seven deterministic, bounded regression areas across
|
||||||
|
Belgium and the Belgian North Sea. Preview the required changes without
|
||||||
|
mutating the runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/provision_release_golden_areas.py \
|
||||||
|
--base-url http://127.0.0.1:8000 \
|
||||||
|
--output artifacts/rc8-golden-areas.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Create only missing Areas through the canonical project/area APIs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/provision_release_golden_areas.py \
|
||||||
|
--base-url http://127.0.0.1:8000 \
|
||||||
|
--output artifacts/rc8-golden-areas.json \
|
||||||
|
--apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The operator copies the governed Mol and Kempen geometries into the national
|
||||||
|
workbench with their source project/Area identifiers and provisions bounded
|
||||||
|
Wallonia, Brussels, language-boundary, coast and offshore multi-zone Areas.
|
||||||
|
Every geometry receives a deterministic SHA-256 fingerprint in the evidence
|
||||||
|
file. It never imports provider data or writes directly to database tables.
|
||||||
|
|
||||||
|
Explicit demo seeding also reactivates its own archived technical project.
|
||||||
|
This keeps the opt-in fixture workflow selectable without changing the normal
|
||||||
|
active-project lifecycle.
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ class DemoWorkflowService:
|
|||||||
return project
|
return project
|
||||||
return projects[0] if projects else None
|
return projects[0] if projects else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _activate_explicit_demo_project(db: Session, project: Project | None) -> Project | None:
|
||||||
|
if project is None or project.status == "active":
|
||||||
|
return project
|
||||||
|
project.status = "active"
|
||||||
|
db.add(project)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_complete_demo_state(db: Session, project_id: UUID) -> bool:
|
def _has_complete_demo_state(db: Session, project_id: UUID) -> bool:
|
||||||
area = db.query(Area).filter(Area.project_id == project_id).first()
|
area = db.query(Area).filter(Area.project_id == project_id).first()
|
||||||
@@ -398,7 +408,10 @@ class DemoWorkflowService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def seed(db: Session) -> DemoWorkflowResponse:
|
def seed(db: Session) -> DemoWorkflowResponse:
|
||||||
existing = DemoWorkflowService._find_existing_project(db)
|
existing = DemoWorkflowService._activate_explicit_demo_project(
|
||||||
|
db,
|
||||||
|
DemoWorkflowService._find_existing_project(db),
|
||||||
|
)
|
||||||
reference_payload, reference_raw = DemoWorkflowService._load_fixture("reference_buildings.geojson")
|
reference_payload, reference_raw = DemoWorkflowService._load_fixture("reference_buildings.geojson")
|
||||||
candidate_payload, candidate_raw = DemoWorkflowService._load_fixture("predicted_buildings.geojson")
|
candidate_payload, candidate_raw = DemoWorkflowService._load_fixture("predicted_buildings.geojson")
|
||||||
if existing:
|
if existing:
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SCRIPT_PATH = ROOT / "scripts" / "provision_release_golden_areas.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_operator():
|
||||||
|
spec = importlib.util.spec_from_file_location("provision_release_golden_areas_test", SCRIPT_PATH)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_golden_area_contract_covers_belgium_and_north_sea() -> None:
|
||||||
|
module = load_operator()
|
||||||
|
created_keys = {item["key"] for item in module.GOLDEN_AREAS}
|
||||||
|
source_keys = {item["key"] for item in module.SOURCE_AREAS}
|
||||||
|
|
||||||
|
assert created_keys == {
|
||||||
|
"wallonia_urban_rural",
|
||||||
|
"brussels_urban",
|
||||||
|
"language_boundary",
|
||||||
|
"coast_land_sea",
|
||||||
|
"north_sea_multi_zone",
|
||||||
|
}
|
||||||
|
assert source_keys == {"mol_municipality", "kempen_region"}
|
||||||
|
assert len(created_keys | source_keys) == 7
|
||||||
|
assert all(item["source_project"] != module.NATIONAL_PROJECT for item in module.SOURCE_AREAS)
|
||||||
|
|
||||||
|
expected_zones = {
|
||||||
|
zone
|
||||||
|
for definition in (*module.GOLDEN_AREAS, *module.SOURCE_AREAS)
|
||||||
|
for zone in definition["expected_zones"]
|
||||||
|
}
|
||||||
|
assert {
|
||||||
|
"flanders",
|
||||||
|
"wallonia",
|
||||||
|
"brussels",
|
||||||
|
"territorial_sea",
|
||||||
|
"exclusive_economic_zone",
|
||||||
|
"continental_shelf",
|
||||||
|
} <= expected_zones
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_golden_area_geometries_are_bounded_and_fingerprintable() -> None:
|
||||||
|
module = load_operator()
|
||||||
|
hashes = set()
|
||||||
|
for definition in module.GOLDEN_AREAS:
|
||||||
|
bbox = module.geometry_bbox(definition["geometry"])
|
||||||
|
assert -180 <= bbox["minx"] < bbox["maxx"] <= 180
|
||||||
|
assert -90 <= bbox["miny"] < bbox["maxy"] <= 90
|
||||||
|
assert bbox["maxx"] - bbox["minx"] <= 0.25
|
||||||
|
assert bbox["maxy"] - bbox["miny"] <= 0.20
|
||||||
|
digest = module.canonical_hash(definition["geometry"])
|
||||||
|
assert len(digest) == 64
|
||||||
|
hashes.add(digest)
|
||||||
|
assert len(hashes) == len(module.GOLDEN_AREAS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rc8_runner_and_container_operator_are_release_wired() -> None:
|
||||||
|
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||||
|
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||||
|
package = (ROOT / "frontend" / "package.json").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "npm run test:unit" in readiness
|
||||||
|
assert '--check frontend/e2e/releaseJourneys.mjs' in readiness
|
||||||
|
assert "bash -n scripts/run_rc8_release_journeys.sh" in readiness
|
||||||
|
assert "COPY scripts/provision_release_golden_areas.py" in dockerfile
|
||||||
|
assert '"test:e2e": "node e2e/releaseJourneys.mjs"' in package
|
||||||
@@ -5,6 +5,7 @@ from uuid import uuid4
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.models import Project
|
||||||
from app.schemas.demo import DemoWorkflowResponse
|
from app.schemas.demo import DemoWorkflowResponse
|
||||||
from app.services.demo_workflow_service import DemoWorkflowService
|
from app.services.demo_workflow_service import DemoWorkflowService
|
||||||
|
|
||||||
@@ -80,3 +81,30 @@ def test_demo_workflow_prefers_complete_existing_demo_project() -> None:
|
|||||||
assert "order_by(Project.created_at.asc())" in service
|
assert "order_by(Project.created_at.asc())" in service
|
||||||
assert "if DemoWorkflowService._has_complete_demo_state(db, project.id):" in service
|
assert "if DemoWorkflowService._has_complete_demo_state(db, project.id):" in service
|
||||||
assert "return projects[0] if projects else None" in service
|
assert "return projects[0] if projects else None" in service
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_demo_seed_reactivates_an_archived_fixture_project() -> None:
|
||||||
|
project = Project(id=uuid4(), name=DemoWorkflowService.PROJECT_NAME, status="archived")
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
added = []
|
||||||
|
commits = 0
|
||||||
|
refreshed = []
|
||||||
|
|
||||||
|
def add(self, value) -> None:
|
||||||
|
self.added.append(value)
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
def refresh(self, value) -> None:
|
||||||
|
self.refreshed.append(value)
|
||||||
|
|
||||||
|
db = Session()
|
||||||
|
result = DemoWorkflowService._activate_explicit_demo_project(db, project)
|
||||||
|
|
||||||
|
assert result is project
|
||||||
|
assert project.status == "active"
|
||||||
|
assert db.added == [project]
|
||||||
|
assert db.commits == 1
|
||||||
|
assert db.refreshed == [project]
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ COPY scripts/provision_regional_timeseries.py /app/scripts/provision_regional_ti
|
|||||||
COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py
|
COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py
|
||||||
COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py
|
COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py
|
||||||
COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/provision_belgium_north_sea_scope.py
|
COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/provision_belgium_north_sea_scope.py
|
||||||
|
COPY scripts/provision_release_golden_areas.py /app/scripts/provision_release_golden_areas.py
|
||||||
COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
|
COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
|
||||||
COPY scripts/provision_regional_grb_context.py /app/scripts/provision_regional_grb_context.py
|
COPY scripts/provision_regional_grb_context.py /app/scripts/provision_regional_grb_context.py
|
||||||
COPY scripts/audit_source_freshness.py /app/scripts/audit_source_freshness.py
|
COPY scripts/audit_source_freshness.py /app/scripts/audit_source_freshness.py
|
||||||
|
|||||||
@@ -10546,3 +10546,39 @@ Decision:
|
|||||||
|
|
||||||
- RC-7 is complete. RC-8 automated frontend and browser release journeys are
|
- RC-7 is complete. RC-8 automated frontend and browser release journeys are
|
||||||
active.
|
active.
|
||||||
|
|
||||||
|
## 2026-07-18 - Belgium/North Sea RC-8 release journey automation
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- Added four Vitest suites with 12 executable tests for map selection,
|
||||||
|
coverage resolution, temporal comparison and bootstrap state.
|
||||||
|
- Added a dry-run-first golden-area operator. It reuses Mol/Kempen and creates
|
||||||
|
only missing bounded Wallonia, Brussels, language-boundary, coast and
|
||||||
|
offshore Areas through canonical APIs, with deterministic geometry hashes.
|
||||||
|
- Added a Playwright release runner and shell wrapper covering every golden
|
||||||
|
area, governed Mol metrics/provenance, compatible forest history, no-data,
|
||||||
|
partial coverage, unsupported maritime metrics, simulated provider failure,
|
||||||
|
persisted map export, real local Ollama context and explicit configured
|
||||||
|
local YOLO execution.
|
||||||
|
- Fixed explicit demo seeding so its archived technical project is reactivated
|
||||||
|
before reuse. A direct service regression protects this lifecycle behavior.
|
||||||
|
- Added the frontend tests and static E2E/script checks to readiness and copied
|
||||||
|
the golden-area operator into the all-in-one image.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- Repository readiness passed backend compilation, 1,012 backend tests, four
|
||||||
|
frontend test files with 12 tests, frontend typecheck/build and Alembic head
|
||||||
|
`202607160001`.
|
||||||
|
- The live runner completed all Belgium/North Sea journeys against PostGIS
|
||||||
|
3.6. It persisted an export, received a grounded Ollama answer and completed
|
||||||
|
configured local YOLO execution through Job and AnalysisRun persistence.
|
||||||
|
- The successful evidence manifest reports no unexpected browser-console or
|
||||||
|
failed-request events. Re-running the area operator created zero duplicate
|
||||||
|
Areas and retained seven unique geometry fingerprints.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- RC-8 is complete. RC-9 loading, accessibility and performance hardening is
|
||||||
|
active.
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ editions and licences must still pass source-specific probes before activation.
|
|||||||
| RC-5 | complete | deployment, secrets, configuration, fresh install and rollback |
|
| RC-5 | complete | deployment, secrets, configuration, fresh install and rollback |
|
||||||
| RC-6 | complete | complete CI, dependency and supply-chain gates |
|
| RC-6 | complete | complete CI, dependency and supply-chain gates |
|
||||||
| RC-7 | complete | critical API envelope typing and contract validation |
|
| RC-7 | complete | critical API envelope typing and contract validation |
|
||||||
| RC-8 | in progress | frontend and browser E2E release journeys |
|
| RC-8 | complete | frontend and browser E2E release journeys |
|
||||||
| RC-9 | pending | loading, accessibility and performance hardening |
|
| RC-9 | in progress | loading, accessibility and performance hardening |
|
||||||
| RC-10 | pending | retention, cleanup and national data operations |
|
| RC-10 | pending | retention, cleanup and national data operations |
|
||||||
| RC-11 | pending | final package, upgrade proof, release tag and handoff |
|
| RC-11 | pending | final package, upgrade proof, release tag and handoff |
|
||||||
|
|
||||||
@@ -428,6 +428,16 @@ offline migration SQL generation.
|
|||||||
|
|
||||||
## RC-8 - Release journey automation
|
## RC-8 - Release journey automation
|
||||||
|
|
||||||
|
**State: complete.** Four Vitest suites protect selection, coverage, temporal
|
||||||
|
comparison and bootstrap behavior with 12 tests. The Playwright release runner
|
||||||
|
provisions seven deterministic land, cross-region, coastal and maritime
|
||||||
|
golden areas in the national workbench through canonical APIs and executes the complete browser/API
|
||||||
|
journey against the live PostGIS runtime. Evidence includes governed Mol
|
||||||
|
metrics and provenance, compatible forest history, no-data, partial coverage,
|
||||||
|
unsupported maritime metrics, simulated provider failure, persistent export,
|
||||||
|
real local Ollama context and an explicit configured-YOLO run. The green run
|
||||||
|
contains no unexpected browser-console or failed-request events.
|
||||||
|
|
||||||
### Work
|
### Work
|
||||||
|
|
||||||
- add frontend unit tests for selection, coverage, loading and temporal guards;
|
- add frontend unit tests for selection, coverage, loading and temporal guards;
|
||||||
@@ -449,6 +459,22 @@ offline migration SQL generation.
|
|||||||
- no workflow depends only on source-text assertion tests;
|
- no workflow depends only on source-text assertion tests;
|
||||||
- console and failed-request audits are clean.
|
- console and failed-request audits are clean.
|
||||||
|
|
||||||
|
### Reproduction
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix frontend run test:unit
|
||||||
|
bash scripts/run_rc8_release_journeys.sh \
|
||||||
|
http://192.168.10.150:1202 \
|
||||||
|
artifacts/rc8-release-journeys \
|
||||||
|
artifacts/rc8-golden-areas.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command is intentionally live and mutating. It creates only missing
|
||||||
|
golden Areas, copies the official Mol/Kempen geometry into the national
|
||||||
|
workbench with source identifiers, uses the existing explicit demo-fixture flow for AI validation
|
||||||
|
and writes screenshots plus `manifest.json` under the ignored `artifacts/`
|
||||||
|
directory.
|
||||||
|
|
||||||
## RC-9 - UX, accessibility and performance
|
## RC-9 - UX, accessibility and performance
|
||||||
|
|
||||||
### Work
|
### Work
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ maritieme zones.
|
|||||||
bewijzen.
|
bewijzen.
|
||||||
- [x] RC-6: volledige CI, dependency-audit, containerscan en SBOM toevoegen.
|
- [x] RC-6: volledige CI, dependency-audit, containerscan en SBOM toevoegen.
|
||||||
- [x] RC-7: kritieke API-routes concrete responsemodellen geven.
|
- [x] RC-7: kritieke API-routes concrete responsemodellen geven.
|
||||||
- [ ] RC-8: echte frontend- en browser-E2E-releaseflows toevoegen.
|
- [x] RC-8: echte frontend- en browser-E2E-releaseflows toevoegen.
|
||||||
- [ ] RC-9: loading, toegankelijkheid, widescreen/mobile en performance afronden.
|
- [ ] RC-9: loading, toegankelijkheid, widescreen/mobile en performance afronden.
|
||||||
- [ ] RC-10: dataretentie, diskdruk en veilige cleanup operationaliseren.
|
- [ ] RC-10: dataretentie, diskdruk en veilige cleanup operationaliseren.
|
||||||
- [ ] RC-11: fresh install, upgrade, rollback, releasepakket, tag en live
|
- [ ] RC-11: fresh install, upgrade, rollback, releasepakket, tag en live
|
||||||
|
|||||||
@@ -726,3 +726,28 @@ the active theme as `Beschikbaar`, `Gedeeltelijk`, `Niet gekoppeld` or `Niet
|
|||||||
ondersteund`. A coastal or cross-region selection remains visibly split. The
|
ondersteund`. A coastal or cross-region selection remains visibly split. The
|
||||||
browser never calls NGI, SPW, UrbIS, RBINS or MDK directly and never promotes
|
browser never calls NGI, SPW, UrbIS, RBINS or MDK directly and never promotes
|
||||||
an audited catalog entry to operational data without a matching ready Dataset.
|
an audited catalog entry to operational data without a matching ready Dataset.
|
||||||
|
|
||||||
|
## Frontend release tests
|
||||||
|
|
||||||
|
Run the deterministic component/hook regression suite locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the browser runtime once and execute the live Belgium/North Sea
|
||||||
|
journeys from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright install chromium
|
||||||
|
bash scripts/run_rc8_release_journeys.sh \
|
||||||
|
http://192.168.10.150:1202 \
|
||||||
|
artifacts/rc8-release-journeys \
|
||||||
|
artifacts/rc8-golden-areas.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The live runner expects a healthy GeoIntel deployment with PostGIS, the
|
||||||
|
configured local Ollama integration and the configured local YOLO model. It
|
||||||
|
uses only the explicit technical demo-fixture flow for AI validation. Runtime
|
||||||
|
screenshots and the machine-readable manifest are ignored build evidence and
|
||||||
|
must not be committed.
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
import { chromium, request } from 'playwright'
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
function parseArguments(argv) {
|
||||||
|
const parsed = {
|
||||||
|
baseUrl: process.env.GE_INTEL_BASE_URL || 'http://127.0.0.1:1202',
|
||||||
|
goldenManifest: process.env.GEOINTEL_GOLDEN_AREA_MANIFEST || '../artifacts/rc8-golden-areas.json',
|
||||||
|
output: process.env.GEOINTEL_RELEASE_JOURNEY_OUTPUT || '../artifacts/rc8-release-journeys',
|
||||||
|
}
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const value = argv[index]
|
||||||
|
if (value === '--base-url') parsed.baseUrl = argv[++index]
|
||||||
|
else if (value === '--golden-manifest') parsed.goldenManifest = argv[++index]
|
||||||
|
else if (value === '--output') parsed.output = argv[++index]
|
||||||
|
else throw new Error(`Unknown release journey argument: ${value}`)
|
||||||
|
}
|
||||||
|
parsed.baseUrl = parsed.baseUrl.replace(/\/$/, '')
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseJson(response, label, { envelope = true } = {}) {
|
||||||
|
const text = await response.text()
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} returned non-JSON data: ${text.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`${label} failed with HTTP ${response.status()}: ${JSON.stringify(payload)}`)
|
||||||
|
}
|
||||||
|
if (!envelope) return payload
|
||||||
|
assert(payload && Object.hasOwn(payload, 'data'), `${label} did not return the canonical data envelope`)
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiCall(api, method, url, data, options = {}) {
|
||||||
|
const response = await api.fetch(url, {
|
||||||
|
method,
|
||||||
|
data,
|
||||||
|
timeout: options.timeout || 60_000,
|
||||||
|
})
|
||||||
|
return responseJson(response, options.label || `${method} ${url}`, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bboxForArea(area) {
|
||||||
|
return {
|
||||||
|
min_x: area.bbox.minx,
|
||||||
|
min_y: area.bbox.miny,
|
||||||
|
max_x: area.bbox.maxx,
|
||||||
|
max_y: area.bbox.maxy,
|
||||||
|
crs: 'EPSG:4326',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverageBbox(area) {
|
||||||
|
return {
|
||||||
|
minx: area.bbox.minx,
|
||||||
|
miny: area.bbox.miny,
|
||||||
|
maxx: area.bbox.maxx,
|
||||||
|
maxy: area.bbox.maxy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntilEnabled(locator, timeout = 20_000) {
|
||||||
|
const started = Date.now()
|
||||||
|
while (Date.now() - started < timeout) {
|
||||||
|
if (await locator.isEnabled().catch(() => false)) return
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
}
|
||||||
|
throw new Error(`Control did not become enabled: ${await locator.getAttribute('aria-label').catch(() => '')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectProject(page, projectId) {
|
||||||
|
await page.getByTestId('workspace-nav-data').click()
|
||||||
|
await page.getByTestId('project-panel').waitFor({ state: 'visible' })
|
||||||
|
const selector = page.getByTestId(`project-select-${projectId}`)
|
||||||
|
if (!await selector.isVisible().catch(() => false)) {
|
||||||
|
const management = page.locator('details.technical-management-block')
|
||||||
|
if (!await management.getAttribute('open')) {
|
||||||
|
await management.locator(':scope > summary').click()
|
||||||
|
}
|
||||||
|
const alternatives = management.locator('details.technical-run-list')
|
||||||
|
if (await alternatives.count() && !await alternatives.getAttribute('open')) {
|
||||||
|
await alternatives.locator(':scope > summary').click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await selector.waitFor({ state: 'visible', timeout: 15_000 })
|
||||||
|
const dataResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes(`/api/v1/projects/${projectId}/datasets`) && response.ok(),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
await selector.click()
|
||||||
|
await dataResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runApiJourneys(api, goldenManifest, evidence) {
|
||||||
|
const ready = await apiCall(api, 'GET', '/health/ready', undefined, {
|
||||||
|
label: 'readiness',
|
||||||
|
envelope: false,
|
||||||
|
})
|
||||||
|
assert(ready.status === 'ok', `Runtime readiness is ${ready.status}`)
|
||||||
|
evidence.runtime = {
|
||||||
|
build_sha: ready.build_sha,
|
||||||
|
build_time: ready.build_time,
|
||||||
|
postgis: ready.postgis,
|
||||||
|
migration: ready.migration,
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await apiCall(api, 'GET', '/api/v1/projects?limit=200')
|
||||||
|
const projectsByName = new Map(projects.items.map((project) => [project.name, project]))
|
||||||
|
const nationalProject = projectsByName.get('Belgium and North Sea Workbench')
|
||||||
|
const molProject = projectsByName.get('Mol Municipality Workbench')
|
||||||
|
assert(nationalProject, 'National release workspace is missing')
|
||||||
|
assert(molProject, 'Mol regression workspace is missing')
|
||||||
|
|
||||||
|
const coverageResults = []
|
||||||
|
for (const area of goldenManifest.areas) {
|
||||||
|
const result = await apiCall(api, 'POST', '/api/v1/external/coverage/resolve', {
|
||||||
|
project_id: nationalProject.id,
|
||||||
|
bbox: coverageBbox(area),
|
||||||
|
themes: ['admin', 'buildings', 'population', 'bathymetry'],
|
||||||
|
})
|
||||||
|
for (const zone of area.expected_zones) {
|
||||||
|
assert(result.intersected_zones.includes(zone), `${area.key} did not resolve expected zone ${zone}`)
|
||||||
|
}
|
||||||
|
coverageResults.push({
|
||||||
|
key: area.key,
|
||||||
|
zones: result.intersected_zones,
|
||||||
|
outside_supported_scope: result.outside_supported_scope,
|
||||||
|
statuses: result.items.map((item) => ({
|
||||||
|
zone: item.zone,
|
||||||
|
theme: item.theme,
|
||||||
|
status: item.status,
|
||||||
|
})),
|
||||||
|
warnings: result.warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
evidence.coverage = coverageResults
|
||||||
|
|
||||||
|
const outsideCoverage = await apiCall(api, 'POST', '/api/v1/external/coverage/resolve', {
|
||||||
|
project_id: nationalProject.id,
|
||||||
|
bbox: { minx: 10.0, miny: 55.0, maxx: 10.1, maxy: 55.1 },
|
||||||
|
themes: ['admin'],
|
||||||
|
})
|
||||||
|
assert(outsideCoverage.outside_supported_scope, 'Outside-scope selection was not marked outside')
|
||||||
|
assert(outsideCoverage.intersected_zones.length === 0, 'Outside-scope selection resolved a Belgian zone')
|
||||||
|
|
||||||
|
const northSeaArea = goldenManifest.areas.find((area) => area.key === 'north_sea_multi_zone')
|
||||||
|
const northSeaCoverage = coverageResults.find((result) => result.key === 'north_sea_multi_zone')
|
||||||
|
assert(northSeaArea && northSeaCoverage, 'North Sea golden Area evidence is missing')
|
||||||
|
assert(
|
||||||
|
northSeaCoverage.statuses.some((item) => item.theme === 'population' && item.status === 'unsupported'),
|
||||||
|
'Unsupported North Sea population metric was not explicit',
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
coverageResults.some((result) => result.statuses.some((item) => item.status === 'partial')),
|
||||||
|
'No golden selection exercised partial source coverage',
|
||||||
|
)
|
||||||
|
evidence.edge_cases = {
|
||||||
|
outside_scope: {
|
||||||
|
zones: outsideCoverage.intersected_zones,
|
||||||
|
warning: outsideCoverage.warnings[0],
|
||||||
|
},
|
||||||
|
partial_coverage: true,
|
||||||
|
unsupported_north_sea_population: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const molArea = goldenManifest.areas.find((area) => area.key === 'mol_municipality')
|
||||||
|
assert(molArea, 'Mol golden Area evidence is missing')
|
||||||
|
assert(molArea.source_area_id, 'Mol regression source Area evidence is missing')
|
||||||
|
const temporalSeries = await apiCall(api, 'GET', `/api/v1/projects/${molProject.id}/temporal/series`)
|
||||||
|
const forestSeries = temporalSeries.items.find(
|
||||||
|
(series) => series.temporal_series_key === 'department-omgeving:land-use:forest:mol',
|
||||||
|
)
|
||||||
|
assert(forestSeries && forestSeries.datasets.length >= 2, 'Mol forest history has fewer than two snapshots')
|
||||||
|
const earlier = forestSeries.datasets[0]
|
||||||
|
const later = forestSeries.datasets[forestSeries.datasets.length - 1]
|
||||||
|
const temporal = await apiCall(api, 'POST', `/api/v1/projects/${molProject.id}/temporal/compare`, {
|
||||||
|
earlier_dataset_id: earlier.id,
|
||||||
|
later_dataset_id: later.id,
|
||||||
|
bbox: bboxForArea(molArea),
|
||||||
|
area_id: molArea.source_area_id,
|
||||||
|
preview_limit: 100,
|
||||||
|
}, { timeout: 120_000, label: 'Mol temporal comparison' })
|
||||||
|
assert(temporal.metric && Number.isFinite(temporal.metric.earlier_value), 'Temporal comparison has no governed metric')
|
||||||
|
evidence.temporal = {
|
||||||
|
series: temporal.temporal_series_key,
|
||||||
|
earlier: temporal.earlier,
|
||||||
|
later: temporal.later,
|
||||||
|
metric: temporal.metric,
|
||||||
|
warning_count: temporal.warnings.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const molDatasets = await apiCall(api, 'GET', `/api/v1/projects/${molProject.id}/datasets?limit=200`)
|
||||||
|
const exportDataset = molDatasets.items.find(
|
||||||
|
(dataset) => dataset.source_name === 'statbel' && dataset.dataset_type === 'vector',
|
||||||
|
)
|
||||||
|
assert(exportDataset, 'No bounded Mol vector dataset is available for export')
|
||||||
|
const exported = await apiCall(api, 'POST', '/api/v1/exports/map-result', {
|
||||||
|
project_id: molProject.id,
|
||||||
|
mode: 'current',
|
||||||
|
bbox: bboxForArea(molArea),
|
||||||
|
dataset_id: exportDataset.id,
|
||||||
|
area_id: molArea.source_area_id,
|
||||||
|
theme_id: 'population',
|
||||||
|
name: 'RC8 Mol population map result',
|
||||||
|
}, { timeout: 120_000, label: 'bounded map export' })
|
||||||
|
assert(exported.status === 'ready', `Map result export status is ${exported.status}`)
|
||||||
|
const exportContent = await apiCall(api, 'GET', `/api/v1/exports/${exported.export_id}/content`)
|
||||||
|
assert(exportContent.content?.type === 'FeatureCollection', 'Persisted map export is not GeoJSON')
|
||||||
|
evidence.export = {
|
||||||
|
export_id: exported.export_id,
|
||||||
|
export_type: exported.export_type,
|
||||||
|
feature_count: exportContent.content.features.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantStatus = await apiCall(api, 'GET', '/api/v1/assistant/status')
|
||||||
|
assert(assistantStatus.enabled && assistantStatus.reachable, 'Configured Ollama assistant is not reachable')
|
||||||
|
evidence.assistant = {
|
||||||
|
status: assistantStatus.status,
|
||||||
|
model: assistantStatus.default_model,
|
||||||
|
api_context_verified: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const demo = await apiCall(api, 'POST', '/api/v1/demo/workflow', {})
|
||||||
|
assert(demo.status === 'ready' && demo.raster_dataset_id, 'Demo PostGIS fixture is not ready')
|
||||||
|
const modelAssets = await apiCall(api, 'GET', '/api/v1/detection/model-assets')
|
||||||
|
const preferredAsset = modelAssets.items.find((asset) => asset.active)
|
||||||
|
|| modelAssets.items.find((asset) => asset.status === 'available')
|
||||||
|
assert(preferredAsset, 'No local YOLO model asset is available')
|
||||||
|
evidence.detection = {
|
||||||
|
project_id: demo.project_id,
|
||||||
|
raster_dataset_id: demo.raster_dataset_id,
|
||||||
|
model_asset_id: preferredAsset.model_asset_id,
|
||||||
|
analysis_run_id: null,
|
||||||
|
status: 'pending_browser_run',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nationalProject,
|
||||||
|
molProject,
|
||||||
|
molArea,
|
||||||
|
demo,
|
||||||
|
preferredAsset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBrowserJourneys(baseUrl, browserData, goldenManifest, outputDir, evidence) {
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } })
|
||||||
|
const consoleErrors = []
|
||||||
|
const failedRequests = []
|
||||||
|
let expectedCoverageFailure = false
|
||||||
|
let expectedCoverageConsoleErrors = 0
|
||||||
|
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() !== 'error') return
|
||||||
|
if (
|
||||||
|
expectedCoverageConsoleErrors > 0
|
||||||
|
&& /Failed to load resource: net::ERR_CONNECTION_FAILED/i.test(message.text())
|
||||||
|
) {
|
||||||
|
expectedCoverageConsoleErrors -= 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
consoleErrors.push(message.text())
|
||||||
|
})
|
||||||
|
page.on('pageerror', (error) => consoleErrors.push(error.message))
|
||||||
|
page.on('requestfailed', (requestValue) => {
|
||||||
|
if (requestValue.url().startsWith(baseUrl) && requestValue.url().includes('/api/')) {
|
||||||
|
if (!(expectedCoverageFailure && requestValue.url().includes('/external/coverage/resolve'))) {
|
||||||
|
failedRequests.push(`${requestValue.method()} ${requestValue.url()}: ${requestValue.failure()?.errorText}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.url().startsWith(baseUrl) && response.url().includes('/api/') && response.status() >= 500) {
|
||||||
|
failedRequests.push(`${response.request().method()} ${response.url()}: HTTP ${response.status()}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
|
||||||
|
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 })
|
||||||
|
await page.screenshot({ path: path.join(outputDir, '01-belgium-map.png') })
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Geavanceerde werkbank' }).click()
|
||||||
|
await page.getByTestId('map-area-select').waitFor({ state: 'visible' })
|
||||||
|
const nationalAreas = goldenManifest.areas
|
||||||
|
const browserCoverage = []
|
||||||
|
for (const area of nationalAreas) {
|
||||||
|
await page.getByTestId('map-area-select').selectOption(area.area_id)
|
||||||
|
const useAreaButton = page.getByRole('button', { name: 'Begrenzing werkgebied gebruiken' })
|
||||||
|
await waitUntilEnabled(useAreaButton)
|
||||||
|
const coverageResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/api/v1/external/coverage/resolve') && response.ok(),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
await useAreaButton.click()
|
||||||
|
const response = await coverageResponse
|
||||||
|
const coverage = await responseJson(response, `browser coverage ${area.key}`)
|
||||||
|
for (const zone of area.expected_zones) {
|
||||||
|
assert(coverage.intersected_zones.includes(zone), `Browser journey ${area.key} missed ${zone}`)
|
||||||
|
}
|
||||||
|
await page.locator('.coverage-resolution-surface').waitFor({ state: 'visible' })
|
||||||
|
browserCoverage.push({ key: area.key, zones: coverage.intersected_zones })
|
||||||
|
}
|
||||||
|
evidence.browser.coverage = browserCoverage
|
||||||
|
await page.screenshot({ path: path.join(outputDir, '02-national-coverage.png') })
|
||||||
|
|
||||||
|
const providerFailureArea = nationalAreas[0]
|
||||||
|
expectedCoverageFailure = true
|
||||||
|
expectedCoverageConsoleErrors = 1
|
||||||
|
await page.route('**/api/v1/external/coverage/resolve', (route) => route.abort('connectionfailed'))
|
||||||
|
await page.getByTestId('map-area-select').selectOption(providerFailureArea.area_id)
|
||||||
|
const failedUseAreaButton = page.getByRole('button', { name: 'Begrenzing werkgebied gebruiken' })
|
||||||
|
await waitUntilEnabled(failedUseAreaButton)
|
||||||
|
await failedUseAreaButton.click()
|
||||||
|
await page.locator('.coverage-resolution-surface .error').waitFor({ state: 'visible', timeout: 15_000 })
|
||||||
|
evidence.browser.provider_failure_state = await page.locator('.coverage-resolution-surface .error').innerText()
|
||||||
|
await page.unroute('**/api/v1/external/coverage/resolve')
|
||||||
|
expectedCoverageFailure = false
|
||||||
|
|
||||||
|
await selectProject(page, browserData.molProject.id)
|
||||||
|
await page.getByTestId('workspace-nav-map').click()
|
||||||
|
const backToExplorer = page.getByRole('button', { name: 'Terug naar gebiedsverkenner' })
|
||||||
|
if (await backToExplorer.isVisible().catch(() => false)) await backToExplorer.click()
|
||||||
|
await page.getByLabel('Werkgebied').selectOption(browserData.molArea.source_area_id)
|
||||||
|
const forestTheme = page.getByRole('button', { name: /^Bos & groen/ })
|
||||||
|
await waitUntilEnabled(forestTheme)
|
||||||
|
await forestTheme.click()
|
||||||
|
const fullArea = page.getByRole('button', { name: 'Volledig werkgebied' })
|
||||||
|
await waitUntilEnabled(fullArea)
|
||||||
|
await fullArea.click()
|
||||||
|
await page.locator('.geo-primary-metrics').waitFor({ state: 'visible', timeout: 120_000 })
|
||||||
|
const currentMetrics = await page.locator('.geo-primary-metrics').innerText()
|
||||||
|
assert(/ha|km2|objecten|inwoners/i.test(currentMetrics), 'Mol map did not render a governed metric')
|
||||||
|
const sourceSummary = await page.locator('.geo-source-summary').innerText()
|
||||||
|
assert(!/Geen databron beschikbaar/i.test(sourceSummary), 'Mol map provenance has no active source')
|
||||||
|
evidence.browser.current_metric = currentMetrics
|
||||||
|
evidence.browser.current_provenance = sourceSummary
|
||||||
|
await page.screenshot({ path: path.join(outputDir, '03-mol-current-metric.png') })
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Evolutie' }).click()
|
||||||
|
const evolutionForest = page.getByRole('button', { name: /^Bos & groen/ })
|
||||||
|
await waitUntilEnabled(evolutionForest)
|
||||||
|
await evolutionForest.click()
|
||||||
|
const compareResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/temporal/compare') && response.ok(),
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
await page.getByRole('button', { name: 'Volledig werkgebied' }).click()
|
||||||
|
await compareResponse
|
||||||
|
await page.locator('.geo-temporal-metrics').waitFor({ state: 'visible', timeout: 120_000 })
|
||||||
|
evidence.browser.temporal_metric = await page.locator('.geo-temporal-metrics').innerText()
|
||||||
|
await page.screenshot({ path: path.join(outputDir, '04-mol-evolution.png') })
|
||||||
|
|
||||||
|
const exportResponsePromise = page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/api/v1/exports/map-result') && response.ok(),
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
await page.getByRole('button', { name: 'Bewaar in downloads' }).click()
|
||||||
|
const browserExportResponse = await exportResponsePromise
|
||||||
|
const browserExport = await responseJson(browserExportResponse, 'browser map export')
|
||||||
|
evidence.browser.export_id = browserExport.export_id
|
||||||
|
await page.getByTestId('workspace-nav-exports').waitFor({ state: 'visible' })
|
||||||
|
await page.screenshot({ path: path.join(outputDir, '05-export-center.png') })
|
||||||
|
|
||||||
|
await page.getByTestId('workspace-nav-assistant').click()
|
||||||
|
await page.getByTestId('geo-assistant-panel').waitFor({ state: 'visible' })
|
||||||
|
const question = 'Vat de gemeten evolutie en belangrijkste bronnen voor dit geselecteerde gebied samen in maximaal drie zinnen.'
|
||||||
|
await page.getByLabel('Vraag over het actieve gebied').fill(question)
|
||||||
|
const assistantResponsePromise = page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/assistant/query') && response.ok(),
|
||||||
|
{ timeout: 240_000 },
|
||||||
|
)
|
||||||
|
await page.getByRole('button', { name: 'Stel vraag' }).click()
|
||||||
|
const assistantResponse = await assistantResponsePromise
|
||||||
|
const assistant = await responseJson(assistantResponse, 'browser Ollama query')
|
||||||
|
assert(assistant.answer?.trim().length > 20, 'Ollama returned no usable answer')
|
||||||
|
await page.locator('.assistant-message-assistant').waitFor({ state: 'visible', timeout: 30_000 })
|
||||||
|
evidence.assistant.api_context_verified = true
|
||||||
|
evidence.assistant.answer_length = assistant.answer.length
|
||||||
|
evidence.assistant.context_metric_count = assistant.context_metrics.length
|
||||||
|
evidence.assistant.temporal_series_count = assistant.temporal_series.length
|
||||||
|
evidence.assistant.source_dataset_count = assistant.source_dataset_ids.length
|
||||||
|
await page.screenshot({ path: path.join(outputDir, '06-ollama-context.png') })
|
||||||
|
|
||||||
|
await selectProject(page, browserData.demo.project_id)
|
||||||
|
await page.getByTestId('workspace-nav-ai').click()
|
||||||
|
const detectionPanel = page.locator('.detection-lab-shell')
|
||||||
|
await detectionPanel.waitFor({ state: 'visible', timeout: 30_000 })
|
||||||
|
const detectionRunSelects = detectionPanel.locator('.lab-form-grid select')
|
||||||
|
await detectionRunSelects.first().waitFor({ state: 'visible', timeout: 30_000 })
|
||||||
|
await detectionRunSelects.nth(0).selectOption(browserData.demo.raster_dataset_id)
|
||||||
|
await detectionRunSelects.nth(1).selectOption('yolo-configured')
|
||||||
|
const localModelDetails = detectionPanel.locator('details[aria-label="Lokale modelkeuze"]')
|
||||||
|
if (!await localModelDetails.getAttribute('open')) {
|
||||||
|
await localModelDetails.locator(':scope > summary').click()
|
||||||
|
}
|
||||||
|
await localModelDetails.locator('select').selectOption(browserData.preferredAsset.model_asset_id)
|
||||||
|
await detectionPanel.locator('.ai-lab-run-surface .lab-form-grid input[type="number"]').fill('0.50')
|
||||||
|
const detectionAction = detectionPanel.getByRole('button', { name: 'Gebouwen zoeken en op kaart tonen' })
|
||||||
|
await waitUntilEnabled(detectionAction, 30_000)
|
||||||
|
const detectionResponsePromise = page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/api/v1/detection/run') && response.ok(),
|
||||||
|
{ timeout: 240_000 },
|
||||||
|
)
|
||||||
|
await detectionAction.click()
|
||||||
|
const detectionResponse = await detectionResponsePromise
|
||||||
|
const detection = await responseJson(detectionResponse, 'browser configured YOLO run')
|
||||||
|
assert(detection.status === 'success', `Configured YOLO browser run status is ${detection.status}`)
|
||||||
|
evidence.detection.analysis_run_id = detection.analysis_run_id
|
||||||
|
evidence.detection.job_id = detection.job_id
|
||||||
|
evidence.detection.detection_count = detection.detection_count
|
||||||
|
evidence.detection.status = detection.status
|
||||||
|
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 60_000 })
|
||||||
|
const postDetectionMapText = await page.getByTestId('map-workspace').innerText()
|
||||||
|
assert(
|
||||||
|
/detectie|gebouw|analyse/i.test(postDetectionMapText),
|
||||||
|
'Successful detection did not hand its persisted result back to the map',
|
||||||
|
)
|
||||||
|
evidence.detection.map_handoff_verified = true
|
||||||
|
await page.screenshot({ path: path.join(outputDir, '07-configured-yolo.png') })
|
||||||
|
|
||||||
|
const frameworkErrorText = await page.locator('body').innerText()
|
||||||
|
assert(
|
||||||
|
!/Unhandled Runtime Error|Cannot read properties|ReferenceError|TypeError:/i.test(frameworkErrorText),
|
||||||
|
'A frontend runtime error is visible',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
evidence.browser.console_errors = consoleErrors
|
||||||
|
evidence.browser.failed_requests = failedRequests
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(consoleErrors.length === 0, `Browser console errors: ${consoleErrors.join('\n')}`)
|
||||||
|
assert(failedRequests.length === 0, `Unexpected API failures: ${failedRequests.join('\n')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArguments(process.argv.slice(2))
|
||||||
|
const goldenManifest = JSON.parse(await readFile(args.goldenManifest, 'utf8'))
|
||||||
|
assert(goldenManifest.area_count === 7, `Expected seven golden Areas, got ${goldenManifest.area_count}`)
|
||||||
|
await mkdir(args.output, { recursive: true })
|
||||||
|
const evidence = {
|
||||||
|
schema_version: 1,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
base_url: args.baseUrl,
|
||||||
|
golden_area_manifest: path.resolve(args.goldenManifest),
|
||||||
|
runtime: null,
|
||||||
|
coverage: [],
|
||||||
|
edge_cases: {},
|
||||||
|
temporal: null,
|
||||||
|
export: null,
|
||||||
|
assistant: null,
|
||||||
|
detection: null,
|
||||||
|
browser: {
|
||||||
|
coverage: [],
|
||||||
|
provider_failure_state: null,
|
||||||
|
current_metric: null,
|
||||||
|
current_provenance: null,
|
||||||
|
temporal_metric: null,
|
||||||
|
export_id: null,
|
||||||
|
console_errors: [],
|
||||||
|
failed_requests: [],
|
||||||
|
},
|
||||||
|
status: 'running',
|
||||||
|
}
|
||||||
|
const evidencePath = path.join(args.output, 'manifest.json')
|
||||||
|
const api = await request.newContext({ baseURL: args.baseUrl })
|
||||||
|
try {
|
||||||
|
const browserData = await runApiJourneys(api, goldenManifest, evidence)
|
||||||
|
await runBrowserJourneys(args.baseUrl, browserData, goldenManifest, args.output, evidence)
|
||||||
|
evidence.status = 'passed'
|
||||||
|
evidence.completed_at = new Date().toISOString()
|
||||||
|
} catch (error) {
|
||||||
|
evidence.status = 'failed'
|
||||||
|
evidence.completed_at = new Date().toISOString()
|
||||||
|
evidence.error = error instanceof Error ? error.stack || error.message : String(error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await api.dispose()
|
||||||
|
await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8')
|
||||||
|
console.log(`Release journey evidence: ${evidencePath}`)
|
||||||
|
}
|
||||||
|
console.log('RC8 Belgium/North Sea release journeys passed')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Generated
+1192
-4
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
|||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:e2e": "node e2e/releaseJourneys.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
@@ -18,10 +20,14 @@
|
|||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^7.3.6"
|
"vite": "^7.3.6",
|
||||||
|
"vitest": "^3.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
bboxesEqual,
|
||||||
|
normalizeBboxFromCorners,
|
||||||
|
parseBboxInput,
|
||||||
|
resultMetricLabel,
|
||||||
|
selectionAreaSquareMetres,
|
||||||
|
} from './mapWorkspaceUtils'
|
||||||
|
import type { VectorSelectionResponse } from '../../types'
|
||||||
|
|
||||||
|
describe('map workspace selection guards', () => {
|
||||||
|
it('normalizes drag corners into an EPSG:4326 bbox', () => {
|
||||||
|
expect(normalizeBboxFromCorners([5.2, 51.3], [4.8, 50.9])).toEqual({
|
||||||
|
min_x: 4.8,
|
||||||
|
min_y: 50.9,
|
||||||
|
max_x: 5.2,
|
||||||
|
max_y: 51.3,
|
||||||
|
crs: 'EPSG:4326',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty, inverted and degenerate manual selections', () => {
|
||||||
|
expect(parseBboxInput({ min_x: '', min_y: '', max_x: '', max_y: '' })).toBeNull()
|
||||||
|
expect(parseBboxInput({ min_x: '5', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
|
||||||
|
expect(parseBboxInput({ min_x: '4', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats sub-nanodegree bbox drift as the same persisted selection', () => {
|
||||||
|
const bbox = normalizeBboxFromCorners([4.98, 51.15], [5.02, 51.19])
|
||||||
|
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-10 })).toBe(true)
|
||||||
|
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-5 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes a positive bounded selection area and renders governed metrics', () => {
|
||||||
|
const bbox = normalizeBboxFromCorners([5.0, 51.0], [5.01, 51.01])
|
||||||
|
expect(selectionAreaSquareMetres(bbox)).toBeGreaterThan(700_000)
|
||||||
|
expect(selectionAreaSquareMetres(bbox)).toBeLessThan(900_000)
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
feature_count: 12,
|
||||||
|
truncated: false,
|
||||||
|
summary: {
|
||||||
|
metric_key: 'forest_area',
|
||||||
|
metric_value: 14.236,
|
||||||
|
metric_unit: 'ha',
|
||||||
|
},
|
||||||
|
} as unknown as VectorSelectionResponse
|
||||||
|
expect(resultMetricLabel(result)).toBe('14,24 ha')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { CoverageResolveResponse, VectorSelectionBBox } from '../types'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
resolveCoverage: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/api', () => ({
|
||||||
|
externalApi: {
|
||||||
|
resolveCoverage: mocks.resolveCoverage,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useCoverageResolver } from './useCoverageResolver'
|
||||||
|
|
||||||
|
const bbox: VectorSelectionBBox = {
|
||||||
|
min_x: 4.98,
|
||||||
|
min_y: 51.15,
|
||||||
|
max_x: 5.02,
|
||||||
|
max_y: 51.19,
|
||||||
|
crs: 'EPSG:4326',
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverageResult = {
|
||||||
|
intersected_zones: ['flanders'],
|
||||||
|
outside_supported_scope: false,
|
||||||
|
items: [],
|
||||||
|
warnings: [],
|
||||||
|
} as unknown as CoverageResolveResponse
|
||||||
|
|
||||||
|
describe('useCoverageResolver', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
mocks.resolveCoverage.mockResolvedValue(coverageResult)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not query until both project and bbox exist', async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId, selection }) => useCoverageResolver({ projectId, bbox: selection }),
|
||||||
|
{ initialProps: { projectId: 'project-1' as string | null, selection: null as VectorSelectionBBox | null } },
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(500)
|
||||||
|
})
|
||||||
|
expect(mocks.resolveCoverage).not.toHaveBeenCalled()
|
||||||
|
expect(result.current.loadingCoverage).toBe(false)
|
||||||
|
|
||||||
|
rerender({ projectId: 'project-1', selection: bbox })
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(249)
|
||||||
|
})
|
||||||
|
expect(mocks.resolveCoverage).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
})
|
||||||
|
expect(result.current.coverage).toEqual(coverageResult)
|
||||||
|
expect(mocks.resolveCoverage).toHaveBeenCalledWith({
|
||||||
|
projectId: 'project-1',
|
||||||
|
bbox: { minx: 4.98, miny: 51.15, maxx: 5.02, maxy: 51.19 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears stale coverage when the selection is removed', async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ selection }) => useCoverageResolver({ projectId: 'project-1', bbox: selection }),
|
||||||
|
{ initialProps: { selection: bbox as VectorSelectionBBox | null } },
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(250)
|
||||||
|
})
|
||||||
|
expect(result.current.coverage).toEqual(coverageResult)
|
||||||
|
|
||||||
|
rerender({ selection: null })
|
||||||
|
expect(result.current.coverage).toBeNull()
|
||||||
|
expect(result.current.coverageError).toBeNull()
|
||||||
|
expect(result.current.loadingCoverage).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes provider failures without retaining stale results', async () => {
|
||||||
|
mocks.resolveCoverage.mockRejectedValueOnce(new Error('provider unavailable'))
|
||||||
|
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(250)
|
||||||
|
})
|
||||||
|
expect(result.current.coverageError).toBe('provider unavailable')
|
||||||
|
expect(result.current.coverage).toBeNull()
|
||||||
|
expect(result.current.loadingCoverage).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { TemporalComparisonResponse, VectorSelectionBBox } from '../types'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
compare: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/api/temporal', () => ({
|
||||||
|
temporalApi: {
|
||||||
|
compare: mocks.compare,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useTemporalComparison } from './useTemporalComparison'
|
||||||
|
|
||||||
|
const bbox: VectorSelectionBBox = {
|
||||||
|
min_x: 4.98,
|
||||||
|
min_y: 51.15,
|
||||||
|
max_x: 5.02,
|
||||||
|
max_y: 51.19,
|
||||||
|
crs: 'EPSG:4326',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTemporalComparison', () => {
|
||||||
|
it('fails locally when no project is active', async () => {
|
||||||
|
const { result } = renderHook(() => useTemporalComparison(null))
|
||||||
|
let response: TemporalComparisonResponse | null = null
|
||||||
|
await act(async () => {
|
||||||
|
response = await result.current.compareTemporalSnapshots('earlier', 'later', bbox)
|
||||||
|
})
|
||||||
|
expect(response).toBeNull()
|
||||||
|
expect(result.current.temporalComparisonError).toContain('project')
|
||||||
|
expect(mocks.compare).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires two explicit snapshots before calling the API', async () => {
|
||||||
|
const { result } = renderHook(() => useTemporalComparison('project-1'))
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.compareTemporalSnapshots('', 'later', bbox)
|
||||||
|
})
|
||||||
|
expect(result.current.temporalComparisonError).toContain('twee meetmomenten')
|
||||||
|
expect(mocks.compare).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists the compatible comparison result in hook state', async () => {
|
||||||
|
const comparison = {
|
||||||
|
project_id: 'project-1',
|
||||||
|
earlier_dataset_id: 'earlier',
|
||||||
|
later_dataset_id: 'later',
|
||||||
|
warnings: [],
|
||||||
|
} as unknown as TemporalComparisonResponse
|
||||||
|
mocks.compare.mockResolvedValueOnce(comparison)
|
||||||
|
const { result } = renderHook(() => useTemporalComparison('project-1'))
|
||||||
|
|
||||||
|
let response: TemporalComparisonResponse | null = null
|
||||||
|
await act(async () => {
|
||||||
|
response = await result.current.compareTemporalSnapshots('earlier', 'later', bbox, 'area-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response).toEqual(comparison)
|
||||||
|
expect(result.current.temporalComparison).toEqual(comparison)
|
||||||
|
expect(result.current.temporalComparisonError).toBeNull()
|
||||||
|
expect(mocks.compare).toHaveBeenCalledWith('project-1', {
|
||||||
|
earlier_dataset_id: 'earlier',
|
||||||
|
later_dataset_id: 'later',
|
||||||
|
bbox,
|
||||||
|
area_id: 'area-1',
|
||||||
|
preview_limit: 500,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useWorkbenchBootstrap } from './useWorkbenchBootstrap'
|
||||||
|
|
||||||
|
function action() {
|
||||||
|
return vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(selectedProjectId: string | null) {
|
||||||
|
return {
|
||||||
|
selectedProjectId,
|
||||||
|
selectedDetectionRunId: '',
|
||||||
|
detectionClassFilter: '',
|
||||||
|
detectionMinConfidenceFilter: 0,
|
||||||
|
selectedSegmentationRunId: '',
|
||||||
|
segmentationClassFilter: '',
|
||||||
|
segmentationMinConfidenceFilter: 0,
|
||||||
|
loadProjects: action(),
|
||||||
|
loadCapabilities: action(),
|
||||||
|
loadDetectionModels: action(),
|
||||||
|
loadSegmentationModels: action(),
|
||||||
|
loadProjectData: action(),
|
||||||
|
loadDetectionRuns: action(),
|
||||||
|
loadSegmentationRuns: action(),
|
||||||
|
loadQualityChecks: action(),
|
||||||
|
loadExports: action(),
|
||||||
|
loadDetectionResults: action(),
|
||||||
|
loadSegmentationResults: action(),
|
||||||
|
resetProjectData: vi.fn(),
|
||||||
|
resetDatasetForProject: vi.fn(),
|
||||||
|
resetDetectionForProject: vi.fn(),
|
||||||
|
resetSegmentationForProject: vi.fn(),
|
||||||
|
resetExportsForProject: vi.fn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useWorkbenchBootstrap', () => {
|
||||||
|
it('loads global capabilities and clears project-owned state without a project', async () => {
|
||||||
|
const state = options(null)
|
||||||
|
renderHook(() => useWorkbenchBootstrap(state))
|
||||||
|
|
||||||
|
await waitFor(() => expect(state.loadProjects).toHaveBeenCalledOnce())
|
||||||
|
expect(state.loadCapabilities).toHaveBeenCalledOnce()
|
||||||
|
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
|
||||||
|
expect(state.loadSegmentationModels).toHaveBeenCalledOnce()
|
||||||
|
expect(state.resetProjectData).toHaveBeenCalledOnce()
|
||||||
|
expect(state.resetDatasetForProject).toHaveBeenCalledOnce()
|
||||||
|
expect(state.loadProjectData).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets stale state before loading every project-owned collection', async () => {
|
||||||
|
const state = options('project-1')
|
||||||
|
renderHook(() => useWorkbenchBootstrap(state))
|
||||||
|
|
||||||
|
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
|
||||||
|
expect(state.resetProjectData).toHaveBeenCalledOnce()
|
||||||
|
expect(state.resetDatasetForProject).toHaveBeenCalledOnce()
|
||||||
|
expect(state.resetDetectionForProject).toHaveBeenCalledOnce()
|
||||||
|
expect(state.resetSegmentationForProject).toHaveBeenCalledOnce()
|
||||||
|
expect(state.resetExportsForProject).toHaveBeenCalledOnce()
|
||||||
|
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
|
||||||
|
expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1')
|
||||||
|
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
|
||||||
|
expect(state.loadExports).toHaveBeenCalledWith('project-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
declare const process: { env: Record<string, string | undefined> }
|
declare const process: { env: Record<string, string | undefined> }
|
||||||
@@ -7,6 +7,12 @@ const apiProxyTarget = process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:80
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
clearMocks: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 900,
|
chunkSizeWarningLimit: 900,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
@@ -2036,3 +2036,36 @@ bash scripts/rotate_postgres_password.sh \
|
|||||||
The command atomically updates the operator-owned `.env`, changes the matching
|
The command atomically updates the operator-owned `.env`, changes the matching
|
||||||
PostgreSQL role and recreates the container. A failed role change restores the
|
PostgreSQL role and recreates the container. A failed role change restores the
|
||||||
previous environment file. The generated secret is never printed.
|
previous environment file. The generated secret is never printed.
|
||||||
|
|
||||||
|
## RC-8 Belgium/North Sea release journeys
|
||||||
|
|
||||||
|
Preview the seven release areas without mutating GeoIntel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/provision_release_golden_areas.py \
|
||||||
|
--base-url http://192.168.10.150:1202 \
|
||||||
|
--output artifacts/rc8-golden-areas.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Provision only missing Areas and execute the complete live browser/API release
|
||||||
|
journey:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/run_rc8_release_journeys.sh \
|
||||||
|
http://192.168.10.150:1202 \
|
||||||
|
artifacts/rc8-release-journeys \
|
||||||
|
artifacts/rc8-golden-areas.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper runs the same operator with `--apply`, then starts the Playwright
|
||||||
|
runner. Mol and Kempen retain their official source Area identifiers while
|
||||||
|
bounded copies are made selectable in the national workbench. It validates all
|
||||||
|
land, cross-region, coast and maritime golden areas;
|
||||||
|
governed metrics and provenance; compatible history; no-data, partial,
|
||||||
|
unsupported and provider-failure states; persistent map export; real local
|
||||||
|
Ollama context; and the explicit configured-YOLO fixture journey.
|
||||||
|
|
||||||
|
The command is intentionally live and creates missing Area records, an export,
|
||||||
|
an assistant conversation and explicit technical demo AI records. It never
|
||||||
|
fetches fake production provider data. Evidence is written below the requested
|
||||||
|
ignored `artifacts/` directory as screenshots plus `manifest.json`.
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Provision and fingerprint the bounded Belgium/North Sea RC journey areas.
|
||||||
|
|
||||||
|
The command is dry-run-first. With --apply it creates only missing Area rows
|
||||||
|
through the public API. Existing Mol and Kempen authoritative geometries are
|
||||||
|
copied into the national release workspace and retained as source evidence;
|
||||||
|
no dataset, feature or source artifact is created by this command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = os.environ.get("GE_INTEL_BASE_URL", "http://127.0.0.1:1202")
|
||||||
|
NATIONAL_PROJECT = "Belgium and North Sea Workbench"
|
||||||
|
MOL_PROJECT = "Mol Municipality Workbench"
|
||||||
|
KEMPEN_PROJECT = "Kempen Regional Workbench"
|
||||||
|
|
||||||
|
|
||||||
|
def rectangle(minx: float, miny: float, maxx: float, maxy: float) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "MultiPolygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
[minx, miny],
|
||||||
|
[maxx, miny],
|
||||||
|
[maxx, maxy],
|
||||||
|
[minx, maxy],
|
||||||
|
[minx, miny],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
GOLDEN_AREAS = (
|
||||||
|
{
|
||||||
|
"key": "wallonia_urban_rural",
|
||||||
|
"project": NATIONAL_PROJECT,
|
||||||
|
"name": "RC Golden - Wallonia urban-rural",
|
||||||
|
"geometry": rectangle(4.80, 50.42, 4.95, 50.53),
|
||||||
|
"expected_zones": ["wallonia"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "brussels_urban",
|
||||||
|
"project": NATIONAL_PROJECT,
|
||||||
|
"name": "RC Golden - Brussels urban",
|
||||||
|
"geometry": rectangle(4.32, 50.82, 4.39, 50.88),
|
||||||
|
"expected_zones": ["brussels"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "language_boundary",
|
||||||
|
"project": NATIONAL_PROJECT,
|
||||||
|
"name": "RC Golden - language boundary",
|
||||||
|
"geometry": rectangle(4.05, 50.70, 4.20, 50.80),
|
||||||
|
"expected_zones": ["flanders", "wallonia"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "coast_land_sea",
|
||||||
|
"project": NATIONAL_PROJECT,
|
||||||
|
"name": "RC Golden - coast land-sea",
|
||||||
|
"geometry": rectangle(2.88, 51.20, 2.98, 51.28),
|
||||||
|
"expected_zones": ["flanders", "territorial_sea"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "north_sea_multi_zone",
|
||||||
|
"project": NATIONAL_PROJECT,
|
||||||
|
"name": "RC Golden - North Sea multi-zone",
|
||||||
|
"geometry": rectangle(2.55, 51.35, 2.75, 51.55),
|
||||||
|
"expected_zones": [
|
||||||
|
"territorial_sea",
|
||||||
|
"exclusive_economic_zone",
|
||||||
|
"continental_shelf",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
SOURCE_AREAS: tuple[dict[str, Any], ...] = (
|
||||||
|
{
|
||||||
|
"key": "mol_municipality",
|
||||||
|
"source_project": MOL_PROJECT,
|
||||||
|
"target_name": "RC Golden - Mol municipality",
|
||||||
|
"predicate": lambda name: name.startswith("Gemeente Mol"),
|
||||||
|
"expected_zones": ["flanders"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "kempen_region",
|
||||||
|
"source_project": KEMPEN_PROJECT,
|
||||||
|
"target_name": "RC Golden - Kempen region",
|
||||||
|
"predicate": lambda name: "Vervoerregio Kempen" in name,
|
||||||
|
"expected_zones": ["flanders"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||||
|
parser.add_argument("--output", type=Path)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="Create missing bounded RC Areas through the canonical API.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClient:
|
||||||
|
def __init__(self, base_url: str) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
|
||||||
|
data = None
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
request = Request(f"{self.base_url}{path}", data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=60) as response:
|
||||||
|
body = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc
|
||||||
|
except URLError as exc:
|
||||||
|
raise RuntimeError(f"{method} {path} failed: {exc.reason}") from exc
|
||||||
|
if not isinstance(body, dict) or "data" not in body:
|
||||||
|
raise RuntimeError(f"{method} {path} did not return the canonical data envelope")
|
||||||
|
return body["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_hash(value: Any) -> str:
|
||||||
|
serialized = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||||
|
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def geometry_bbox(geometry: dict[str, Any]) -> dict[str, float]:
|
||||||
|
points: list[tuple[float, float]] = []
|
||||||
|
|
||||||
|
def walk(value: Any) -> None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return
|
||||||
|
if len(value) >= 2 and isinstance(value[0], (int, float)) and isinstance(value[1], (int, float)):
|
||||||
|
points.append((float(value[0]), float(value[1])))
|
||||||
|
return
|
||||||
|
for child in value:
|
||||||
|
walk(child)
|
||||||
|
|
||||||
|
walk(geometry.get("coordinates"))
|
||||||
|
if not points:
|
||||||
|
raise RuntimeError("Golden Area geometry has no coordinates")
|
||||||
|
xs = [point[0] for point in points]
|
||||||
|
ys = [point[1] for point in points]
|
||||||
|
return {"minx": min(xs), "miny": min(ys), "maxx": max(xs), "maxy": max(ys)}
|
||||||
|
|
||||||
|
|
||||||
|
def find_one(items: list[dict[str, Any]], predicate: Callable[[str], bool], label: str) -> dict[str, Any]:
|
||||||
|
matches = [item for item in items if predicate(str(item.get("name", "")))]
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise RuntimeError(f"Expected exactly one {label}; found {len(matches)}")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
client = ApiClient(args.base_url)
|
||||||
|
projects_payload = client.request("GET", "/api/v1/projects?limit=200")
|
||||||
|
projects = projects_payload.get("items", [])
|
||||||
|
projects_by_name = {str(project["name"]): project for project in projects}
|
||||||
|
required_projects = {NATIONAL_PROJECT, MOL_PROJECT, KEMPEN_PROJECT}
|
||||||
|
missing_projects = sorted(required_projects - set(projects_by_name))
|
||||||
|
if missing_projects:
|
||||||
|
raise RuntimeError(f"Required release projects are missing: {', '.join(missing_projects)}")
|
||||||
|
|
||||||
|
areas_by_project: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for project_name in required_projects:
|
||||||
|
project_id = projects_by_name[project_name]["id"]
|
||||||
|
payload = client.request("GET", f"/api/v1/projects/{project_id}/areas?limit=200")
|
||||||
|
areas_by_project[project_name] = list(payload.get("items", []))
|
||||||
|
|
||||||
|
evidence: list[dict[str, Any]] = []
|
||||||
|
missing_area_names: list[str] = []
|
||||||
|
for definition in GOLDEN_AREAS:
|
||||||
|
project_name = str(definition["project"])
|
||||||
|
project = projects_by_name[project_name]
|
||||||
|
existing = [
|
||||||
|
area
|
||||||
|
for area in areas_by_project[project_name]
|
||||||
|
if area.get("name") == definition["name"]
|
||||||
|
]
|
||||||
|
if len(existing) > 1:
|
||||||
|
raise RuntimeError(f"Duplicate release Area name: {definition['name']}")
|
||||||
|
created = False
|
||||||
|
if not existing:
|
||||||
|
if not args.apply:
|
||||||
|
missing_area_names.append(str(definition["name"]))
|
||||||
|
continue
|
||||||
|
area = client.request(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/projects/{project['id']}/areas",
|
||||||
|
{
|
||||||
|
"name": definition["name"],
|
||||||
|
"geometry": definition["geometry"],
|
||||||
|
"crs": "EPSG:4326",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
areas_by_project[project_name].append(area)
|
||||||
|
created = True
|
||||||
|
else:
|
||||||
|
area = existing[0]
|
||||||
|
|
||||||
|
geometry = area.get("geometry")
|
||||||
|
if not isinstance(geometry, dict):
|
||||||
|
raise RuntimeError(f"Area {definition['name']} has no serialized geometry")
|
||||||
|
expected_hash = canonical_hash(definition["geometry"])
|
||||||
|
actual_hash = canonical_hash(geometry)
|
||||||
|
if actual_hash != expected_hash:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Area {definition['name']} geometry drifted: expected {expected_hash}, got {actual_hash}"
|
||||||
|
)
|
||||||
|
evidence.append(
|
||||||
|
{
|
||||||
|
"key": definition["key"],
|
||||||
|
"project_name": project_name,
|
||||||
|
"project_id": str(project["id"]),
|
||||||
|
"area_id": str(area["id"]),
|
||||||
|
"area_name": str(area["name"]),
|
||||||
|
"bbox": geometry_bbox(geometry),
|
||||||
|
"geometry_sha256": actual_hash,
|
||||||
|
"expected_zones": definition["expected_zones"],
|
||||||
|
"created": created,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_area_names:
|
||||||
|
print("Missing release Areas (rerun with --apply):", file=sys.stderr)
|
||||||
|
for name in missing_area_names:
|
||||||
|
print(f"- {name}", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
national_project = projects_by_name[NATIONAL_PROJECT]
|
||||||
|
for definition in SOURCE_AREAS:
|
||||||
|
source_project_name = str(definition["source_project"])
|
||||||
|
source_area = find_one(
|
||||||
|
areas_by_project[source_project_name],
|
||||||
|
definition["predicate"],
|
||||||
|
f"{definition['key']} Area in {source_project_name}",
|
||||||
|
)
|
||||||
|
geometry = source_area.get("geometry")
|
||||||
|
if not isinstance(geometry, dict):
|
||||||
|
raise RuntimeError(f"Area {source_area['name']} has no serialized geometry")
|
||||||
|
|
||||||
|
existing = [
|
||||||
|
area
|
||||||
|
for area in areas_by_project[NATIONAL_PROJECT]
|
||||||
|
if area.get("name") == definition["target_name"]
|
||||||
|
]
|
||||||
|
if len(existing) > 1:
|
||||||
|
raise RuntimeError(f"Duplicate release Area name: {definition['target_name']}")
|
||||||
|
created = False
|
||||||
|
if not existing:
|
||||||
|
if not args.apply:
|
||||||
|
missing_area_names.append(str(definition["target_name"]))
|
||||||
|
continue
|
||||||
|
area = client.request(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/projects/{national_project['id']}/areas",
|
||||||
|
{
|
||||||
|
"name": definition["target_name"],
|
||||||
|
"geometry": geometry,
|
||||||
|
"crs": "EPSG:4326",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
areas_by_project[NATIONAL_PROJECT].append(area)
|
||||||
|
created = True
|
||||||
|
else:
|
||||||
|
area = existing[0]
|
||||||
|
|
||||||
|
target_geometry = area.get("geometry")
|
||||||
|
if not isinstance(target_geometry, dict):
|
||||||
|
raise RuntimeError(f"Area {area['name']} has no serialized geometry")
|
||||||
|
source_hash = canonical_hash(geometry)
|
||||||
|
target_hash = canonical_hash(target_geometry)
|
||||||
|
if target_hash != source_hash:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Area {definition['target_name']} geometry drifted from "
|
||||||
|
f"{source_project_name}: expected {source_hash}, got {target_hash}"
|
||||||
|
)
|
||||||
|
evidence.append(
|
||||||
|
{
|
||||||
|
"key": definition["key"],
|
||||||
|
"project_name": NATIONAL_PROJECT,
|
||||||
|
"project_id": str(national_project["id"]),
|
||||||
|
"area_id": str(area["id"]),
|
||||||
|
"area_name": str(area["name"]),
|
||||||
|
"bbox": geometry_bbox(target_geometry),
|
||||||
|
"geometry_sha256": target_hash,
|
||||||
|
"expected_zones": definition["expected_zones"],
|
||||||
|
"created": created,
|
||||||
|
"source_project_name": source_project_name,
|
||||||
|
"source_project_id": str(projects_by_name[source_project_name]["id"]),
|
||||||
|
"source_area_id": str(source_area["id"]),
|
||||||
|
"source_area_name": str(source_area["name"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_area_names:
|
||||||
|
print("Missing release Areas (rerun with --apply):", file=sys.stderr)
|
||||||
|
for name in missing_area_names:
|
||||||
|
print(f"- {name}", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
evidence.sort(key=lambda item: item["key"])
|
||||||
|
manifest = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"base_url": args.base_url.rstrip("/"),
|
||||||
|
"area_count": len(evidence),
|
||||||
|
"areas": evidence,
|
||||||
|
}
|
||||||
|
output = json.dumps(manifest, indent=2, sort_keys=True)
|
||||||
|
if args.output:
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(f"{output}\n", encoding="utf-8")
|
||||||
|
print(output)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://127.0.0.1:1202}}"
|
||||||
|
OUTPUT_DIR="${2:-artifacts/rc8-release-journeys}"
|
||||||
|
GOLDEN_MANIFEST="${3:-artifacts/rc8-golden-areas.json}"
|
||||||
|
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||||
|
|
||||||
|
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python"
|
||||||
|
fi
|
||||||
|
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
|
||||||
|
echo "Python is required to provision release Areas." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! command -v npm >/dev/null 2>&1; then
|
||||||
|
echo "Node.js/npm is required to execute release journeys." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$PYTHON_BIN" scripts/provision_release_golden_areas.py \
|
||||||
|
--base-url "$BASE_URL" \
|
||||||
|
--output "$GOLDEN_MANIFEST" \
|
||||||
|
--apply
|
||||||
|
|
||||||
|
npm --prefix frontend run test:e2e -- \
|
||||||
|
--base-url "$BASE_URL" \
|
||||||
|
--golden-manifest "../$GOLDEN_MANIFEST" \
|
||||||
|
--output "../$OUTPUT_DIR"
|
||||||
@@ -25,6 +25,17 @@ if [ -z "${PYTHON_BIN}" ]; then
|
|||||||
echo "No usable python interpreter found" >&2
|
echo "No usable python interpreter found" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
NODE_BIN=""
|
||||||
|
for candidate in node node.exe; do
|
||||||
|
if command -v "${candidate}" >/dev/null 2>&1; then
|
||||||
|
NODE_BIN="${candidate}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "${NODE_BIN}" ]; then
|
||||||
|
echo "No usable Node.js interpreter found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "== GeoIntel run readiness check =="
|
echo "== GeoIntel run readiness check =="
|
||||||
|
|
||||||
@@ -87,6 +98,7 @@ ${PYTHON_BIN} -m py_compile scripts/provision_regional_timeseries.py
|
|||||||
${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py
|
${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py
|
||||||
${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py
|
${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py
|
||||||
${PYTHON_BIN} -m py_compile scripts/provision_belgium_north_sea_scope.py
|
${PYTHON_BIN} -m py_compile scripts/provision_belgium_north_sea_scope.py
|
||||||
|
${PYTHON_BIN} -m py_compile scripts/provision_release_golden_areas.py
|
||||||
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_buildings.py
|
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_buildings.py
|
||||||
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_context.py
|
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_context.py
|
||||||
${PYTHON_BIN} -m py_compile scripts/audit_source_freshness.py
|
${PYTHON_BIN} -m py_compile scripts/audit_source_freshness.py
|
||||||
@@ -111,8 +123,10 @@ ${PYTHON_BIN} -m py_compile backend/scripts/cleanup_demo_artifacts.py
|
|||||||
${PYTHON_BIN} -m compileall backend/app
|
${PYTHON_BIN} -m compileall backend/app
|
||||||
(cd backend && ${PYTHON_BIN} -m pytest -W error::DeprecationWarning)
|
(cd backend && ${PYTHON_BIN} -m pytest -W error::DeprecationWarning)
|
||||||
(cd backend && ${PYTHON_BIN} -m alembic heads)
|
(cd backend && ${PYTHON_BIN} -m alembic heads)
|
||||||
|
(cd frontend && npm run test:unit)
|
||||||
(cd frontend && npm run typecheck)
|
(cd frontend && npm run typecheck)
|
||||||
(cd frontend && npm run build)
|
(cd frontend && npm run build)
|
||||||
|
"${NODE_BIN}" --check frontend/e2e/releaseJourneys.mjs
|
||||||
bash -n scripts/live_migration_smoke.sh
|
bash -n scripts/live_migration_smoke.sh
|
||||||
bash -n scripts/deploy_tower.sh
|
bash -n scripts/deploy_tower.sh
|
||||||
bash -n scripts/verify_release_fresh_install.sh
|
bash -n scripts/verify_release_fresh_install.sh
|
||||||
@@ -145,4 +159,5 @@ bash -n scripts/verify_gis_runtime.sh
|
|||||||
bash -n scripts/verify_golden_qa_benchmark.sh
|
bash -n scripts/verify_golden_qa_benchmark.sh
|
||||||
bash -n scripts/verify_demo_cleanup_dry_run.sh
|
bash -n scripts/verify_demo_cleanup_dry_run.sh
|
||||||
bash -n scripts/capture_workbench_screenshots.sh
|
bash -n scripts/capture_workbench_screenshots.sh
|
||||||
|
bash -n scripts/run_rc8_release_journeys.sh
|
||||||
echo "== Run readiness check passed =="
|
echo "== Run readiness check passed =="
|
||||||
|
|||||||
Reference in New Issue
Block a user