feat(okr): aarsrapport del III-generator
This commit is contained in:
parent
8ef8f7a405
commit
77031b0c9a
3 changed files with 384 additions and 65 deletions
|
|
@ -56,6 +56,11 @@ function krRad(kr, erCommitted) {
|
|||
return `| ${kr.navn} | ${kr.baseline} | ${kr.target} | ${kr.naa} | ${score} | ${formatAvvik(kr, erCommitted)} | |`;
|
||||
}
|
||||
|
||||
const krTabell = (krer, erCommitted) => [
|
||||
...TABELLHODE,
|
||||
...krer.map((kr) => krRad(kr, erCommitted)),
|
||||
];
|
||||
|
||||
// Ett avsnitt per OKR, med KR-ene som tabellrader. Rekkefolgen arves fra
|
||||
// lesSyklus (sortert filnavn) og er del av determinisme-kontrakten.
|
||||
function seksjon(okrer, type, erCommitted) {
|
||||
|
|
@ -65,9 +70,7 @@ function seksjon(okrer, type, erCommitted) {
|
|||
const krer = okr.krer.filter((kr) => kr.type === type);
|
||||
if (krer.length === 0) continue;
|
||||
antallKr += krer.length;
|
||||
linjer.push(`### ${okr.tittel}`, '', ...TABELLHODE);
|
||||
for (const kr of krer) linjer.push(krRad(kr, erCommitted));
|
||||
linjer.push('');
|
||||
linjer.push(`### ${okr.tittel}`, '', ...krTabell(krer, erCommitted), '');
|
||||
}
|
||||
return { linjer, antallKr };
|
||||
}
|
||||
|
|
@ -94,6 +97,36 @@ function aspirationalSnitt(okrer) {
|
|||
return `**Snitt aspirational: ${snitt.toFixed(2)}** over ${scorer.length} KR${hale}.`;
|
||||
}
|
||||
|
||||
// Felles inngangsvakt for alle rapportformene. En form som stille rapporterte
|
||||
// en tom syklus ville produsert et styringsdokument uten innhold, som ser
|
||||
// komplett ut.
|
||||
function krevSyklus(syklus, hvor) {
|
||||
if (!syklus || typeof syklus !== 'object' || !Array.isArray(syklus.okrer)) {
|
||||
throw new Error(`${hvor}: forventet en syklus fra lesSyklus()`);
|
||||
}
|
||||
if (syklus.okrer.length === 0) {
|
||||
throw new Error(`${hvor}: syklusen ${syklus.id ?? ''} inneholder ingen OKR`.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Skala- og confidence-avsnittet er felles for alle formene, ikke fordi det
|
||||
// sparer linjer, men fordi de tre governance-invariantene ikke kan gjelde bare
|
||||
// den ene rapporten mottakeren tilfeldigvis leser.
|
||||
const SKALAFORKLARING = [
|
||||
// Governance-invariant 3: skalaen forklares, og score presenteres ALDRI som
|
||||
// prosent maaloppnaaelse -- en leser som tror 0.7 betyr "70 % av maalet"
|
||||
// trekker feil konklusjon om baade ambisjonsniva og resultat.
|
||||
'Score er en andel paa skalaen 0 til 1.0, beregnet som',
|
||||
'(naa - baseline) / (target - baseline). Den er ikke prosent maaloppnaaelse.',
|
||||
`Et KR der target er lik baseline har ingen definert andel og staar som ${UDEFINERT}.`,
|
||||
'',
|
||||
'Confidence fylles ut av /okr:sporing. Den utledes ikke av tallene her --',
|
||||
'confidence er en sannsynlighetsvurdering, ikke en funksjon av score.',
|
||||
'',
|
||||
];
|
||||
|
||||
const klokke = (opts) => opts.naa || process.env.OKR_NOW || new Date().toISOString();
|
||||
|
||||
/**
|
||||
* Bygger tertialrapporten for en syklus lest av lesSyklus().
|
||||
*
|
||||
|
|
@ -103,14 +136,9 @@ function aspirationalSnitt(okrer) {
|
|||
* @returns {string} markdown
|
||||
*/
|
||||
export function tertialrapport(syklus, opts = {}) {
|
||||
if (!syklus || typeof syklus !== 'object' || !Array.isArray(syklus.okrer)) {
|
||||
throw new Error('tertialrapport: forventet en syklus fra lesSyklus()');
|
||||
}
|
||||
if (syklus.okrer.length === 0) {
|
||||
throw new Error(`tertialrapport: syklusen ${syklus.id ?? ''} inneholder ingen OKR`.trim());
|
||||
}
|
||||
krevSyklus(syklus, 'tertialrapport');
|
||||
|
||||
const naa = opts.naa || process.env.OKR_NOW || new Date().toISOString();
|
||||
const naa = klokke(opts);
|
||||
|
||||
const committed = seksjon(syklus.okrer, 'committed', true);
|
||||
const aspirational = seksjon(syklus.okrer, 'aspirational', false);
|
||||
|
|
@ -124,16 +152,7 @@ export function tertialrapport(syklus, opts = {}) {
|
|||
'',
|
||||
`Generert: ${naa}`,
|
||||
'',
|
||||
// Governance-invariant 3: skalaen forklares, og score presenteres ALDRI som
|
||||
// prosent maaloppnaaelse -- en leser som tror 0.7 betyr "70 % av maalet"
|
||||
// trekker feil konklusjon om baade ambisjonsniva og resultat.
|
||||
'Score er en andel paa skalaen 0 til 1.0, beregnet som',
|
||||
'(naa - baseline) / (target - baseline). Den er ikke prosent maaloppnaaelse.',
|
||||
`Et KR der target er lik baseline har ingen definert andel og staar som ${UDEFINERT}.`,
|
||||
'',
|
||||
'Confidence fylles ut av /okr:sporing. Den utledes ikke av tallene her --',
|
||||
'confidence er en sannsynlighetsvurdering, ikke en funksjon av score.',
|
||||
'',
|
||||
...SKALAFORKLARING,
|
||||
];
|
||||
|
||||
if (committed.antallKr > 0) {
|
||||
|
|
@ -164,3 +183,116 @@ export function tertialrapport(syklus, opts = {}) {
|
|||
|
||||
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
|
||||
}
|
||||
|
||||
// --- Aarsrapport del III (steg 11) ---
|
||||
//
|
||||
// Del III «Aarets aktiviteter og resultater» er hovedplassen for OKR i den
|
||||
// statlige aarsrapporten (okr-offentlig-governance.md:125-133).
|
||||
//
|
||||
// ANTIPATTERNET generatoren maa unngaa, ordrett fra primaerkilden --
|
||||
// Riksrevisjonen (2020), «Undersoekelse av etats- og virksomhetsstyringen av
|
||||
// Norsk institutt for biooekonomi (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». Saken ble
|
||||
// senere avsluttet etter forbedringer i maal- og resultatstyringen (gjengitt i
|
||||
// DFOe-notat 2026:2 s. 48) -- funnet er en historisk dokumentert svakhet
|
||||
// forvaltningen har rettet, ikke gjeldende kritikk av NIBIO.
|
||||
//
|
||||
// Konsekvensen for koden er konkret: en generator som bare dumper KR-tabeller
|
||||
// PRODUSERER nettopp det oeyeblikksbildet. Den reserverer derfor plass til
|
||||
// vurderingen av maaloppnaaelse per Objective -- og fyller den aldri selv, av
|
||||
// samme grunn som den ikke setter confidence: vurderingen krever skjoenn.
|
||||
const VURDERINGSFELT = [
|
||||
'[Fylles ut av virksomheten: analysen av maaloppnaaelsen for dette Objectivet --',
|
||||
'hva tallene over betyr, hva som forklarer avvikene, og hva som er laert.',
|
||||
'Generatoren fyller ikke feltet; en vurdering av maaloppnaaelse krever skjoenn.',
|
||||
'Uten analysen staar del III igjen som et oeyeblikksbilde av aktiviteter og',
|
||||
'resultater, som er nettopp det revisjonen har paapekt. Slett klammene naar',
|
||||
'feltet er fylt ut.]',
|
||||
];
|
||||
|
||||
// DFOe-notat 2026:2 kap. 5.3.3 (s. 61): virksomhetene «skal planlegge med baade
|
||||
// ettaarig og flerarig perspektiv», men «i tildelingsbrevene og aarsrapportene vi
|
||||
// har sett paa omtales imidlertid i liten grad det ettaarige i et flerarig
|
||||
// perspektiv». Generatoren PEKER derfor paa materialet fra tidligere sykluser --
|
||||
// den regner ikke trend paa tvers av dem. Historikk-filene baerer retrospektiv-
|
||||
// prosa, ikke KR-tall; en beregnet utvikling ville vaert oppdiktet.
|
||||
function flerarigSeksjon(historikk) {
|
||||
if (historikk.length === 0) return [];
|
||||
return [
|
||||
'## Flerarig perspektiv',
|
||||
'',
|
||||
'Virksomheten skal planlegge med baade ettaarig og flerarig perspektiv.',
|
||||
'Materialet fra tidligere sykluser ligger i historikk-katalogen:',
|
||||
'',
|
||||
...historikk.map((h) => `- ${h.tittel} (${h.fil})`),
|
||||
'',
|
||||
'[Fylles ut av virksomheten: utviklingen sett over tid. Generatoren viser',
|
||||
'hvilket materiale som finnes, og sammenligner ikke sykluser den ikke har',
|
||||
'tall fra.]',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bygger aarsrapportens del III for en syklus lest av lesSyklus().
|
||||
*
|
||||
* @param {object} syklus struktur fra lesSyklus()
|
||||
* @param {{naa?: string, historikk?: Array<{tittel: string, fil: string}>}} [opts]
|
||||
* historikk leses av kalleren (scripts/syklus-rapport.mjs), ikke her -- modulen
|
||||
* holdes fri for disk, saa formen er testbar uten et tre paa filsystemet.
|
||||
* @returns {string} markdown
|
||||
*/
|
||||
export function arsrapportDelIII(syklus, opts = {}) {
|
||||
krevSyklus(syklus, 'arsrapportDelIII');
|
||||
|
||||
const naa = klokke(opts);
|
||||
const historikk = Array.isArray(opts.historikk) ? opts.historikk : [];
|
||||
const antallKr = syklus.okrer.reduce((n, okr) => n + okr.krer.length, 0);
|
||||
if (antallKr === 0) {
|
||||
throw new Error(`arsrapportDelIII: syklusen ${syklus.id} inneholder ingen KR aa rapportere`);
|
||||
}
|
||||
|
||||
const ut = [
|
||||
`# Aarsrapport del III - Aarets aktiviteter og resultater (${syklus.id})`,
|
||||
'',
|
||||
`Generert: ${naa}`,
|
||||
'',
|
||||
...SKALAFORKLARING,
|
||||
];
|
||||
|
||||
// Per Objective, fordi del III dokumenterer maaloppnaaelse mot tildelingsbrevets
|
||||
// krav -- og kravene henger paa Objectives, ikke paa rapportformen.
|
||||
for (const okr of syklus.okrer) {
|
||||
ut.push(`## ${okr.tittel}`, '');
|
||||
for (const [type, erCommitted, overskrift] of [
|
||||
['committed', true, '### Committed Key Results'],
|
||||
['aspirational', false, '### Aspirational Key Results'],
|
||||
]) {
|
||||
const krer = okr.krer.filter((kr) => kr.type === type);
|
||||
if (krer.length === 0) continue;
|
||||
ut.push(overskrift, '', ...krTabell(krer, erCommitted), '');
|
||||
}
|
||||
ut.push('### Vurdering av maaloppnaaelse', '', ...VURDERINGSFELT, '');
|
||||
}
|
||||
|
||||
// Snittet hoerer PAA TVERS av Objectives (okr-framework.md:557) og staar derfor
|
||||
// ikke i noen enkelt Objective-seksjon. Committed har bevisst ingen motpart:
|
||||
// et snitt av binaere krav er ikke en stoerrelse.
|
||||
const snitt = aspirationalSnitt(syklus.okrer);
|
||||
if (snitt) {
|
||||
ut.push(
|
||||
'## Aspirational maaloppnaaelse paa tvers av Objectives',
|
||||
'',
|
||||
'Aspirational KR forventes aa lande rundt 0.7 med hoey varians, og vurderes',
|
||||
'paa snittet -- ikke paa ett enkelt KR.',
|
||||
'',
|
||||
snitt,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
ut.push(...flerarigSeksjon(historikk));
|
||||
|
||||
return `${ut.join('\n').replace(/\n+$/, '')}\n`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@
|
|||
//
|
||||
// Former: tertial | arsrapport | etatsstyring
|
||||
// EN kommandoflate med argument, ikke tre kommandoer -- samme moenster som
|
||||
// /okr:oppsett full|mvp|arkiver|oppdater|vis. Bare `tertial` er implementert
|
||||
// i D5; arsrapport og etatsstyring kommer i steg 11-12 og svarer inntil da
|
||||
// med exit 2 og en forklarende melding, aldri stum feil.
|
||||
// /okr:oppsett full|mvp|arkiver|oppdater|vis. `etatsstyring` kommer i steg 12
|
||||
// og svarer inntil da med exit 2 og en forklarende melding, aldri stum feil.
|
||||
//
|
||||
// Exit-koder (kontrakten commands/rapport.md mapper til norsk brukertekst):
|
||||
// 0 rapporten er skrevet
|
||||
|
|
@ -22,13 +21,43 @@
|
|||
//
|
||||
// Zero npm dependencies.
|
||||
|
||||
import { existsSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { existsSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { writeFrontmatter } from '../lib/frontmatter.mjs';
|
||||
import { parseFrontmatter, writeFrontmatter } from '../lib/frontmatter.mjs';
|
||||
import { lesSyklus } from '../lib/syklus-data.mjs';
|
||||
import { tertialrapport } from '../lib/syklus-rapport.mjs';
|
||||
import { arsrapportDelIII, tertialrapport } from '../lib/syklus-rapport.mjs';
|
||||
|
||||
// historikk/ er SOESKEN av syklus/, altsaa <syklusDir>/../../historikk. Den
|
||||
// avledningen er en determinisme-risiko: regnet ut blindt ville en syklus-katalog
|
||||
// utenfor et OKR-tre pekt paa en vilkaarlig katalog paa maskinen, og rapporten
|
||||
// variert med hva som tilfeldigvis laa der. Derfor avledes stien KUN naar
|
||||
// foreldrekatalogen faktisk heter `syklus`; ellers finnes det ingen historikk.
|
||||
//
|
||||
// Katalogen leses direkte -- ALDRI via index.md. Indeksen er navigasjon og kan
|
||||
// peke paa filer som ikke finnes (fixturen har en slik dangling-lenke med vilje);
|
||||
// et styringsdokument som lister materiale som ikke eksisterer er verre enn et
|
||||
// som utelater seksjonen. readdir sorteres, fordi byte-identiske kjoeringer er
|
||||
// kontrakt og lesrekkefolgen fra filsystemet ikke er garantert.
|
||||
function lesHistorikk(syklusDir) {
|
||||
const syklusRot = dirname(resolve(syklusDir));
|
||||
if (basename(syklusRot) !== 'syklus') return [];
|
||||
const dir = join(dirname(syklusRot), 'historikk');
|
||||
let filer;
|
||||
try {
|
||||
filer = readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return filer
|
||||
.filter((n) => n.endsWith('.md') && n !== 'index.md')
|
||||
.sort()
|
||||
.map((fil) => {
|
||||
const fm = parseFrontmatter(readFileSync(join(dir, fil), 'utf8'));
|
||||
return { tittel: fm.get('title') ?? fil.replace(/\.md$/, ''), fil };
|
||||
});
|
||||
}
|
||||
|
||||
const FORMER = {
|
||||
tertial: {
|
||||
|
|
@ -37,8 +66,16 @@ const FORMER = {
|
|||
beskrivelse: 'Maskingenerert tertialrapport med committed og aspirational holdt fra hverandre.',
|
||||
bygg: tertialrapport,
|
||||
},
|
||||
// Steg 11-12. Oppfoert her, ikke skjult, saa flaten er synlig foer den virker.
|
||||
arsrapport: null,
|
||||
arsrapport: {
|
||||
filnavn: 'rapport-arsrapport.md',
|
||||
tittel: (id) => `Aarsrapport del III ${id}`,
|
||||
beskrivelse: 'Maskingenerert underlag til aarsrapportens del III. Vurderingen av maaloppnaaelse er reservert til virksomheten.',
|
||||
bygg: arsrapportDelIII,
|
||||
// Disklesing hoerer til orkestratoren, ikke til lib/: generatorformen skal
|
||||
// kunne testes uten et tre paa filsystemet.
|
||||
kontekst: (syklusDir) => ({ historikk: lesHistorikk(syklusDir) }),
|
||||
},
|
||||
// Steg 12. Oppfoert her, ikke skjult, saa flaten er synlig foer den virker.
|
||||
etatsstyring: null,
|
||||
};
|
||||
|
||||
|
|
@ -50,7 +87,7 @@ class Bruksfeil extends Error {}
|
|||
// okr-second-brain-search paa kjoepet.
|
||||
// Filnavnet starter bevisst IKKE med `okr-`: ellers ville neste lesSyklus()
|
||||
// plukket rapporten opp som en OKR-definisjon.
|
||||
function komponer(form, syklus, naa) {
|
||||
function komponer(form, syklus, naa, kontekst) {
|
||||
const frontmatter = writeFrontmatter({
|
||||
type: 'Status',
|
||||
resource: 'local',
|
||||
|
|
@ -58,7 +95,7 @@ function komponer(form, syklus, naa) {
|
|||
description: form.beskrivelse,
|
||||
timestamp: naa,
|
||||
});
|
||||
return `${frontmatter}\n${form.bygg(syklus, { naa })}`;
|
||||
return `${frontmatter}\n${form.bygg(syklus, { naa, ...kontekst })}`;
|
||||
}
|
||||
|
||||
// Atomisk skriv: temp i SAMME katalog (rename er kun atomisk innen filsystem),
|
||||
|
|
@ -94,8 +131,9 @@ export function genererRapport(syklusDir, formNavn, opts = {}) {
|
|||
|
||||
const naa = opts.naa || process.env.OKR_NOW || new Date().toISOString();
|
||||
const syklus = lesSyklus(syklusDir);
|
||||
const kontekst = form.kontekst ? form.kontekst(syklusDir) : {};
|
||||
const maal = join(syklusDir, form.filnavn);
|
||||
skrivAtomisk(maal, komponer(form, syklus, naa));
|
||||
skrivAtomisk(maal, komponer(form, syklus, naa, kontekst));
|
||||
return { fil: maal, syklus: syklus.id, okrer: syklus.okrer.length };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue