refactor(ms-ai-architect): playground v1.14.0 sesjon 5b — verifikasjon av lavt-scope-renderere

- renderCost: FIX — KEY_STATS_CONFIG['cost-distribution'] og inferVerdict('cost-distribution') viste "[object Object]" / returnerte alltid 'go' fordi parser-output har p50/p90 = {monthly, yearly}-objekter, ikke tall. Begge ekstraherer nå .monthly med fallback for flate fixtures.
- renderLicense: PASS — ingen kode-endring. Capability-matrix-status korrekt utledet (met/partial/missing) via parseCapabilityMatrix. Visuell QA gjenstår i sesjon 6.
- renderCompare: FIX — firstWord-heuristikk feilet når begge subjekter delte førsteord (f.eks. "Azure AI Foundry" vs "Azure ML + AKS" ga begge fw='azure', kollapset vinn-attribusjon). Erstattet med distinctive-token-matching: full-subject-substring først, deretter ord som er unike for ett subjekt. Diff-cell coloring oppdatert til samme matchSubject()-helper.
- renderUtredning: MINOR — droppet misvisende role="tab"/role="tablist" siden vi rendrer anchor-jump-TOC (alle paneler synlige), ikke ekte tab-toggle. Beholdt aria-current="true" for visuell aktiv-markør (DS-CSS hekter på den). Ekte tab-toggle defer til v1.15.0.

validate-plugin.sh: 219 PASS uendret
run-e2e.sh --playground: 272 PASS uendret
test-playground-migrations.sh: 7 PASS uendret

Refs V1.14.0-AUDIT.local.md sub-batch E (sesjon 5b).
This commit is contained in:
Kjell Tore Guttormsen 2026-05-08 20:55:45 +02:00
commit 0033404e7a

View file

@ -4485,8 +4485,14 @@
}
return '<div>' + escapeHtml(txt).replace(/\n/g, '<br>') + '</div>';
};
const tabsNavHtml = tabs.length ? '<nav class="tab-list" role="tablist" aria-label="Utredning">' + tabs.map(function (t, i) {
return '<a class="tab" role="tab" aria-current="' + (i === 0 ? 'true' : 'false') + '" href="#utr-' + escapeAttr(t.id) + '">' + escapeHtml(t.label) + '</a>';
// v1.14.0 sesjon 5b: Avvik fra DS-default — vi bruker <a href="#..."> +
// alle paneler synlige (anchor-jump-TOC), ikke ekte tab-toggle med
// hidden paneler. Dropper derfor role="tab/tablist" siden de impliserer
// tab-control-semantikk vi ikke leverer. aria-current="true" beholdes
// som visuell aktiv-markør (DS-CSS hekter på den). Ekte tab-toggle med
// <button> + JS-state defer til v1.15.0.
const tabsNavHtml = tabs.length ? '<nav class="tab-list" aria-label="Seksjoner">' + tabs.map(function (t, i) {
return '<a class="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">' +
@ -4517,24 +4523,43 @@
function renderCompare(data, slot) {
const subjects = (data.subjects && data.subjects.length === 2) ? data.subjects : ['Subjekt 1', 'Subjekt 2'];
const firstWord = function (s) { return (s || '').toLowerCase().split(/\s+/)[0] || ''; };
const fw1 = firstWord(subjects[0]);
const fw2 = firstWord(subjects[1]);
// v1.14.0 sesjon 5b: firstWord-heuristikk feilet når begge subjekter
// delte førsteord (f.eks. "Azure AI Foundry" vs "Azure ML + AKS" ga
// fw1=fw2='azure'). Bytt til distinctive-token-matching: full-subject-
// substring først, deretter ord som er unike for ett subjekt.
const subjLow1 = String(subjects[0] || '').toLowerCase();
const subjLow2 = String(subjects[1] || '').toLowerCase();
const tok = function (s) {
return String(s || '').toLowerCase().split(/[^a-z0-9æøå]+/).filter(Boolean);
};
const t1 = tok(subjects[0]);
const t2 = tok(subjects[1]);
const set1 = new Set(t1.filter(function (w) { return t2.indexOf(w) < 0; }));
const set2 = new Set(t2.filter(function (w) { return t1.indexOf(w) < 0; }));
const matchSubject = function (raw) {
const w = String(raw || '').toLowerCase().trim();
if (!w || /^(lik|begge|—|-)$/.test(w)) return -1;
if (subjLow1 && w.indexOf(subjLow1) >= 0) return 0;
if (subjLow2 && w.indexOf(subjLow2) >= 0) return 1;
const wTokens = tok(w);
let m1 = 0, m2 = 0;
wTokens.forEach(function (x) {
if (set1.has(x)) m1++;
if (set2.has(x)) m2++;
});
if (m1 > m2) return 0;
if (m2 > m1) return 1;
return -1;
};
let count1 = 0, count2 = 0, lik = 0;
(data.rows || []).forEach(function (r) {
const w = (r.winner || '').toLowerCase();
if (!w || /lik|begge|—|-/.test(w)) lik++;
else if (fw1 && w.indexOf(fw1) >= 0) count1++;
else if (fw2 && w.indexOf(fw2) >= 0) count2++;
const idx = matchSubject(r.winner);
if (idx === 0) count1++;
else if (idx === 1) 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;
}
let winnerIdx = matchSubject(data.winner);
if (winnerIdx < 0 && (count1 || count2)) {
winnerIdx = count1 > count2 ? 0 : count2 > count1 ? 1 : -1;
}
@ -4569,10 +4594,9 @@
'<div class="diff__cell diff__cell--unchanged"><strong>' + escapeHtml(subjects[1]) + '</strong></div>' +
'</div>';
const rowsHtml = (data.rows || []).map(function (r) {
const w = (r.winner || '').toLowerCase();
let cls1 = 'diff__cell--unchanged', cls2 = 'diff__cell--unchanged';
if (fw1 && w.indexOf(fw1) >= 0) cls1 = 'diff__cell--added';
if (fw2 && w.indexOf(fw2) >= 0) cls2 = 'diff__cell--added';
const idx = matchSubject(r.winner);
const cls1 = idx === 0 ? 'diff__cell--added' : 'diff__cell--unchanged';
const cls2 = idx === 1 ? 'diff__cell--added' : 'diff__cell--unchanged';
return '<div class="diff__row">' +
'<div class="diff__cell ' + cls1 + '"><strong>' + escapeHtml(r.aspect) + ':</strong> ' + escapeHtml(r.value1) + '</div>' +
'<div class="diff__cell ' + cls2 + '"><strong>' + escapeHtml(r.aspect) + ':</strong> ' + escapeHtml(r.value2) + '</div>' +
@ -4720,9 +4744,14 @@
];
},
'cost-distribution': function (d) {
// parseCostDistribution emitterer p50/p90 som {monthly, yearly}-objekter,
// ikke tall. Trekk ut monthly før formatNok (ellers returnerer den
// "[object Object]"). Number-fallback tillater også flate fixtures.
const p50m = (d.p50 && typeof d.p50 === 'object') ? d.p50.monthly : d.p50;
const p90m = (d.p90 && typeof d.p90 === 'object') ? d.p90.monthly : d.p90;
return [
{ label: 'P50', value: formatNok(d.p50), hint: 'median' },
{ label: 'P90', value: formatNok(d.p90), hint: 'pessimistisk', modifier: 'high' },
{ label: 'P50', value: formatNok(p50m), hint: 'median' },
{ label: 'P90', value: formatNok(p90m), hint: 'pessimistisk', modifier: 'high' },
{ label: 'KOMPONENTER', value: (d.monthly_breakdown || []).length }
];
},
@ -4825,8 +4854,13 @@
return crit ? 'block' : 'warning';
}
case 'cost-distribution': {
// Samme p50/p90-objekt-shape som KEY_STATS_CONFIG over —
// trekk ut .monthly før Number-konvertering, ellers blir
// ratioen alltid NaN og verdict feiler stille til 'go'.
if (data.p90 != null && data.p50 != null) {
const ratio = Number(data.p90) / Math.max(Number(data.p50), 1);
const p50m = (data.p50 && typeof data.p50 === 'object') ? data.p50.monthly : data.p50;
const p90m = (data.p90 && typeof data.p90 === 'object') ? data.p90.monthly : data.p90;
const ratio = Number(p90m) / Math.max(Number(p50m), 1);
return ratio > 2 ? 'warning' : 'go';
}
return 'n-a';