diff --git a/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html b/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html index cc30447..3e2abc9 100644 --- a/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html +++ b/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html @@ -502,6 +502,10 @@ const loaded = await persistence.load(); // Sørg for at schemaVersion finnes (cold start kan returnere uten). if (!loaded.schemaVersion) loaded.schemaVersion = SCHEMA_VERSION; + // Data-version migrasjon (v1.9.0 -> v1.10.0): utled verdict/keyStats + // for eksisterende parser-output. Idempotent via state.dataVersion-guard. + try { migrateDataVersion(loaded, defaultArchetypeFor); } + catch (e) { console.warn('[playground v3] migrateDataVersion failed:', e); } store = createStore(loaded, sharedBus); scheduleWrite = makeThrottledWriter(function () { return persistence.save(store.raw); @@ -625,12 +629,17 @@ // Migrer envelope opp til gjeldende SCHEMA_VERSION før vi commit-er. const migrated = migrateState({ schemaVersion: envelope.schemaVersion, + dataVersion: envelope.dataVersion, shared: envelope.shared || INITIAL_STATE.shared, projects: envelope.projects || [], activeProjectId: envelope.activeProjectId || null, activeSurface: envelope.activeSurface || 'home', preferences: envelope.preferences || INITIAL_STATE.preferences }); + // Data-version migrasjon (additive — verdict/keyStats utledes for + // pre-v1.10.0 envelope-er). Idempotent via dataVersion=2 guard. + try { migrateDataVersion(migrated, defaultArchetypeFor); } + catch (e) { console.warn('[playground v3] migrateDataVersion (import) failed:', e); } // Skriv direkte til persistens for å unngå at debounce-vinduet // svelger import-en ved en samtidig page-unload. if (persistence) { @@ -640,6 +649,7 @@ // sin referanse fordi Proxy-en er bundet til den; muter feltvis. const target = store.raw; target.schemaVersion = migrated.schemaVersion; + if (migrated.dataVersion != null) target.dataVersion = migrated.dataVersion; target.shared = migrated.shared; target.projects = migrated.projects; target.activeProjectId = migrated.activeProjectId; @@ -3237,6 +3247,7 @@ slot.innerHTML = '
' + summaryHtml + headerHtml + rowsHtml + '
'; } + // === V2_FOUNDATION_BEGIN === // ============================================================ // FOUNDATION HELPERS (v1.10.0 Sesjon 1) // ============================================================ @@ -3511,6 +3522,50 @@ ); } + // ============================================================ + // DATA-VERSION MIGRATION (v1->v2) + // ============================================================ + // + // State.dataVersion sporer parser-output-format separat fra + // state.schemaVersion (som sporer state-shape). v1.9.0 produserte + // parser-output uten verdict/keyStats; v1.10.0 utvider med felles + // grunnskjelett-data. Migrasjonen er additive — eksisterende felter + // forblir uendret. + // + // v1_to_v2-handler: itererer projects[].reports[cmdId].parsed; hvis + // verdict eller keyStats mangler, utled fra eksisterende felter via + // inferVerdict + inferKeyStats. Setter state.dataVersion = 2 så + // migrasjonen er idempotent (re-kjøring er no-op). + + function migrateDataVersion(state, archetypeFor) { + if (!state) return state; + if (state.dataVersion === 2) return state; + const projects = state.projects || []; + for (let i = 0; i < projects.length; i++) { + const reports = (projects[i] && projects[i].reports) || {}; + const ids = Object.keys(reports); + for (let j = 0; j < ids.length; j++) { + const cmdId = ids[j]; + const r = reports[cmdId]; + if (!r || !r.parsed) continue; + const arche = typeof archetypeFor === 'function' ? archetypeFor(cmdId) : null; + if (!arche) continue; + if (r.parsed.verdict == null) r.parsed.verdict = inferVerdict(r.parsed, arche); + if (!Array.isArray(r.parsed.keyStats)) r.parsed.keyStats = inferKeyStats(r.parsed, arche); + } + } + state.dataVersion = 2; + return state; + } + + function defaultArchetypeFor(commandId) { + const cmds = (CATALOG && CATALOG.commands) || []; + for (let i = 0; i < cmds.length; i++) { + if (cmds[i].id === commandId) return cmds[i].report_archetype || null; + } + return null; + } + // Eksponer for tester og fremtidig renderer-iterasjon (Sesjon 3-5) window.__renderPageShell = renderPageShell; window.__renderVerdictPill = renderVerdictPill; @@ -3518,6 +3573,9 @@ window.__inferVerdict = inferVerdict; window.__inferKeyStats = inferKeyStats; window.__KEY_STATS_CONFIG = KEY_STATS_CONFIG; + window.__migrateDataVersion = migrateDataVersion; + window.__defaultArchetypeFor = defaultArchetypeFor; + // === V2_FOUNDATION_END === // ---- RENDERERS routing-objekt (17 commands) ---- diff --git a/plugins/ms-ai-architect/playground/test-fixtures/state-v1-snapshot.json b/plugins/ms-ai-architect/playground/test-fixtures/state-v1-snapshot.json new file mode 100644 index 0000000..60c6987 --- /dev/null +++ b/plugins/ms-ai-architect/playground/test-fixtures/state-v1-snapshot.json @@ -0,0 +1,111 @@ +{ + "schemaVersion": 1, + "shared": { + "organization": { + "name": "Acme AS", + "sector": "Statlig", + "regulatory_requirements": ["Personopplysningsloven/GDPR"] + }, + "technology": { + "cloud_platform": ["Azure"], + "license_type": "E5", + "ai_services_in_use": ["Azure OpenAI"] + }, + "security": { + "data_classification": ["Intern"], + "dpia_practice": "Systematisk" + }, + "architecture": { + "annual_ai_budget": "500k-2M" + }, + "business": {} + }, + "projects": [ + { + "id": "p-snapshot-classify", + "name": "Demosystem A — klassifisering", + "description": "Fiktiv test-prosjekt for v1->v2 migrasjons-test.", + "scenarios": [], + "createdAt": "2026-04-15T10:00:00.000Z", + "reports": { + "classify": { + "input": { "system_name": "Demosystem A" }, + "raw_markdown": "# AI Act-klassifisering\n\nRisikonivå: Høy\nRolle: deployer", + "parsed": { + "risk_level": "Høy", + "role": "deployer", + "reasoning": "Beslutningsstøtte i forvaltningsbehandling.", + "obligations": ["Art. 13 — transparens", "Art. 14 — menneskelig tilsyn", "Art. 27 — FRIA"] + } + }, + "ros": { + "input": { "system_name": "Demosystem A" }, + "raw_markdown": "# ROS-analyse\n\nNS 5814 / ISO 31000.", + "parsed": { + "matrix_cells": [ + { "row": 4, "col": 4, "count": 2 }, + { "row": 3, "col": 5, "count": 1 } + ], + "threats": [ + { "id": "T1", "description": "Modell-bias mot minoriteter", "severity": "Høy", "mitigation": "Bias-audit + kalibrering" }, + { "id": "T2", "description": "Privacy leak via prompts", "severity": "Kritisk", "mitigation": "DLP + redaction" }, + { "id": "T3", "description": "Hallusinerte fakta", "severity": "Medium", "mitigation": "Citation-grounding + reviewer" } + ], + "radar_axes": [ + { "name": "Tilgjengelighet", "score": 3 }, + { "name": "Konfidensialitet", "score": 4 }, + { "name": "Integritet", "score": 4 }, + { "name": "Robusthet", "score": 3 }, + { "name": "Sporbarhet", "score": 2 }, + { "name": "Fairness", "score": 2 }, + { "name": "Transparens", "score": 3 } + ] + } + } + } + }, + { + "id": "p-snapshot-cost", + "name": "Demosystem B — kostnadsestimat", + "description": "Fiktiv kostnadsestimat-rapport for migrasjons-test.", + "scenarios": [], + "createdAt": "2026-04-20T09:30:00.000Z", + "reports": { + "cost": { + "input": { "system_name": "Demosystem B" }, + "raw_markdown": "# Kostnadsestimat\n\nP10/P50/P90 i NOK/mnd.", + "parsed": { + "p10": 45000, + "p50": 82000, + "p90": 165000, + "monthly_breakdown": [ + { "component": "Azure OpenAI gpt-4o", "cost": 48000 }, + { "component": "Azure AI Search", "cost": 12000 }, + { "component": "Storage + log", "cost": 8000 } + ], + "tco_table": [], + "tco_headers": [] + } + }, + "summary": { + "input": {}, + "raw_markdown": "# Sammendrag\n\nBetinget anbefaling.", + "parsed": { + "verdict": "go-with-conditions", + "sub": "Med betingelser", + "rationale": "Kostnaden er innenfor rammen, men avhengig av governance-modning.", + "key_metrics": [ + { "label": "P50/mnd", "value": "82 000 NOK" }, + { "label": "Risikonivå", "value": "Høy" } + ], + "metrics_headers": [], + "next_steps": ["Etabler DPIA", "Avklar dataleverandør-kontrakt"] + } + } + } + } + ], + "activeProjectId": "p-snapshot-classify", + "activeSurface": "project", + "preferences": { "theme": "dark" } +} diff --git a/plugins/ms-ai-architect/tests/test-playground-migrations.sh b/plugins/ms-ai-architect/tests/test-playground-migrations.sh new file mode 100755 index 0000000..2493a22 --- /dev/null +++ b/plugins/ms-ai-architect/tests/test-playground-migrations.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# test-playground-migrations.sh — Playground v3 dataVersion v1->v2 idempotency +# +# Verifiserer: +# 1. fixture (playground/test-fixtures/state-v1-snapshot.json) eksisterer + er v1 +# 2. V2_FOUNDATION-blokken kan ekstraheres fra HTML-fila +# 3. migrateDataVersion(fixture) gir resultat A +# 4. migrateDataVersion(A) gir resultat B +# 5. JSON.stringify(A) === JSON.stringify(B) -> idempotency +# 6. A.dataVersion === 2 -> migrasjonen ble utført +# +# Bash 3.2-kompatibel. Bruker node til JS-eval; ingen npm-deps. +# Designet for å integreres i tests/run-e2e.sh --playground. + +set -euo pipefail + +PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HTML_FILE="$PLUGIN_ROOT/playground/ms-ai-architect-playground.html" +FIXTURE_FILE="$PLUGIN_ROOT/playground/test-fixtures/state-v1-snapshot.json" + +# shellcheck disable=SC1091 +source "$PLUGIN_ROOT/tests/lib/e2e-helpers.sh" + +init_suite "Playground v3 — dataVersion v1->v2 migration idempotency" + +# ---- 1. Filer eksisterer ---- +if [ ! -f "$HTML_FILE" ]; then + fail "HTML-fila finnes ikke: $HTML_FILE" + print_summary; exit 1 +fi +pass "HTML-fil finnes: $(basename "$HTML_FILE")" + +if [ ! -f "$FIXTURE_FILE" ]; then + fail "Fixture mangler: $FIXTURE_FILE" + print_summary; exit 1 +fi +pass "Fixture finnes: $(basename "$FIXTURE_FILE")" + +# ---- 2. Fixture er v1 (mangler dataVersion eller dataVersion < 2) ---- +if node -e " +const f = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); +if (f.dataVersion === 2) { console.error('fixture er allerede v2'); process.exit(1); } +if (typeof f.schemaVersion !== 'number') { console.error('fixture mangler schemaVersion'); process.exit(1); } +if (!Array.isArray(f.projects) || f.projects.length === 0) { console.error('fixture har ingen projects'); process.exit(1); } +" "$FIXTURE_FILE" 2>/dev/null; then + pass "Fixture er v1-state (dataVersion ikke satt)" +else + fail "Fixture er ikke gyldig v1-state" + print_summary; exit 1 +fi + +# ---- 3. V2_FOUNDATION-markører eksisterer i HTML ---- +if grep -q "V2_FOUNDATION_BEGIN" "$HTML_FILE" && grep -q "V2_FOUNDATION_END" "$HTML_FILE"; then + pass "V2_FOUNDATION_BEGIN/END markører finnes" +else + fail "Mangler V2_FOUNDATION-markører i HTML" + print_summary; exit 1 +fi + +# ---- 4. migrateDataVersion-funksjonen er definert ---- +if grep -q "function migrateDataVersion" "$HTML_FILE"; then + pass "migrateDataVersion-funksjonen finnes" +else + fail "migrateDataVersion-funksjonen mangler" + print_summary; exit 1 +fi + +# ---- 5. Eval idempotency-test i node ---- +# Strategi: ekstraher V2_FOUNDATION-blokken via sed, wrap med stubs (window, +# CATALOG-mock som returnerer archetype basert på fixture command-IDs), eval, +# kjør migrasjon to ganger på fixture-deep-copy, sammenlign JSON.stringify. + +IDEMPOTENCY_RESULT=$(node -e ' +const fs = require("fs"); +const path = require("path"); +const htmlPath = process.argv[1]; +const fixturePath = process.argv[2]; + +const html = fs.readFileSync(htmlPath, "utf8"); +const beginMarker = "// === V2_FOUNDATION_BEGIN ==="; +const endMarker = "// === V2_FOUNDATION_END ==="; +const beginIdx = html.indexOf(beginMarker); +const endIdx = html.indexOf(endMarker); +if (beginIdx < 0 || endIdx < 0) { + console.error("MARKER_MISSING"); + process.exit(2); +} +const block = html.substring(beginIdx, endIdx + endMarker.length); + +// Stub-ene som blokken trenger +const stubs = ` + const window = {}; + function escapeHtml(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } + function escapeAttr(s) { return escapeHtml(s); } + // Mock CATALOG: kun de command-id-ene fixture-en bruker. + const CATALOG = { commands: [ + { id: "classify", report_archetype: "aiact" }, + { id: "ros", report_archetype: "matrix-risk" }, + { id: "cost", report_archetype: "cost-distribution" }, + { id: "summary", report_archetype: "verdict" } + ]}; +`; + +const wrapped = stubs + block + "\nreturn { migrateDataVersion, defaultArchetypeFor };"; +let api; +try { + api = (new Function(wrapped))(); +} catch (e) { + console.error("EVAL_FAILED:", e.message); + process.exit(3); +} + +const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")); + +// Deep clones så vi sammenligner uavhengige objekter +const stateA = JSON.parse(JSON.stringify(fixture)); +api.migrateDataVersion(stateA, api.defaultArchetypeFor); + +const stateB = JSON.parse(JSON.stringify(stateA)); +api.migrateDataVersion(stateB, api.defaultArchetypeFor); + +const a = JSON.stringify(stateA); +const b = JSON.stringify(stateB); + +if (stateA.dataVersion !== 2) { + console.error("DATA_VERSION_NOT_BUMPED"); + process.exit(4); +} + +// Sjekk at minst én rapport fikk verdict + keyStats utledet +let verdictsAdded = 0, statsAdded = 0; +for (const p of (stateA.projects || [])) { + for (const id of Object.keys(p.reports || {})) { + const r = p.reports[id]; + if (r && r.parsed) { + if (r.parsed.verdict != null) verdictsAdded++; + if (Array.isArray(r.parsed.keyStats)) statsAdded++; + } + } +} +if (verdictsAdded === 0) { console.error("NO_VERDICTS_ADDED"); process.exit(5); } +if (statsAdded === 0) { console.error("NO_KEYSTATS_ADDED"); process.exit(6); } + +if (a === b) { + console.log("IDEMPOTENT verdicts=" + verdictsAdded + " stats=" + statsAdded); +} else { + console.error("NOT_IDEMPOTENT"); + process.exit(7); +} +' "$HTML_FILE" "$FIXTURE_FILE" 2>&1) || RC=$? + +if [ "${RC:-0}" -eq 0 ] && echo "$IDEMPOTENCY_RESULT" | grep -q "^IDEMPOTENT"; then + pass "migrateDataVersion er idempotent ($IDEMPOTENCY_RESULT)" +else + fail "Idempotency-test feilet: $IDEMPOTENCY_RESULT" +fi + +# ---- 6. dataVersion bumpes til 2 ved første kjøring ---- +DV_RESULT=$(node -e ' +const fs = require("fs"); +const html = fs.readFileSync(process.argv[1], "utf8"); +const begin = html.indexOf("// === V2_FOUNDATION_BEGIN ==="); +const end = html.indexOf("// === V2_FOUNDATION_END ==="); +const block = html.substring(begin, end + 32); +const stubs = ` + const window = {}; + function escapeHtml(s) { return String(s == null ? "" : s); } + function escapeAttr(s) { return escapeHtml(s); } + const CATALOG = { commands: [ + { id: "classify", report_archetype: "aiact" } + ]}; +`; +const api = (new Function(stubs + block + "\nreturn { migrateDataVersion, defaultArchetypeFor };"))(); +const state = { schemaVersion: 1, projects: [] }; +api.migrateDataVersion(state, api.defaultArchetypeFor); +console.log(state.dataVersion); +' "$HTML_FILE" 2>&1) || true + +if [ "$DV_RESULT" = "2" ]; then + pass "dataVersion bumpes til 2" +else + fail "dataVersion ble ikke bumpet til 2 (got '$DV_RESULT')" +fi + +print_summary