Automate RC8 release journeys
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-18 06:07:44 +02:00
parent 0fae53a7de
commit 55685ba1bd
22 changed files with 2665 additions and 11 deletions
+25
View File
@@ -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
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.
## 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.
+498
View File
@@ -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)
})
+1192 -4
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -7,7 +7,9 @@
"start": "vite",
"build": "tsc -b && vite build",
"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": {
"node": "^20.19.0 || >=22.12.0"
@@ -18,10 +20,14 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^26.1.0",
"playwright": "^1.61.1",
"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')
})
})
+7 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
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({
plugins: [react()],
test: {
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
clearMocks: true,
restoreMocks: true,
},
build: {
chunkSizeWarningLimit: 900,
rollupOptions: {