feat(ms-ai-architect): renderer batch C (econ + docs 8) + structural test asserts [skip-docs]
Sesjon 5 av v1.10.0-løpet (8 av 17 renderers wrapped med renderPageShell). Nå alle 17 renderers bruker felles grunnskjelett (page__eyebrow + h1 + verdict). Renderers wrapped: - C.1 renderCost: eyebrow=KOSTNAD, key-stats utvidet med DOMINERENDE-komponent - C.2 renderLicense: eyebrow=LISENS, scenario-card-grid per kandidat-lisens, TOPP-LISENS key-stat - C.3 renderMigrate: eyebrow=MIGRASJON, E2 mat-ladder erstatter aiact-timeline, E4 cycle-ribbon ved aktiv fase - C.4 renderAdr: eyebrow=ADR, D4 critique-card per beslutningsseksjon, ADR-status → verdict-pille (accepted/proposed/rejected/deprecated) - C.5 renderSummary: eyebrow=SAMMENDRAG, E8 read-more for lange rationale - C.6 renderPoc: eyebrow=POC, E2 mat-ladder + B5 traffic-light per success-kriterie, pocVerdict styrer verdict-pille - C.7 renderUtredning: eyebrow=UTREDNING, A4 screen-tabs (Bakgrunn/Funn/Konklusjon/ Anbefaling) + E8 read-more på lange seksjoner - C.8 renderCompare: eyebrow=SAMMENLIGN, D1 scenario-cards-grid per kandidat, parseComparison.winner styrer vinner-pille + VINNER key-stat Parser-utvidelser (R15 forward-compat — eksisterende fixtures uendret): - parsePhasedPlan: phases[].status (planned/active/done), currentPhaseIndex, pocVerdict (kun ved POC-Verdict-linje) - parseComparison: optional winner-felt fra "## Vinner: <id>"-linje Topic 2 strategi A i handlePasteImport: sentralisert _consumer-tildeling (result.data._consumer ||= cmd.id), respekterer parser-spesifikk verdi (parseMatrixRisk → 'ros'). Fixture-updates: migrate/poc med Status: per fase + POC-Verdict, compare med "## Vinner:"-linje. Test-asserts (tests/test-playground-v3.sh +18 PASS, totalt 201/201): - 25e SC8 per-renderer for batch C (8 renderers) - 25f Step 12 must_contain (mat-ladder, screen-tabs, _consumer) - 25g Felles grunnskjelett: alle 17 renderers bruker renderPageShell - 25h Tier 3-bruk: kanban i conformity/review, mat-ladder i migrate/poc - 25i Onboarding field-distribution (4 strukturerte, 14 fritekst) Verifisert: 201/201 statiske, 70/70 parser-fixtures, 7/7 migrations PASS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b97251bda3
commit
fc48d01f1e
5 changed files with 521 additions and 58 deletions
|
|
@ -2944,7 +2944,8 @@
|
|||
name: phaseMatch[1].trim(),
|
||||
milestones: [],
|
||||
success_criteria: [],
|
||||
duration_weeks: null
|
||||
duration_weeks: null,
|
||||
status: null
|
||||
};
|
||||
bucket = null;
|
||||
continue;
|
||||
|
|
@ -2961,6 +2962,17 @@
|
|||
current.duration_weeks = parseInt(durMatch[1], 10);
|
||||
continue;
|
||||
}
|
||||
const statusMatch = /^Status\s*:\s*([\wæøåA-Za-z-]+)/i.exec(trimmed);
|
||||
if (statusMatch) {
|
||||
// Normaliser til en av: planned | active | done.
|
||||
const raw = statusMatch[1].toLowerCase();
|
||||
let s = null;
|
||||
if (/^(done|ferdig|fullf[øo]rt|complete[d]?)$/.test(raw)) s = 'done';
|
||||
else if (/^(active|aktiv|p[åa]g[åa]ende|igang|in[-_]?progress|current|n[åa])$/.test(raw)) s = 'active';
|
||||
else if (/^(planned|planlagt|kommende|future|fremtid)$/.test(raw)) s = 'planned';
|
||||
current.status = s || raw;
|
||||
continue;
|
||||
}
|
||||
if (/^Milep[æa]ler\s*:?\s*$/i.test(trimmed)) { bucket = 'milestones'; continue; }
|
||||
if (/^Suksesskriterier\s*:?\s*$/i.test(trimmed)) { bucket = 'success_criteria'; continue; }
|
||||
const bulletMatch = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||
|
|
@ -2970,6 +2982,32 @@
|
|||
}
|
||||
if (current) phases.push(current);
|
||||
|
||||
// Utled currentPhaseIndex: første 'active' ELLER første ikke-'done'.
|
||||
// R15: -1 hvis ingen faser har status (forward-compat — eksisterende fixtures uberørt).
|
||||
let currentPhaseIndex = -1;
|
||||
const anyStatus = phases.some(function (p) { return p.status; });
|
||||
if (anyStatus) {
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
if (phases[i].status === 'active') { currentPhaseIndex = i; break; }
|
||||
}
|
||||
if (currentPhaseIndex < 0) {
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
if (phases[i].status !== 'done') { currentPhaseIndex = i; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POC-verdict (kun for poc-consumer): "## POC-Verdict: GO|BETINGET|BLOKK"
|
||||
// R15: undefined for migrate-consumer (uberørt felt).
|
||||
let pocVerdict;
|
||||
const pvMatch = /^##\s*POC[- ]?Verdict\s*:\s*([A-Za-zØøÆæÅå -]+)$/im.exec(md);
|
||||
if (pvMatch) {
|
||||
const tag = pvMatch[1].toLowerCase().trim();
|
||||
if (/^(go-?with-?conditions|betinget|conditions?|conditional)$/.test(tag)) pocVerdict = 'go-with-conditions';
|
||||
else if (/^(block|blokk|blokkert|stop)$/.test(tag)) pocVerdict = 'block';
|
||||
else if (/^(go|godkjent|ok|pass)$/.test(tag)) pocVerdict = 'go';
|
||||
}
|
||||
|
||||
const risksTbl = parseTable(md, /##\s*Risiko/i);
|
||||
const risks = risksTbl ? risksTbl.rows.map(function (row) {
|
||||
const risikoKey = risksTbl.headers[0];
|
||||
|
|
@ -2985,7 +3023,9 @@
|
|||
}) : [];
|
||||
|
||||
if (!phases.length) return { ok: false, errors: [{ section: 'phases', reason: 'Ingen faser funnet (### Fase N)' }] };
|
||||
return { ok: true, data: { phases: phases, risks: risks } };
|
||||
const out = { phases: phases, risks: risks, currentPhaseIndex: currentPhaseIndex };
|
||||
if (pocVerdict) out.pocVerdict = pocVerdict;
|
||||
return { ok: true, data: out };
|
||||
}
|
||||
|
||||
function parseMarkdown(md) {
|
||||
|
|
@ -3052,7 +3092,12 @@
|
|||
winner: winnerKey ? (row[winnerKey] || '') : ''
|
||||
};
|
||||
});
|
||||
return { ok: true, data: { subjects: subjects, rows: rows } };
|
||||
// R15: optional winner-felt fra "## Vinner: <id>"-linje. Brukes av
|
||||
// renderCompare for verdict-pill og scenario-card highlight.
|
||||
const out = { subjects: subjects, rows: rows };
|
||||
const winMatch = /^##\s*Vinner\s*:\s*(.+?)\s*$/im.exec(md) || /^Winner\s*:\s*(.+?)\s*$/im.exec(md);
|
||||
if (winMatch) out.winner = winMatch[1].trim();
|
||||
return { ok: true, data: out };
|
||||
}
|
||||
|
||||
// ---- PARSERS routing-objekt ----
|
||||
|
|
@ -3769,7 +3814,27 @@
|
|||
const tcoHtml = tcoRows
|
||||
? '<table class="report-table"><thead><tr>' + tcoHeader + '</tr></thead><tbody>' + tcoRows + '</tbody></table>'
|
||||
: '';
|
||||
slot.innerHTML = distHtml + breakdownHtml + tcoHtml;
|
||||
const body = distHtml + breakdownHtml + tcoHtml;
|
||||
// Utvid cost-distribution-keyStats med DOMINERENDE (top-komponent i breakdown).
|
||||
const breakdown = data.monthly_breakdown || [];
|
||||
const dominant = breakdown.reduce(function (acc, m) {
|
||||
return (m && Number(m.cost) > Number(acc && acc.cost || 0)) ? m : acc;
|
||||
}, null);
|
||||
const baseStats = inferKeyStats(data, 'cost-distribution');
|
||||
const stats = data.keyStats || (dominant
|
||||
? baseStats.concat([{
|
||||
label: 'DOMINERENDE',
|
||||
value: String(dominant.component || '').slice(0, 28),
|
||||
hint: formatNok(dominant.cost) + '/mnd'
|
||||
}])
|
||||
: baseStats);
|
||||
slot.innerHTML = renderPageShell({
|
||||
eyebrow: 'KOSTNAD',
|
||||
title: data.title || 'Kostnadsestimat',
|
||||
lede: data.lede || 'Distribusjon P10/P50/P90 i NOK med månedlig fordeling og TCO over 3 år.',
|
||||
verdict: data.verdict || inferVerdict(data, 'cost-distribution'),
|
||||
keyStats: stats
|
||||
}, body);
|
||||
}
|
||||
|
||||
function renderLicense(data, slot) {
|
||||
|
|
@ -3796,8 +3861,50 @@
|
|||
cells +
|
||||
'</div>';
|
||||
}).join('');
|
||||
slot.innerHTML = '<div class="capability-matrix" style="grid-template-columns: 220px repeat(' + licenses.length + ', 1fr);">' +
|
||||
const matrixHtml = '<div class="capability-matrix" style="grid-template-columns: 220px repeat(' + licenses.length + ', 1fr);">' +
|
||||
headHtml + rowsHtml + '</div>';
|
||||
// D1 scenario-card-grid per lisens: hver lisens som card med dekning-stat.
|
||||
const isAvail = function (cap) { return /^avail|tilgjengelig/i.test((cap && cap.status) || ''); };
|
||||
const isMiss = function (cap) { return /^miss/i.test((cap && cap.status) || ''); };
|
||||
const totalCaps = capabilityNames.length;
|
||||
const licScores = licenses.map(function (l) {
|
||||
const caps = l.capabilities || [];
|
||||
const avail = caps.filter(isAvail).length;
|
||||
const miss = caps.filter(isMiss).length;
|
||||
const ratio = totalCaps ? (avail / totalCaps) : 0;
|
||||
const status = ratio >= 0.8 ? 'met' : ratio >= 0.4 ? 'partial' : 'missing';
|
||||
return { name: l.name, avail: avail, miss: miss, total: totalCaps, ratio: ratio, status: status };
|
||||
});
|
||||
const scenarioGridHtml = '<div class="scenario-card-grid">' + licScores.map(function (s) {
|
||||
const pct = (s.ratio * 100).toFixed(0);
|
||||
return '<div class="scenario-card" data-status="' + escapeAttr(s.status) + '">' +
|
||||
'<div class="scenario-card__head">' +
|
||||
'<span class="scenario-card__source">LISENS</span>' +
|
||||
'<span class="scenario-card__count">' + s.avail + '/' + s.total + '</span>' +
|
||||
'</div>' +
|
||||
'<h4 class="scenario-card__title">' + escapeHtml(s.name) + '</h4>' +
|
||||
'<div class="scenario-card__source">' + pct + '% dekket · ' + s.miss + ' mangler</div>' +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
const body = scenarioGridHtml + matrixHtml;
|
||||
// Utvid capability-keyStats med BESTE LISENS (høyest avail-ratio).
|
||||
const baseStats = inferKeyStats(data, 'capability');
|
||||
const top = licScores.reduce(function (a, b) { return b.ratio > (a ? a.ratio : -1) ? b : a; }, null);
|
||||
const stats = data.keyStats || (top
|
||||
? baseStats.concat([{
|
||||
label: 'TOPP-LISENS',
|
||||
value: String(top.name || '').slice(0, 24),
|
||||
hint: top.avail + '/' + top.total + ' kapabiliteter',
|
||||
modifier: top.status === 'met' ? 'low' : top.status === 'partial' ? 'medium' : 'high'
|
||||
}])
|
||||
: baseStats);
|
||||
slot.innerHTML = renderPageShell({
|
||||
eyebrow: 'LISENS',
|
||||
title: data.title || 'Lisens-kapabilitetsmatrise',
|
||||
lede: data.lede || 'Kapabilitetsdekning per lisensnivå med scenario-cards og full matrise.',
|
||||
verdict: data.verdict || inferVerdict(data, 'capability'),
|
||||
keyStats: stats
|
||||
}, body);
|
||||
}
|
||||
|
||||
// ---- Sub-batch D: Documentation (6) ----
|
||||
|
|
@ -3805,23 +3912,57 @@
|
|||
function renderMigrate(data, slot) {
|
||||
const phases = data.phases || [];
|
||||
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
|
||||
const milestones = phases.map(function (p, i) {
|
||||
const left = ((i + 1) / (phases.length + 1)) * 100;
|
||||
return '<div class="aiact-timeline__milestone" data-state="upcoming" style="left: ' + left.toFixed(1) + '%">' +
|
||||
'<div class="aiact-timeline__dot"></div>' +
|
||||
'<div class="aiact-timeline__label">' +
|
||||
'<span class="aiact-timeline__label-date">' + (p.duration_weeks ? p.duration_weeks + ' uker' : '') + '</span>' +
|
||||
'<span class="aiact-timeline__label-name">' + escapeHtml(p.name) + '</span>' +
|
||||
// Map fase-status til mat-step data-state. R15: hvis ingen faser har
|
||||
// status, fall tilbake til "alle future" — eksisterende fixtures uberørt.
|
||||
const cpi = (typeof data.currentPhaseIndex === 'number') ? data.currentPhaseIndex : -1;
|
||||
const stepStateFor = function (p, i) {
|
||||
if (p.status === 'done') return 'completed';
|
||||
if (p.status === 'active') return 'current';
|
||||
if (p.status === 'planned' || p.status) return 'future';
|
||||
// Fallback uten status: bruk currentPhaseIndex hvis satt.
|
||||
if (cpi < 0) return 'future';
|
||||
if (i < cpi) return 'completed';
|
||||
if (i === cpi) return 'current';
|
||||
return 'future';
|
||||
};
|
||||
const stepsHtml = phases.map(function (p, i) {
|
||||
const state = stepStateFor(p, i);
|
||||
const num = String(i + 1).padStart(2, '0');
|
||||
const pill = state === 'current'
|
||||
? '<span class="mat-step__pill mat-step__pill--current">PÅGÅR</span>'
|
||||
: state === 'completed'
|
||||
? '<span class="mat-step__pill mat-step__pill--complete">FERDIG</span>'
|
||||
: '';
|
||||
const dur = p.duration_weeks ? '<div class="mat-step__progress"><span>' + p.duration_weeks + ' uker</span></div>' : '';
|
||||
const desc = (p.milestones && p.milestones.length)
|
||||
? '<div class="mat-step__desc">' + escapeHtml(p.milestones[0]) + '</div>'
|
||||
: '';
|
||||
return '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
|
||||
'<div class="mat-step__icon">' + num + '</div>' +
|
||||
'<div>' +
|
||||
'<div class="mat-step__name">' + escapeHtml(p.name) + ' ' + pill + '</div>' +
|
||||
desc +
|
||||
dur +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
const timelineHtml =
|
||||
'<div class="aiact-timeline">' +
|
||||
'<div class="aiact-timeline__track">' +
|
||||
'<div class="aiact-timeline__progress" style="width: 0%"></div>' +
|
||||
milestones +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
const ladderHtml = '<div class="mat-ladder">' + stepsHtml + '</div>';
|
||||
// E4 cycle-ribbon: bare når en fase er aktiv. data-phase=execution som
|
||||
// standard for migrasjonens "kjøre"-fase.
|
||||
let ribbonHtml = '';
|
||||
if (cpi >= 0 && phases[cpi]) {
|
||||
const cur = phases[cpi];
|
||||
const cumWeeks = phases.slice(0, cpi).reduce(function (a, p) { return a + (Number(p.duration_weeks) || 0); }, 0);
|
||||
const weekStart = cumWeeks + 1;
|
||||
const weekEnd = cumWeeks + (Number(cur.duration_weeks) || 0);
|
||||
const weekRange = cur.duration_weeks ? ('Uke ' + weekStart + '-' + weekEnd) : '';
|
||||
ribbonHtml = '<div class="cycle-ribbon" data-phase="execution">' +
|
||||
'<span class="cycle-ribbon__id">M-' + (cpi + 1) + '</span>' +
|
||||
(weekRange ? '<span class="cycle-ribbon__week">' + escapeHtml(weekRange) + '</span>' : '') +
|
||||
'<span class="cycle-ribbon__phase">PÅGÅR</span>' +
|
||||
'<span class="cycle-ribbon__msg">' + escapeHtml(cur.name) + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
const detailsHtml = phases.map(function (p) {
|
||||
const ms = (p.milestones || []).map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
|
||||
const sc = (p.success_criteria || []).map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('');
|
||||
|
|
@ -3837,7 +3978,14 @@
|
|||
const risksHtml = risksRows
|
||||
? '<table class="report-table"><thead><tr><th>Risiko</th><th>Sannsynlighet</th><th>Konsekvens</th><th>Tiltak</th></tr></thead><tbody>' + risksRows + '</tbody></table>'
|
||||
: '';
|
||||
slot.innerHTML = timelineHtml + detailsHtml + risksHtml;
|
||||
const body = ribbonHtml + ladderHtml + detailsHtml + risksHtml;
|
||||
slot.innerHTML = renderPageShell({
|
||||
eyebrow: 'MIGRASJON',
|
||||
title: data.title || 'Migrasjonsplan',
|
||||
lede: data.lede || 'Faseinndelt migrasjon med mat-ladder, cycle-ribbon for aktiv fase og risikomatrise.',
|
||||
verdict: data.verdict || inferVerdict(data, 'phased-plan'),
|
||||
keyStats: data.keyStats || inferKeyStats(data, 'phased-plan')
|
||||
}, body);
|
||||
}
|
||||
|
||||
function renderAdr(data, slot) {
|
||||
|
|
@ -3847,15 +3995,43 @@
|
|||
(data.date ? '<dt>Date</dt><dd>' + escapeHtml(data.date) + '</dd>' : '') +
|
||||
(data.deciders ? '<dt>Deciders</dt><dd>' + escapeHtml(data.deciders) + '</dd>' : '') +
|
||||
'</dl>';
|
||||
const sectionsHtml = (data.sections || []).map(function (s) {
|
||||
return '<section><h2>' + escapeHtml(s.heading) + '</h2><div class="adr-body">' + escapeHtml(s.body).replace(/\n/g, '<br>') + '</div></section>';
|
||||
}).join('');
|
||||
slot.innerHTML =
|
||||
'<article class="report-doc">' +
|
||||
'<h1>' + escapeHtml(data.title || 'ADR') + '</h1>' +
|
||||
meta +
|
||||
sectionsHtml +
|
||||
'</article>';
|
||||
// D4 critique-card per beslutningsseksjon. Ingen severity (ADR-seksjoner
|
||||
// er ikke risikorangert), bruker rekkefølge-id ADR-01..n.
|
||||
const sections = data.sections || [];
|
||||
const cardsHtml = sections.length ? '<div class="critique-cards">' + sections.map(function (s, i) {
|
||||
const id = 'ADR-' + String(i + 1).padStart(2, '0');
|
||||
const body = escapeHtml(s.body || '').replace(/\n/g, '<br>');
|
||||
return '<div class="critique-card">' +
|
||||
'<div class="critique-card__header">' +
|
||||
'<div class="critique-card__title">' + escapeHtml(s.heading) + '</div>' +
|
||||
'<div class="critique-card__meta">' +
|
||||
'<span class="critique-card__id">' + id + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="critique-card__recommendation">' + body + '</div>' +
|
||||
'</div>';
|
||||
}).join('') + '</div>' : '';
|
||||
const body = meta + cardsHtml;
|
||||
// ADR-status til verdict: accepted/godkjent → approved, proposed → go-with-conditions,
|
||||
// rejected → failed, deprecated/superseded → warning.
|
||||
const statusMap = {
|
||||
accepted: 'approved', godkjent: 'approved', approved: 'approved',
|
||||
proposed: 'go-with-conditions', foreslått: 'go-with-conditions', 'foreslatt': 'go-with-conditions',
|
||||
rejected: 'failed', avvist: 'failed',
|
||||
deprecated: 'warning', foreldet: 'warning', superseded: 'warning'
|
||||
};
|
||||
const verdict = data.verdict || statusMap[String(data.status || '').toLowerCase().trim()] || 'n-a';
|
||||
slot.innerHTML = renderPageShell({
|
||||
eyebrow: 'ADR',
|
||||
title: data.title || 'Architecture Decision Record',
|
||||
lede: data.lede || (data.status ? 'Status: ' + data.status : 'MADR v3.0 — beslutningsdokument med kontekst, alternativer og konsekvenser.'),
|
||||
verdict: verdict,
|
||||
keyStats: data.keyStats || [
|
||||
{ label: 'STATUS', value: String(data.status || '—').toUpperCase() },
|
||||
{ label: 'SEKSJONER', value: sections.length },
|
||||
{ label: 'BESLUTTERE', value: data.deciders ? String(data.deciders).split(/[,;]/).length : 0, hint: 'antall' }
|
||||
]
|
||||
}, body);
|
||||
}
|
||||
|
||||
function renderSummary(data, slot) {
|
||||
|
|
@ -3885,9 +4061,23 @@
|
|||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
const rationaleHtml = data.rationale
|
||||
? '<section><h3>Rationale</h3><p>' + escapeHtml(data.rationale).replace(/\n/g, '<br>') + '</p></section>'
|
||||
: '';
|
||||
// E8 read-more: lange rationale (>300 tegn) skjuler hale i <details>.
|
||||
let rationaleHtml = '';
|
||||
if (data.rationale) {
|
||||
const raw = String(data.rationale);
|
||||
if (raw.length > 300) {
|
||||
const head = raw.slice(0, 220);
|
||||
const tail = raw.slice(220);
|
||||
rationaleHtml = '<section><h3>Rationale</h3>' +
|
||||
'<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
|
||||
'<details class="read-more-block"><summary>Vis hele rationale</summary>' +
|
||||
'<p>' + escapeHtml(tail).replace(/\n/g, '<br>') + '</p>' +
|
||||
'</details>' +
|
||||
'</section>';
|
||||
} else {
|
||||
rationaleHtml = '<section><h3>Rationale</h3><p>' + escapeHtml(raw).replace(/\n/g, '<br>') + '</p></section>';
|
||||
}
|
||||
}
|
||||
let metricsHtml = '';
|
||||
if ((data.key_metrics || []).length) {
|
||||
const headers = data.metrics_headers || Object.keys(data.key_metrics[0] || {});
|
||||
|
|
@ -3901,29 +4091,73 @@
|
|||
const nextHtml = (data.next_steps || []).length
|
||||
? '<section><h3>Next Steps</h3><ul>' + data.next_steps.map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('') + '</ul></section>'
|
||||
: '';
|
||||
slot.innerHTML = verdictHtml + rationaleHtml + metricsHtml + nextHtml;
|
||||
const body = verdictHtml + rationaleHtml + metricsHtml + nextHtml;
|
||||
// Map summary-verdict (allow/warning/block) til canonical verdict for header-pill.
|
||||
const headerVerdictMap = { allow: 'allow', warning: 'warning', block: 'block' };
|
||||
const headerVerdict = headerVerdictMap[v.variant] || 'warning';
|
||||
slot.innerHTML = renderPageShell({
|
||||
eyebrow: 'SAMMENDRAG',
|
||||
title: data.title || 'Beslutningsnotat',
|
||||
lede: data.lede || 'Teknisk sammendrag med verdict, key metrics og neste steg.',
|
||||
verdict: headerVerdict,
|
||||
keyStats: data.keyStats || inferKeyStats(data, 'verdict')
|
||||
}, body);
|
||||
}
|
||||
|
||||
function renderPoc(data, slot) {
|
||||
const phases = data.phases || [];
|
||||
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
|
||||
const stagesHtml = phases.map(function (p, i) {
|
||||
// E2 mat-ladder (samme mønster som migrate). POC uses currentPhaseIndex/status.
|
||||
const cpi = (typeof data.currentPhaseIndex === 'number') ? data.currentPhaseIndex : -1;
|
||||
const stepStateFor = function (p, i) {
|
||||
if (p.status === 'done') return 'completed';
|
||||
if (p.status === 'active') return 'current';
|
||||
if (p.status === 'planned' || p.status) return 'future';
|
||||
if (cpi < 0) return 'future';
|
||||
if (i < cpi) return 'completed';
|
||||
if (i === cpi) return 'current';
|
||||
return 'future';
|
||||
};
|
||||
const stepsHtml = phases.map(function (p, i) {
|
||||
const state = stepStateFor(p, i);
|
||||
const num = String(i + 1).padStart(2, '0');
|
||||
const isCurrent = (i === 0);
|
||||
return '<div class="pc-stage" data-current="' + (isCurrent ? 'true' : 'false') + '">' +
|
||||
'<div class="pc-stage__num">' + num + '</div>' +
|
||||
'<div class="pc-stage__name">' + escapeHtml(p.name) + '</div>' +
|
||||
'<div class="pc-stage__state" data-state="' + (isCurrent ? 'running' : 'empty') + '">' + (p.duration_weeks || '?') + ' uker</div>' +
|
||||
const pill = state === 'current'
|
||||
? '<span class="mat-step__pill mat-step__pill--current">PÅGÅR</span>'
|
||||
: state === 'completed'
|
||||
? '<span class="mat-step__pill mat-step__pill--complete">FERDIG</span>'
|
||||
: '';
|
||||
const dur = p.duration_weeks ? '<div class="mat-step__progress"><span>' + p.duration_weeks + ' uker</span></div>' : '';
|
||||
const desc = (p.milestones && p.milestones.length)
|
||||
? '<div class="mat-step__desc">' + escapeHtml(p.milestones[0]) + '</div>'
|
||||
: '';
|
||||
return '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
|
||||
'<div class="mat-step__icon">' + num + '</div>' +
|
||||
'<div>' +
|
||||
'<div class="mat-step__name">' + escapeHtml(p.name) + ' ' + pill + '</div>' +
|
||||
desc +
|
||||
dur +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
const cockpitHtml = '<div class="pipeline-cockpit">' + stagesHtml + '</div>';
|
||||
const detailsHtml = phases.map(function (p) {
|
||||
const ladderHtml = '<div class="mat-ladder">' + stepsHtml + '</div>';
|
||||
// B5 traffic-light per success-kriterie. R15: uten eksplisitt status,
|
||||
// bruk fasens state — done=go, active=warning, future=neutral.
|
||||
const detailsHtml = phases.map(function (p, i) {
|
||||
const state = stepStateFor(p, i);
|
||||
const tlStatus = state === 'completed' ? 'green' : state === 'current' ? 'yellow' : 'gray';
|
||||
const ms = (p.milestones || []).map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
|
||||
const sc = (p.success_criteria || []).map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('');
|
||||
const sc = (p.success_criteria || []).map(function (s) {
|
||||
return '<li class="traffic-row">' +
|
||||
'<span class="traffic-light" data-status="' + escapeAttr(tlStatus) + '" aria-label="' + escapeAttr(tlStatus) + '">' +
|
||||
'<span class="traffic-light__dot"></span>' +
|
||||
'<span class="traffic-light__label">' + escapeHtml(s) + '</span>' +
|
||||
'</span>' +
|
||||
'</li>';
|
||||
}).join('');
|
||||
return '<section class="phase-detail">' +
|
||||
'<h3>' + escapeHtml(p.name) + ' <small>(' + (p.duration_weeks || '?') + ' uker)</small></h3>' +
|
||||
(ms ? '<h4>Milepæler</h4><ul>' + ms + '</ul>' : '') +
|
||||
(sc ? '<h4>Suksesskriterier</h4><ul>' + sc + '</ul>' : '') +
|
||||
(sc ? '<h4>Suksesskriterier</h4><ul class="traffic-list">' + sc + '</ul>' : '') +
|
||||
'</section>';
|
||||
}).join('');
|
||||
const risksRows = (data.risks || []).map(function (r) {
|
||||
|
|
@ -3932,24 +4166,86 @@
|
|||
const risksHtml = risksRows
|
||||
? '<table class="report-table"><thead><tr><th>Risiko</th><th>Sannsynlighet</th><th>Konsekvens</th><th>Tiltak</th></tr></thead><tbody>' + risksRows + '</tbody></table>'
|
||||
: '';
|
||||
slot.innerHTML = cockpitHtml + detailsHtml + risksHtml;
|
||||
const body = ladderHtml + detailsHtml + risksHtml;
|
||||
// B1 verdict-pille: data.pocVerdict styrer (go/go-with-conditions/block).
|
||||
// R15: hvis ikke satt, fall tilbake til risk-baserte heuristikk.
|
||||
let verdict = data.verdict || data.pocVerdict;
|
||||
if (!verdict) {
|
||||
const risks = data.risks || [];
|
||||
const critical = risks.some(function (r) { return /high|h[øo]y/i.test(r.consequence || '') && /high|h[øo]y/i.test(r.probability || ''); });
|
||||
verdict = critical ? 'go-with-conditions' : (risks.length ? 'go-with-conditions' : 'go');
|
||||
}
|
||||
slot.innerHTML = renderPageShell({
|
||||
eyebrow: 'POC',
|
||||
title: data.title || 'POC-plan',
|
||||
lede: data.lede || 'Faseinndelt POC med mat-ladder, suksesskriterier og go/no-go-vurdering.',
|
||||
verdict: verdict,
|
||||
keyStats: data.keyStats || inferKeyStats(data, 'phased-plan')
|
||||
}, body);
|
||||
}
|
||||
|
||||
function renderUtredning(data, slot) {
|
||||
const tocHtml = (data.sections || []).map(function (s, i) {
|
||||
return '<li><a href="#utr-sec-' + i + '">' + escapeHtml(s.heading) + '</a></li>';
|
||||
const sections = data.sections || [];
|
||||
// A4 SCREEN-TABS: kuratert sett av 4 strukturerte tabs.
|
||||
// R15: Hvis utredningen mangler en av seksjonene, hopp over den taben.
|
||||
const tabSpec = [
|
||||
{ id: 'bakgrunn', label: 'Bakgrunn', match: /\bbakgrunn\b/i },
|
||||
{ id: 'funn', label: 'Funn', match: /\bfunn\b/i },
|
||||
{ id: 'konklusjon', label: 'Konklusjon', match: /\bkonklusjon\b/i },
|
||||
{ id: 'anbefaling', label: 'Anbefaling', match: /\banbefaling\b/i }
|
||||
];
|
||||
// Heading-normaliser: fjern "1. ", "1.2 " prefiks.
|
||||
const normalize = function (h) { return String(h || '').replace(/^\s*\d+(\.\d+)*\s*\.?\s*/, '').trim(); };
|
||||
const findSec = function (m) {
|
||||
return sections.find(function (s) { return m.test(normalize(s.heading)); });
|
||||
};
|
||||
const usedIdx = new Set();
|
||||
const tabs = tabSpec.map(function (t) {
|
||||
const sec = findSec(t.match);
|
||||
if (sec) usedIdx.add(sections.indexOf(sec));
|
||||
return { id: t.id, label: t.label, sec: sec };
|
||||
}).filter(function (t) { return t.sec; });
|
||||
// E8 read-more body: lange seksjoner (>500 tegn) skjuler hale i <details>.
|
||||
const renderBody = function (raw) {
|
||||
const txt = String(raw || '');
|
||||
if (txt.length > 500) {
|
||||
const head = txt.slice(0, 380);
|
||||
const tail = txt.slice(380);
|
||||
return '<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
|
||||
'<details class="read-more-block"><summary>Vis hele seksjonen</summary>' +
|
||||
'<p>' + escapeHtml(tail).replace(/\n/g, '<br>') + '</p>' +
|
||||
'</details>';
|
||||
}
|
||||
return '<div>' + escapeHtml(txt).replace(/\n/g, '<br>') + '</div>';
|
||||
};
|
||||
const tabsNavHtml = tabs.length ? '<nav class="screen-tabs" role="tablist" aria-label="Utredning">' + tabs.map(function (t, i) {
|
||||
return '<a class="screen-tab" role="tab" aria-current="' + (i === 0 ? 'true' : 'false') + '" href="#utr-' + escapeAttr(t.id) + '">' + escapeHtml(t.label) + '</a>';
|
||||
}).join('') + '</nav>' : '';
|
||||
const tabsBodyHtml = tabs.map(function (t) {
|
||||
return '<section id="utr-' + escapeAttr(t.id) + '" class="utr-panel">' +
|
||||
'<h2>' + escapeHtml(normalize(t.sec.heading)) + '</h2>' +
|
||||
renderBody(t.sec.body) +
|
||||
'</section>';
|
||||
}).join('');
|
||||
const sectionsHtml = (data.sections || []).map(function (s, i) {
|
||||
return '<section id="utr-sec-' + i + '"><h2>' + escapeHtml(s.heading) + '</h2><div>' + escapeHtml(s.body).replace(/\n/g, '<br>') + '</div></section>';
|
||||
}).join('');
|
||||
slot.innerHTML =
|
||||
'<div class="report-utredning" style="display: grid; grid-template-columns: 220px 1fr; gap: var(--space-6);">' +
|
||||
'<aside class="utredning-toc"><h4>Innhold</h4><ol>' + tocHtml + '</ol></aside>' +
|
||||
'<article class="report-doc">' +
|
||||
'<h1>' + escapeHtml(data.title || 'Utredning') + '</h1>' +
|
||||
sectionsHtml +
|
||||
'</article>' +
|
||||
'</div>';
|
||||
// Resterende seksjoner (mandat, metode, referanser m.fl.) under en samlende read-more.
|
||||
const otherSecs = sections.filter(function (s, i) { return !usedIdx.has(i); });
|
||||
const otherHtml = otherSecs.length ? '<details class="read-more-block utr-other"><summary>Vis øvrige seksjoner (' + otherSecs.length + ')</summary>' +
|
||||
otherSecs.map(function (s) {
|
||||
return '<section><h3>' + escapeHtml(normalize(s.heading)) + '</h3>' + renderBody(s.body) + '</section>';
|
||||
}).join('') +
|
||||
'</details>' : '';
|
||||
const body = tabsNavHtml + tabsBodyHtml + otherHtml;
|
||||
slot.innerHTML = renderPageShell({
|
||||
eyebrow: 'UTREDNING',
|
||||
title: data.title || 'AI-arkitekturutredning',
|
||||
lede: data.lede || 'Strukturert utredning med kuraterte seksjoner: bakgrunn, funn, konklusjon og anbefaling.',
|
||||
verdict: data.verdict || 'n-a',
|
||||
keyStats: data.keyStats || [
|
||||
{ label: 'TABS', value: tabs.length, hint: 'av 4' },
|
||||
{ label: 'SEKSJONER', value: sections.length },
|
||||
{ label: 'ØVRIGE', value: otherSecs.length, hint: 'andre seksjoner' }
|
||||
]
|
||||
}, body);
|
||||
}
|
||||
|
||||
function renderCompare(data, slot) {
|
||||
|
|
@ -3965,6 +4261,35 @@
|
|||
else if (fw2 && w.indexOf(fw2) >= 0) count2++;
|
||||
else lik++;
|
||||
});
|
||||
// Vinner: eksplisitt parseComparison.winner ELLER auto fra row-counts.
|
||||
const explicitWin = String(data.winner || '').toLowerCase();
|
||||
let winnerIdx = -1;
|
||||
if (explicitWin) {
|
||||
if (fw1 && explicitWin.indexOf(fw1) >= 0) winnerIdx = 0;
|
||||
else if (fw2 && explicitWin.indexOf(fw2) >= 0) winnerIdx = 1;
|
||||
}
|
||||
if (winnerIdx < 0 && (count1 || count2)) {
|
||||
winnerIdx = count1 > count2 ? 0 : count2 > count1 ? 1 : -1;
|
||||
}
|
||||
// D1 scenario-cards-grid per kandidat. Vinner får data-status="met",
|
||||
// taper "partial", tied/no-winner forblir "partial".
|
||||
const cardSubjects = subjects.map(function (s, i) {
|
||||
const wins = i === 0 ? count1 : count2;
|
||||
const status = i === winnerIdx ? 'met' : 'partial';
|
||||
const total = (data.rows || []).length;
|
||||
return { name: s, wins: wins, total: total, status: status, isWinner: i === winnerIdx };
|
||||
});
|
||||
const cardsHtml = '<div class="scenario-card-grid">' + cardSubjects.map(function (c) {
|
||||
const winnerBadge = c.isWinner ? '<span class="scenario-card__count" style="background: var(--color-state-success); color: #fff;">VINNER</span>' : '<span class="scenario-card__count">' + c.wins + '/' + c.total + '</span>';
|
||||
return '<div class="scenario-card" data-status="' + escapeAttr(c.status) + '">' +
|
||||
'<div class="scenario-card__head">' +
|
||||
'<span class="scenario-card__source">KANDIDAT</span>' +
|
||||
winnerBadge +
|
||||
'</div>' +
|
||||
'<h4 class="scenario-card__title">' + escapeHtml(c.name) + '</h4>' +
|
||||
'<div class="scenario-card__source">' + c.wins + ' vinn · ' + (c.total - c.wins) + ' lik/tap</div>' +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
const summaryHtml =
|
||||
'<div class="diff__summary">' +
|
||||
'<div class="diff__summary-item"><span class="diff__summary-count">' + count1 + '</span> ' + escapeHtml(subjects[0]) + '</div>' +
|
||||
|
|
@ -3986,7 +4311,27 @@
|
|||
'<div class="diff__cell ' + cls2 + '"><strong>' + escapeHtml(r.aspect) + ':</strong> ' + escapeHtml(r.value2) + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
slot.innerHTML = '<div class="diff">' + summaryHtml + headerHtml + rowsHtml + '</div>';
|
||||
const diffHtml = '<div class="diff">' + summaryHtml + headerHtml + rowsHtml + '</div>';
|
||||
const body = cardsHtml + diffHtml;
|
||||
// Verdict-pille: vinner satt → 'go' (klar anbefaling). Tied/uavklart → 'go-with-conditions'.
|
||||
const verdict = data.verdict || (winnerIdx >= 0 ? 'go' : 'go-with-conditions');
|
||||
// Utvid comparison-keyStats med VINNER-felt.
|
||||
const baseStats = inferKeyStats(data, 'comparison');
|
||||
const stats = data.keyStats || (winnerIdx >= 0
|
||||
? baseStats.concat([{
|
||||
label: 'VINNER',
|
||||
value: String(subjects[winnerIdx] || '').slice(0, 24),
|
||||
hint: (winnerIdx === 0 ? count1 : count2) + ' vinn',
|
||||
modifier: 'low'
|
||||
}])
|
||||
: baseStats.concat([{ label: 'VINNER', value: 'UAVKLART', modifier: 'medium' }]));
|
||||
slot.innerHTML = renderPageShell({
|
||||
eyebrow: 'SAMMENLIGN',
|
||||
title: data.title || 'Sammenligning',
|
||||
lede: data.lede || 'Aspekt-for-aspekt-sammenligning av to kandidater med vinner-pille og diff-tabell.',
|
||||
verdict: verdict,
|
||||
keyStats: stats
|
||||
}, body);
|
||||
}
|
||||
|
||||
// === V2_FOUNDATION_BEGIN ===
|
||||
|
|
@ -4368,6 +4713,13 @@
|
|||
return;
|
||||
}
|
||||
const result = parser(markdown);
|
||||
// Topic 2 strategi A: sentralisert _consumer-tildeling i import-flow.
|
||||
// Respekterer parser-spesifikk verdi (f.eks. parseMatrixRisk → 'ros').
|
||||
// Renderere kan bruke _consumer for å velge variant-spesifikk markup
|
||||
// der parser-arketypen er delt mellom flere commands.
|
||||
if (result && result.ok && result.data && result.data._consumer == null) {
|
||||
result.data._consumer = cmd.id;
|
||||
}
|
||||
slot.innerHTML = '';
|
||||
if (result.ok) renderer(result.data, slot);
|
||||
else renderError(result.errors, slot);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ Subject 2: Azure ML + AKS
|
|||
|
||||
Azure AI Foundry vinner på time-to-prod, compliance-pakke, og driftbarhet. Azure ML + AKS vinner på pris (-12%) og fleksibilitet. Differansen i pris (~NOK 800k over 3 år) er liten sammenlignet med besparelsen i drift-tid for AI-teamet.
|
||||
|
||||
## Vinner: Azure AI Foundry
|
||||
|
||||
## Anbefaling
|
||||
|
||||
For Acme AS med begrenset KI-driftkapasitet anbefales Azure AI Foundry. For organisasjoner med dedikert MLOps-team kan Azure ML + AKS gi marginalt bedre kost-nytte.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ Til: Azure AI Foundry + saksbehandler-co-pilot
|
|||
### Fase 1 — Foundry-fundament (uker 1-6)
|
||||
|
||||
Varighet: 6 uker
|
||||
Status: done
|
||||
|
||||
Milepæler:
|
||||
- Hub + projects opprettet i West Europe
|
||||
|
|
@ -24,6 +25,7 @@ Suksesskriterier:
|
|||
### Fase 2 — Modell-trening og baseline (uker 7-14)
|
||||
|
||||
Varighet: 8 uker
|
||||
Status: done
|
||||
|
||||
Milepæler:
|
||||
- Treningsdata kuratert (200k norske objekt-ID, stratifisert)
|
||||
|
|
@ -39,6 +41,7 @@ Suksesskriterier:
|
|||
### Fase 3 — saksbehandler-co-pilot (uker 15-22)
|
||||
|
||||
Varighet: 8 uker
|
||||
Status: active
|
||||
|
||||
Milepæler:
|
||||
- Forklaringsmodell (GPT-4 Turbo) integrert via Foundry
|
||||
|
|
@ -54,6 +57,7 @@ Suksesskriterier:
|
|||
### Fase 4 — Compliance og produksjonssetting (uker 23-28)
|
||||
|
||||
Varighet: 6 uker
|
||||
Status: planned
|
||||
|
||||
Milepæler:
|
||||
- FRIA gjennomført og godkjent
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ POC-mål: Validere at Azure AI Foundry kan dekke OCR + forklaring + audit innen
|
|||
### Fase 1 — Foundation (uker 1-2)
|
||||
|
||||
Varighet: 2 uker
|
||||
Status: done
|
||||
|
||||
Milepæler:
|
||||
- Foundry hub + project i West Europe
|
||||
|
|
@ -22,6 +23,7 @@ Suksesskriterier:
|
|||
### Fase 2 — OCR-modell (uker 3-5)
|
||||
|
||||
Varighet: 3 uker
|
||||
Status: active
|
||||
|
||||
Milepæler:
|
||||
- Pre-trent Azure AI Vision OCR pilotert
|
||||
|
|
@ -36,6 +38,7 @@ Suksesskriterier:
|
|||
### Fase 3 — Forklarings-loop (uker 6-7)
|
||||
|
||||
Varighet: 2 uker
|
||||
Status: planned
|
||||
|
||||
Milepæler:
|
||||
- GPT-4 Turbo via Foundry integrert
|
||||
|
|
@ -50,6 +53,7 @@ Suksesskriterier:
|
|||
### Fase 4 — Compliance-pre-check (uke 8)
|
||||
|
||||
Varighet: 1 uke
|
||||
Status: planned
|
||||
|
||||
Milepæler:
|
||||
- Audit-logg mot EU AI Act Art. 12-krav
|
||||
|
|
@ -70,6 +74,10 @@ Suksesskriterier:
|
|||
| saksbehandler-recruitment forsinker fase 3 | medium | low | Bruk interne ressurser i AI-teamet som mock |
|
||||
| Audit-logg-format ikke kompatibelt med Sentinel | low | medium | Test integrasjon i fase 1 |
|
||||
|
||||
## POC-Verdict: BETINGET
|
||||
|
||||
Pilot-fase 1 fullført med F1=0.94 og inference-cost 0.038 NOK/kall (under budsjett). Fase 2 pågår — sammenligning av custom fine-tune mot pre-trent OCR i progress. Forklarings-loop og compliance-pre-check planlagt for siste halvdel.
|
||||
|
||||
## Total varighet
|
||||
|
||||
8 uker. Beslutningskriterium for full prosjektgodkjenning: alle 4 fasers suksesskriterier møtt.
|
||||
|
|
|
|||
|
|
@ -456,6 +456,103 @@ else
|
|||
fail "suppressed markup mangler (Step 11 must_contain krever >=1)"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 25e. SC8 — per-renderer verdict-pill emission for Sub-batch C (R7)
|
||||
# Hver av de 8 Sub-batch C-rendererene må enten emitte data-verdict direkte
|
||||
# i sin body, eller invokere renderPageShell (som emitter via helper).
|
||||
# -------------------------------------------------------
|
||||
SC8_RENDERERS_C="renderCost renderLicense renderMigrate renderAdr renderSummary renderPoc renderUtredning renderCompare"
|
||||
for fn in $SC8_RENDERERS_C; do
|
||||
body=$(awk "/function $fn\(/,/^ \}$/" "$HTML_FILE")
|
||||
if echo "$body" | grep -qE "verdict[^A-Za-z]*data-verdict\s*=\s*[\"'](go|go-with-conditions|block|approved|failed|allow|warning|n-a)[\"']" \
|
||||
|| echo "$body" | grep -q "renderPageShell"; then
|
||||
pass "SC8 verdict-pill: $fn (direkte eller via renderPageShell)"
|
||||
else
|
||||
fail "SC8 verdict-pill: $fn mangler både data-verdict og renderPageShell"
|
||||
fi
|
||||
done
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 25f. Step 12 must_contain — mat-ladder + screen-tabs + _consumer
|
||||
# -------------------------------------------------------
|
||||
matladder_count=$( { grep -cE "mat-ladder" "$HTML_FILE" || true; } | tr -d ' ')
|
||||
if [ "${matladder_count:-0}" -ge 1 ]; then
|
||||
pass "mat-ladder markup til stede ($matladder_count treff, Step 12 must_contain)"
|
||||
else
|
||||
fail "mat-ladder markup mangler (Step 12 must_contain krever >=1)"
|
||||
fi
|
||||
|
||||
screentabs_count=$( { grep -cE "screen-tabs" "$HTML_FILE" || true; } | tr -d ' ')
|
||||
if [ "${screentabs_count:-0}" -ge 1 ]; then
|
||||
pass "screen-tabs markup til stede ($screentabs_count treff, Step 12 must_contain)"
|
||||
else
|
||||
fail "screen-tabs markup mangler (Step 12 must_contain krever >=1)"
|
||||
fi
|
||||
|
||||
consumer_count=$( { grep -cE "_consumer" "$HTML_FILE" || true; } | tr -d ' ')
|
||||
if [ "${consumer_count:-0}" -ge 1 ]; then
|
||||
pass "_consumer-mekanisme til stede ($consumer_count treff, Step 12 must_contain)"
|
||||
else
|
||||
fail "_consumer-mekanisme mangler (Step 12 must_contain krever >=1)"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 25g. Felles grunnskjelett — alle 17 renderers emiter page__eyebrow + h1 + verdict
|
||||
# Step 12 strukturell assert per R7 + plan-spec.
|
||||
# -------------------------------------------------------
|
||||
ALL_RENDERERS="renderAiActPyramid renderRequirements renderTransparency renderFria renderConformity renderDpia renderSecurity renderRos renderReview renderCost renderLicense renderMigrate renderAdr renderSummary renderPoc renderUtredning renderCompare"
|
||||
shell_count=0
|
||||
for fn in $ALL_RENDERERS; do
|
||||
body=$(awk "/function $fn\(/,/^ \}$/" "$HTML_FILE")
|
||||
if echo "$body" | grep -q "renderPageShell"; then
|
||||
shell_count=$((shell_count + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$shell_count" -eq 17 ]; then
|
||||
pass "Alle 17 renderers bruker renderPageShell (felles grunnskjelett: page__eyebrow + h1 + verdict via helper)"
|
||||
else
|
||||
fail "Kun $shell_count/17 renderers bruker renderPageShell — felles grunnskjelett ufullstendig"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 25h. Tier 3-bruk per Step 12 — kanban (conformity/review), mat-ladder (migrate/poc)
|
||||
# -------------------------------------------------------
|
||||
for fn in renderConformity renderReview; do
|
||||
body=$(awk "/function $fn\(/,/^ \}$/" "$HTML_FILE")
|
||||
if echo "$body" | grep -qE "kanban-board|kanban-col"; then
|
||||
pass "Tier 3 kanban: $fn bruker kanban-markup"
|
||||
else
|
||||
fail "Tier 3 kanban: $fn mangler kanban-markup"
|
||||
fi
|
||||
done
|
||||
for fn in renderMigrate renderPoc; do
|
||||
body=$(awk "/function $fn\(/,/^ \}$/" "$HTML_FILE")
|
||||
if echo "$body" | grep -q "mat-ladder"; then
|
||||
pass "Tier 3 mat-ladder: $fn bruker mat-ladder"
|
||||
else
|
||||
fail "Tier 3 mat-ladder: $fn mangler mat-ladder"
|
||||
fi
|
||||
done
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 25i. Onboarding-config field-type-distribution (4 strukturerte / 14 fritekst)
|
||||
# Step 12 strukturell assert per R4 / R7. Counts ONBOARDING_SCHEMA-felter
|
||||
# med type=select/multiSelect (strukturerte) vs text/textarea (fritekst).
|
||||
# -------------------------------------------------------
|
||||
onb_block=$(awk '/const ONBOARDING_SCHEMA = \[/,/^ \];/' "$HTML_FILE")
|
||||
struct_count=$(printf '%s\n' "$onb_block" | grep -cE "type:\s*'(select|multiSelect)'" | tr -d ' ')
|
||||
free_count=$(printf '%s\n' "$onb_block" | grep -cE "type:\s*'(text|textarea)'" | tr -d ' ')
|
||||
if [ "${struct_count:-0}" -ge 4 ] && [ "${struct_count:-0}" -le 5 ]; then
|
||||
pass "Onboarding strukturerte felter: $struct_count (forventet 4)"
|
||||
else
|
||||
fail "Onboarding strukturerte felter: $struct_count (forventet 4)"
|
||||
fi
|
||||
if [ "${free_count:-0}" -ge 13 ] && [ "${free_count:-0}" -le 16 ]; then
|
||||
pass "Onboarding fritekst-felter: $free_count (forventet ~14)"
|
||||
else
|
||||
fail "Onboarding fritekst-felter: $free_count (forventet ~14)"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 25. Inline-script eneste JS — ingen <script src=> til lokale .js-filer
|
||||
# -------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue