feat(okr): aarsrapport del III-generator

This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 06:49:59 +02:00
commit 77031b0c9a
3 changed files with 384 additions and 65 deletions

View file

@ -13,7 +13,7 @@ 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';
import { arsrapportDelIII, 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');
@ -21,6 +21,22 @@ const NAA = '2026-05-01T10:00:00.000Z';
const rapport = () => tertialrapport(lesSyklus(SYKLUS_DIR), { naa: NAA });
// --- Invariantene gjelder HVER rapportform, ikke bare tertialrapporten ---
//
// Steg 9 hengte alle forbudene paa tertialrapporten. Da ville steg 11-12 levert
// to nye former med NULL dekning paa nettopp de vaktene som baerer doktrinen --
// og (5c), som forbyr generatoren aa utlede confidence, ville dekket 1 av 3
// former. Formene registreres her; en ny form arver hele invariant-settet ved aa
// staa i listen. Radform-vaktene (5a/5b) staar bevisst UTENFOR: de handler om
// tabellen coaching-hook.mjs teller, og aarsrapport/etatsstyring er ikke
// forpliktet til den tabellformen.
const FORMER = [
['tertial', (syklus, opts) => tertialrapport(syklus, opts)],
['arsrapport', (syklus, opts) => arsrapportDelIII(syklus, opts)],
];
const byggFor = (bygger, opts = {}) => bygger(lesSyklus(SYKLUS_DIR), { naa: NAA, ...opts });
// --- Hook-kontrakten: predikatene LESES fra hooken, aldri kopieres ---
//
// En kopiert regex beviser at testen er enig med seg selv. Ved aa hente
@ -55,11 +71,12 @@ test('(1) committed og aspirational rapporteres hver for seg', () => {
// 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')}`);
});
for (const [navn, bygger] of FORMER) {
test(`(1b/${navn}) rapportformen emitterer ingen felles samlet score`, () => {
const offenders = byggFor(bygger).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();
@ -77,24 +94,26 @@ test('(2b) invariant 2: committed rapporteres binaert mot kravet', () => {
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');
});
for (const [navn, bygger] of FORMER) {
test(`(2c/${navn}) invariant 3: skalaen forklares, og score konverteres ikke til prosent`, () => {
const md = byggFor(bygger);
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');
});
// B-2-kanten moetes her av DATA (fixturens kr1 har target == baseline).
// Uten en eksplisitt literal ville cellen blitt "undefined" eller kastet.
test(`(3/${navn}) udefinert score rendres som en literal, ikke som undefined eller NaN`, () => {
const md = byggFor(bygger);
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(`(4/${navn}) determinisme: to kjoeringer med samme naa gir byte-identisk streng`, () => {
assert.equal(byggFor(bygger), byggFor(bygger));
});
}
test('(4b) klokke-soemmen: OKR_NOW styrer tidsstempelet', (t) => {
const forrige = process.env.OKR_NOW;
@ -132,21 +151,108 @@ test('(5b) hook-kontrakt: en kanonisk etikett i Confidence-cellen goer raden tel
// 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')}`,
);
});
for (const [navn, bygger] of FORMER) {
test(`(5c/${navn}) generatoren emitterer aldri en confidence-etikett selv`, () => {
const { fareOrd } = hookPredikater();
const offenders = byggFor(bygger).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);
});
// --- (8) Aarsrapport del III (steg 11) ---
//
// Ankeret er okr-offentlig-governance.md:125-133: del III «Aarets aktiviteter og
// resultater» er hovedplassen for OKR i den statlige aarsrapporten.
//
// Antipatternet generatoren maa unngaa er kildebelagt i primaerkilden:
// Riksrevisjonen (2020), «Undersoekelse av etats- og virksomhetsstyringen av NIBIO»,
// del av Dokument 1 (2020-2021) s. 107 -- framstillingen «synliggjoer i mindre grad
// NIBIOs analyser av maaloppnaaelsen og framstaar som et oeyeblikksbilde av NIBIOs
// aktiviteter og resultater». En generator som bare dumper tabeller PRODUSERER det
// oeyeblikksbildet. Derfor RESERVERER den plassen til vurderingen -- og fyller den
// aldri selv, siden en vurdering av maaloppnaaelse krever skjoenn.
const delIII = (opts = {}) => arsrapportDelIII(lesSyklus(SYKLUS_DIR), { naa: NAA, ...opts });
// Seksjonskropp: linjene mellom en overskrift og neste overskrift paa samme
// eller hoeyere nivaa.
function seksjonsKropp(md, overskrift) {
const linjer = md.split('\n');
const start = linjer.findIndex((l) => l.trim() === overskrift);
if (start === -1) return null;
const niva = overskrift.match(/^#+/)[0].length;
const kropp = [];
for (const linje of linjer.slice(start + 1)) {
const m = /^(#+)\s/.exec(linje);
if (m && m[1].length <= niva) break;
kropp.push(linje);
}
return kropp.join('\n').trim();
}
test('(8a) del III bygger paa aarsrapportens eget anker og en seksjon per Objective', () => {
const md = delIII();
assert.match(md, /[Aa]arets aktiviteter og resultater/, 'del III-ankeret mangler');
const objectives = md.split('\n').filter((l) => /^##\s+(?!Flerarig|Aspirational)/.test(l));
assert.equal(objectives.length, 2, `forventet 2 Objective-seksjoner, fant ${objectives.length}`);
assert.match(md, /Trafikksikkerhet og tunnelsikkerhet/);
assert.match(md, /Digitalisering av tjenester/);
});
test('(8b) hvert Objective reserverer et vurderingsfelt generatoren ikke fyller', () => {
const md = delIII();
const felt = md.split('\n').filter((l) => /^###\s+Vurdering av maaloppnaaelse\s*$/.test(l));
assert.equal(felt.length, 2, `forventet ett vurderingsfelt per Objective, fant ${felt.length}`);
const kropp = seksjonsKropp(md, '### Vurdering av maaloppnaaelse');
assert.ok(kropp, 'fant ingen kropp under vurderingsfeltet');
// Plassholderen er merket som plassholder, ikke skrevet som en vurdering.
assert.match(kropp, /^\[/, 'vurderingsfeltet maa staa som en merket plassholder');
assert.match(kropp, /\]$/, 'plassholderen maa vaere avgrenset');
assert.match(kropp, /[Ff]ylles ut av virksomheten/, 'plassholderen maa si hvem som fyller den');
// En generator som «hjelpsomt» skrev inn tall eller en konklusjon ville
// gjort seg til vurderingsinstans. Ingen tall i feltet = ingen vurdering.
assert.doesNotMatch(kropp, /\d/, `generatoren fylte vurderingsfeltet med data:\n${kropp}`);
});
test('(8c) flerarig perspektiv kommer fra historikk, og oppfinnes ikke uten data', () => {
const utenHistorikk = delIII();
assert.doesNotMatch(
utenHistorikk,
/^##\s+Flerarig perspektiv/m,
'uten historikk skal seksjonen utelates, ikke staa tom',
);
const md = delIII({
historikk: [
{ tittel: 'Retrospektiv T3-2025', fil: 'retrospektiv-T3-2025.md' },
{ tittel: 'Retrospektiv T2-2025', fil: 'retrospektiv-T2-2025.md' },
],
});
assert.match(md, /^##\s+Flerarig perspektiv/m, 'historikk skal gi et flerarig perspektiv');
const kropp = seksjonsKropp(md, '## Flerarig perspektiv');
assert.match(kropp, /Retrospektiv T3-2025/);
assert.match(kropp, /Retrospektiv T2-2025/);
// DFOe-notat 2026:2 kap. 5.3.3 (s. 61): det ettaarige omtales i liten grad i
// flerarig perspektiv. Generatoren peker paa materialet -- den sammenligner ikke
// sykluser den ikke har tall fra.
assert.doesNotMatch(kropp, /\d\.\d\d/, 'generatoren skal ikke regne trend paa tvers av sykluser');
});
test('(8d) arsrapportDelIII kaster ved tom eller ufullstendig syklus', () => {
assert.throws(() => arsrapportDelIII({ id: 'T1-2026', okrer: [] }), /ingen OKR/i);
assert.throws(() => arsrapportDelIII(undefined), /syklus/i);
});
// --- (2d) Avvik-kolonnen maa vaere retningsagnostisk ---
//
// `naa >= target` ser riktig ut for oppadgaaende maal og er FEIL for
@ -244,8 +350,8 @@ test('(7a) CLI: exit 2 ved feil aritet', async () => {
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']) {
// etatsstyring kommer i steg 12 og skal si det, ikke feile stumt.
for (const form of ['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`);
@ -280,6 +386,49 @@ test('(7e) CLI: exit 0 og fil paa disk ved gyldig input', async (t) => {
assert.match(innhold, /## Committed Key Results/);
});
// --- (7g/7h) historikk-stien er avledet, og avledningen er en determinisme-risiko ---
//
// historikk/ er SOESKEN av syklus/, altsaa <syklusDir>/../../historikk. Regnet ut
// blindt ville en syklus-katalog utenfor et OKR-tre (som testenes tmp-kopi) pekt paa
// en vilkaarlig katalog paa maskinen -- og rapporten ville variert med hva som
// tilfeldigvis laa der. Derfor avledes historikk KUN naar foreldrekatalogen faktisk
// heter `syklus`. Begge grenene testes: uten tre (7g) og med tre (7h).
test('(7g) CLI: arsrapport skrives, og uten OKR-tre utledes ingen historikk', async (t) => {
const { rmSync, readFileSync: les } = await import('node:fs');
const { rot, dir } = await midlertidigSyklus();
t.after(() => rmSync(rot, { recursive: true, force: true }));
const r = await kjoerCli([dir, 'arsrapport']);
assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}: ${r.stderr ?? ''}`);
const innhold = les(join(dir, 'rapport-arsrapport.md'), 'utf8');
assert.match(innhold, /^---\ntype: Status\n/, 'skrevet fil mangler OKF-frontmatter');
assert.match(innhold, /[Aa]arets aktiviteter og resultater/);
assert.doesNotMatch(innhold, /^##\s+Flerarig perspektiv/m, 'historikk ble utledet utenfor et OKR-tre');
});
test('(7h) CLI: i et ekte OKR-tre leses historikk-katalogen, ikke index.md', async (t) => {
const { rmSync, mkdirSync, mkdtempSync, writeFileSync, copyFileSync, readdirSync, readFileSync: les } = await import('node:fs');
const { tmpdir } = await import('node:os');
const rot = mkdtempSync(join(tmpdir(), 'okr-tre-'));
const dir = join(rot, 'syklus', 'T1-2026');
const hist = join(rot, 'historikk');
mkdirSync(dir, { recursive: true });
mkdirSync(hist);
for (const f of readdirSync(SYKLUS_DIR)) copyFileSync(join(SYKLUS_DIR, f), join(dir, f));
writeFileSync(join(hist, 'retrospektiv-T3-2025.md'), ['---', 'type: Retrospektiv', 'title: Retrospektiv T3-2025', '---', '# Retrospektiv T3-2025', ''].join('\n'));
// index.md peker paa en fil som ikke finnes (samme dangling-kant som fixturen).
// Leser generatoren index.md i stedet for katalogen, dukker den opp i rapporten.
writeFileSync(join(hist, 'index.md'), ['# Historikk', '', '* [Retrospektiv T2-2025](retrospektiv-T2-2025.md) - finnes ikke', ''].join('\n'));
t.after(() => rmSync(rot, { recursive: true, force: true }));
assert.equal((await kjoerCli([dir, 'arsrapport'])).code, 0);
const innhold = les(join(dir, 'rapport-arsrapport.md'), 'utf8');
assert.match(innhold, /^##\s+Flerarig perspektiv/m, 'historikk-katalogen ble ikke lest');
assert.match(innhold, /Retrospektiv T3-2025/);
assert.doesNotMatch(innhold, /T2-2025/, 'en dangling index.md-lenke ble rapportert som materiale');
assert.doesNotMatch(innhold, /index\.md/, 'index.md er navigasjon, ikke kildemateriale');
});
// 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) => {