294 lines
13 KiB
JavaScript
294 lines
13 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);
|
|
});
|
|
|
|
// --- (2d) Avvik-kolonnen maa vaere retningsagnostisk ---
|
|
//
|
|
// `naa >= target` ser riktig ut for oppadgaaende maal og er FEIL for
|
|
// nedadgaaende: et krav om 5 dager, naa 11, gir 11 >= 5 = "ingen avvik" -- et
|
|
// brudd rapportert som etterlevd i et Riksrevisjon-vendt dokument. Fixturen kan
|
|
// ikke fange det (dens committed-KR er begge oppadgaaende, og dens nedadgaaende
|
|
// KR er aspirational, som kortslutter til "-"), saa syklusen konstrueres her.
|
|
// Retningen hoerer hjemme i beregnScore, som allerede haandterer den.
|
|
const NEDADGAAENDE = {
|
|
id: 'T-avvik',
|
|
okrer: [{
|
|
fil: 'okr-avvik.md',
|
|
tittel: 'Nedadgaaende krav',
|
|
krer: [
|
|
{ n: 1, navn: 'Saksbehandlingstid (dager)', baseline: 14, target: 5, naa: 11, type: 'committed' },
|
|
{ n: 2, navn: 'Alvorlige hendelser (antall)', baseline: 0, target: 0, naa: 3, type: 'committed' },
|
|
{ n: 3, navn: 'Naadd krav (dager)', baseline: 14, target: 5, naa: 4, type: 'committed' },
|
|
],
|
|
}],
|
|
};
|
|
|
|
const avvikFor = (navn) => {
|
|
const rad = tertialrapport(NEDADGAAENDE, { naa: NAA }).split('\n').find((l) => l.includes(navn));
|
|
assert.ok(rad, `fant ikke raden for ${navn}`);
|
|
return rad.split('|').map((c) => c.trim())[6];
|
|
};
|
|
|
|
test('(2d) committed nedadgaaende KR under kravet merkes som avvik', () => {
|
|
assert.equal(avvikFor('Saksbehandlingstid'), 'Ja', 'naa 11 mot krav 5 er et avvik');
|
|
});
|
|
|
|
test('(2d2) committed KR med target == baseline avviker naar naa ikke er lik kravet', () => {
|
|
assert.equal(avvikFor('Alvorlige hendelser'), 'Ja', '3 hendelser mot krav 0 er et avvik');
|
|
});
|
|
|
|
test('(2d3) committed KR som naar kravet merkes ikke som avvik', () => {
|
|
assert.equal(avvikFor('Naadd krav'), 'Nei', 'naa 4 mot krav 5 (nedadgaaende) er oppfylt');
|
|
});
|
|
|
|
// Rapporten sier selv at "en score under 1.0 er et avvik som skal forklares".
|
|
// Avvik-kolonnen maa aldri motsi den setningen paa samme rad.
|
|
test('(2d4) Avvik-kolonnen motsier ikke rapportens egen score-setning', () => {
|
|
const md = tertialrapport(NEDADGAAENDE, { naa: NAA });
|
|
const motsigelser = md.split('\n').filter((l) => {
|
|
const c = l.split('|').map((x) => x.trim());
|
|
if (c.length < 8 || c[6] !== 'Nei') return false;
|
|
return c[5] !== 'udefinert' && Number(c[5]) < 1;
|
|
});
|
|
assert.deepEqual(motsigelser, [], `score < 1.0 merket som ikke-avvik:\n${motsigelser.join('\n')}`);
|
|
});
|
|
|
|
// --- (7) CLI-kontrakten (steg 10) ---
|
|
//
|
|
// Testet BEGGE VEIER i samme fil: direkte import for atferd, execFileSync for
|
|
// exit-koden -- fordi exit-koden ER kontrakten kommandofila mapper til norsk
|
|
// brukertekst. En atferdstest alene ville ikke fanget at 1 og 2 byttet plass.
|
|
// Moenster: tests/innboks-ingest.test.mjs (execFileSync for exit-kode).
|
|
|
|
const CLI = join(ROOT, 'scripts/syklus-rapport.mjs');
|
|
|
|
async function kjoerCli(args, opts = {}) {
|
|
const { execFileSync } = await import('node:child_process');
|
|
try {
|
|
const stdout = execFileSync(process.execPath, [CLI, ...args], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, OKR_NOW: NAA, ...(opts.env ?? {}) },
|
|
});
|
|
return { code: 0, stdout };
|
|
} catch (e) {
|
|
return { code: e.status, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
|
|
}
|
|
}
|
|
|
|
// Egen kopi av syklusen per test som skriver -- CLI-en persisterer, og en test
|
|
// skal aldri skitne til fixturen den delte med de andre testene.
|
|
async function midlertidigSyklus(filer) {
|
|
const { mkdtempSync, writeFileSync, mkdirSync, copyFileSync, readdirSync } = await import('node:fs');
|
|
const { tmpdir } = await import('node:os');
|
|
const rot = mkdtempSync(join(tmpdir(), 'okr-rapport-'));
|
|
const dir = join(rot, 'T1-2026');
|
|
mkdirSync(dir);
|
|
if (filer) {
|
|
for (const [navn, innhold] of Object.entries(filer)) writeFileSync(join(dir, navn), innhold);
|
|
} else {
|
|
for (const f of readdirSync(SYKLUS_DIR)) copyFileSync(join(SYKLUS_DIR, f), join(dir, f));
|
|
}
|
|
return { rot, dir };
|
|
}
|
|
|
|
test('(7a) CLI: exit 2 ved feil aritet', async () => {
|
|
assert.equal((await kjoerCli([])).code, 2);
|
|
assert.equal((await kjoerCli([SYKLUS_DIR])).code, 2);
|
|
});
|
|
|
|
test('(7b) CLI: exit 2 ved ukjent og ved ennaa uimplementert form', async () => {
|
|
assert.equal((await kjoerCli([SYKLUS_DIR, 'tullball'])).code, 2);
|
|
// arsrapport/etatsstyring kommer i steg 11-12 og skal si det, ikke feile stumt.
|
|
for (const form of ['arsrapport', 'etatsstyring']) {
|
|
const r = await kjoerCli([SYKLUS_DIR, form]);
|
|
assert.equal(r.code, 2, `${form} burde gi exit 2`);
|
|
assert.match(r.stderr, /ikke implementert/i, `${form} mangler forklarende melding`);
|
|
}
|
|
});
|
|
|
|
test('(7c) CLI: exit 2 naar syklus-katalogen ikke finnes', async () => {
|
|
assert.equal((await kjoerCli([join(ROOT, 'finnes-ikke'), 'tertial'])).code, 2);
|
|
});
|
|
|
|
test('(7d) CLI: exit 1 ved ufullstendig syklusdata', async (t) => {
|
|
const { rmSync } = await import('node:fs');
|
|
const { rot, dir } = await midlertidigSyklus({
|
|
'okr-ufullstendig.md': ['---', 'type: OKR', 'title: Ufullstendig', 'kr1_navn: Mangler tall', 'kr1_baseline: 3', '---', '# Ufullstendig', ''].join('\n'),
|
|
});
|
|
t.after(() => rmSync(rot, { recursive: true, force: true }));
|
|
const r = await kjoerCli([dir, 'tertial']);
|
|
assert.equal(r.code, 1, 'domenefeil skal gi exit 1, ikke 2');
|
|
assert.match(r.stderr, /kr1/i);
|
|
});
|
|
|
|
test('(7e) CLI: exit 0 og fil paa disk ved gyldig input', async (t) => {
|
|
const { rmSync, existsSync, readFileSync: les } = await import('node:fs');
|
|
const { rot, dir } = await midlertidigSyklus();
|
|
t.after(() => rmSync(rot, { recursive: true, force: true }));
|
|
const r = await kjoerCli([dir, 'tertial']);
|
|
assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}: ${r.stderr ?? ''}`);
|
|
const ut = join(dir, 'rapport-tertial.md');
|
|
assert.ok(existsSync(ut), 'rapporten ble ikke skrevet');
|
|
const innhold = les(ut, 'utf8');
|
|
assert.match(innhold, /^---\ntype: Status\n/, 'skrevet fil mangler OKF-frontmatter');
|
|
assert.match(innhold, /## Committed Key Results/);
|
|
});
|
|
|
|
// Idempotens: rapporten skal kunne kjoeres om igjen uten aa endre bundlen, og
|
|
// uten aa bli lest som en OKR neste gang (filnavnet starter ikke med okr-).
|
|
test('(7f) CLI: to kjoeringer gir byte-identisk fil', async (t) => {
|
|
const { rmSync, readFileSync: les } = await import('node:fs');
|
|
const { rot, dir } = await midlertidigSyklus();
|
|
t.after(() => rmSync(rot, { recursive: true, force: true }));
|
|
await kjoerCli([dir, 'tertial']);
|
|
const foerste = les(join(dir, 'rapport-tertial.md'), 'utf8');
|
|
await kjoerCli([dir, 'tertial']);
|
|
assert.equal(les(join(dir, 'rapport-tertial.md'), 'utf8'), foerste);
|
|
assert.equal(lesSyklus(dir).okrer.length, 2, 'rapporten ble lest som en OKR');
|
|
});
|