okr/tests/syklus-rapport.test.mjs

148 lines
6.5 KiB
JavaScript

// syklus-rapport.test.mjs
// D5 steg 9: tertialrapport-generatoren. Tester tre bindende krav:
// (1) committed og aspirational rapporteres HVER FOR SEG (okr-framework.md:557)
// (2) de tre formateringsinvariantene (okr-offentlig-governance.md:148-156)
// (3) hook-kontrakten mot hooks/scripts/coaching-hook.mjs:86-92
//
// Offline, zero-dep, node:test. Test-kilden holdes ASCII-ren (bash 3.2 set -u).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { lesSyklus } from '../lib/syklus-data.mjs';
import { tertialrapport } from '../lib/syklus-rapport.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SYKLUS_DIR = join(ROOT, 'tests/fixtures/okf-realistic/syklus/T1-2026');
const NAA = '2026-05-01T10:00:00.000Z';
const rapport = () => tertialrapport(lesSyklus(SYKLUS_DIR), { naa: NAA });
// --- Hook-kontrakten: predikatene LESES fra hooken, aldri kopieres ---
//
// En kopiert regex beviser at testen er enig med seg selv. Ved aa hente
// literalene ut av coaching-hook.mjs' egen kilde blir en fremtidig endring i
// hooken en ROED suite her, i stedet for en stille inkompatibilitet.
// Hooken er i forbidden_paths for dette steget -- forbudt aa ENDRE, ikke aa LESE.
function hookPredikater() {
const src = readFileSync(join(ROOT, 'hooks/scripts/coaching-hook.mjs'), 'utf8');
const funnet = [...src.matchAll(/(\/(?:[^/\\\n]|\\.)+\/[gimsuy]*)\.test\(line\)/g)].map(
(m) => m[1],
);
assert.equal(
funnet.length,
2,
`parser-sanity: forventet 2 rad-predikater i coaching-hook.mjs, fant ${funnet.length}`,
);
const kompiler = (lit) => {
const siste = lit.lastIndexOf('/');
return new RegExp(lit.slice(1, siste), lit.slice(siste + 1));
};
return { radForm: kompiler(funnet[0]), fareOrd: kompiler(funnet[1]) };
}
const erKrRad = (linje) => /^\s*\|/.test(linje) && !/^\s*\|[\s|:-]+\|\s*$/.test(linje)
&& !/\|\s*KR\s*\|/.test(linje);
test('(1) committed og aspirational rapporteres hver for seg', () => {
const md = rapport();
assert.match(md, /^##\s+Committed/m, 'mangler committed-seksjon');
assert.match(md, /^##\s+Aspirational/m, 'mangler aspirational-seksjon');
});
// okr-framework.md:557 -- aa aggregere de to typene til ett tall blander to
// maalestokker og gjoer resultatet tvetydig.
test('(1b) rapporten emitterer ingen felles samlet score', () => {
const md = rapport();
const offenders = md.split('\n').filter((l) => /samlet\s+score/i.test(l));
assert.deepEqual(offenders, [], `felles aggregat funnet:\n${offenders.join('\n')}`);
});
test('(2a) invariant 1: typen er merket i rapporteringen', () => {
const md = rapport();
assert.match(md, /committed/i);
assert.match(md, /aspirational/i);
});
test('(2b) invariant 2: committed rapporteres binaert mot kravet', () => {
const md = rapport();
// Avvik-kolonnen finnes, og tunnel-KR2 (17 av 20) er et avvik selv om
// scoren er 0.63 -- et krav er naadd eller ikke.
assert.match(md, /\|\s*Avvik\s*\|/, 'mangler Avvik-kolonne for committed');
const rad = md.split('\n').find((l) => l.includes('oppgradert sikkerhetsutrustning'));
assert.ok(rad, 'fant ikke committed-KR-raden');
assert.match(rad, /\|\s*Ja\s*\|/, 'committed under target maa merkes som avvik');
});
test('(2c) invariant 3: skalaen forklares, og score konverteres ikke til prosent', () => {
const md = rapport();
assert.match(md, /skala/i, 'score-skalaen maa forklares eksplisitt');
const prosentPaaScore = md.split('\n').filter((l) => /score[^|\n]*%|%[^|\n]*score/i.test(l));
assert.deepEqual(prosentPaaScore, [], 'score skal ikke presenteres som prosent maaloppnaaelse');
});
// B-2-kanten moetes her av DATA (fixturens kr1 har target == baseline).
// Uten en eksplisitt literal ville cellen blitt "undefined" eller kastet.
test('(3) udefinert score rendres som en literal, ikke som undefined eller NaN', () => {
const md = rapport();
assert.match(md, /udefinert/, 'target == baseline maa rendres som "udefinert"');
assert.doesNotMatch(md, /undefined|NaN/, 'raa JS-verdier lekket til rapporten');
});
test('(4) determinisme: to kjoeringer med samme naa gir byte-identisk streng', () => {
assert.equal(rapport(), rapport());
});
test('(4b) klokke-soemmen: OKR_NOW styrer tidsstempelet', (t) => {
const forrige = process.env.OKR_NOW;
t.after(() => {
if (forrige === undefined) delete process.env.OKR_NOW;
else process.env.OKR_NOW = forrige;
});
process.env.OKR_NOW = '2026-06-15T08:00:00.000Z';
const md = tertialrapport(lesSyklus(SYKLUS_DIR));
assert.match(md, /2026-06-15T08:00:00\.000Z/);
});
// --- (5) Hook-kontrakten, tredelt ---
test('(5a) hook-kontrakt: hver KR-rad matcher hookens radform', () => {
const { radForm } = hookPredikater();
const rader = rapport().split('\n').filter(erKrRad);
assert.ok(rader.length >= 4, `parser-sanity: fant ${rader.length} KR-rader`);
const avvikende = rader.filter((l) => !radForm.test(l));
assert.deepEqual(avvikende, [], `rader hooken ikke kan telle:\n${avvikende.join('\n')}`);
});
test('(5b) hook-kontrakt: en kanonisk etikett i Confidence-cellen goer raden tellbar', () => {
const { radForm, fareOrd } = hookPredikater();
const rad = rapport().split('\n').filter(erKrRad)[0];
// Confidence er siste kolonne og staar tom by design -- fyll den slik
// /okr:sporing ville gjort det.
const fylt = rad.replace(/\|\s*\|\s*$/, '| At Risk |');
assert.notEqual(fylt, rad, 'fant ingen tom Confidence-celle aa fylle');
assert.ok(radForm.test(fylt) && fareOrd.test(fylt), 'hooken ville ikke telt den fylte raden');
});
// LOAD-BEARING. Generatoren eier aritmetikk, aldri confidence:
// okr-framework.md:563 gjoer confidence-tabellen til eneste sannhetskilde, og
// okr-calculator.md:249 avviser mekanisk utledning eksplisitt ("ETT innspill
// ... ikke som en mekanisk regel"). En fremtidig sesjon som "hjelpsomt" utleder
// et trafikklys fra score ville oppfunnet en terskel doktrinen forbyr.
test('(5c) generatoren emitterer aldri en confidence-etikett selv', () => {
const { fareOrd } = hookPredikater();
const offenders = rapport().split('\n').filter((l) => fareOrd.test(l));
assert.deepEqual(
offenders,
[],
`generatoren utledet confidence mekanisk -- forbudt av okr-calculator.md:249:\n${offenders.join('\n')}`,
);
});
test('(6) tertialrapport kaster ved tom eller ufullstendig syklus', () => {
assert.throws(() => tertialrapport({ id: 'T1-2026', okrer: [] }), /ingen OKR/i);
assert.throws(() => tertialrapport(undefined), /syklus/i);
});