// 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 { arsrapportDelIII, etatsstyringsunderlag, 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 }); // --- 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)], ['etatsstyring', (syklus, opts) => etatsstyringsunderlag(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 // 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. 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(); 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'); }); 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/${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/${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; 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. 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|Avvik)/.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); }); // --- (9) Etatsstyringsmoete-underlag (steg 12) --- // // Bygget paa styringsdialog-kapittelet i okr-offentlig-governance.md:96-114. // Hjemmelen for styringsparametere er bestemmelser om oekonomistyring i staten // («bestemmelsene») punkt 1.5, gjengitt i DFOe-notat 2026:2 s. 49 (fn. 228). const underlag = (opts = {}) => etatsstyringsunderlag(lesSyklus(SYKLUS_DIR), { naa: NAA, ...opts }); test('(9a) underlaget skiller committed og aspirational', () => { const md = underlag(); assert.match(md, /^###?\s+Committed Key Results/m, 'mangler committed-seksjon'); assert.match(md, /^###?\s+Aspirational Key Results/m, 'mangler aspirational-seksjon'); }); // F-j fjernet «2-4 etatsstyringsmoeter per aar» fordi paastanden ikke lot seg // verifisere. Den disiplinen gjelder generatoren ogsaa: antall og form fastsettes // i departementets hovedinstruks, og et underlag som paastaar en kadens // foregriper noe det ikke kan vite. test('(9b) underlaget hardkoder ingen moetefrekvens', () => { const md = underlag(); const paastander = md.split('\n').filter( (l) => /(\d+|to|tre|fire)\s*(-\s*\d+\s*)?(etatsstyrings)?m[o]+ter\b/i.test(l) || /m[o]+ter?\s+(per|i)\s+(aar|halvaar)/i.test(l) || /(halvaarlig|kvartalsvis|maanedlig|ukentlig)[^\n]{0,20}m[o]+te/i.test(l), ); assert.deepEqual(paastander, [], `hardkodet moetefrekvens:\n${paastander.join('\n')}`); // Den positive motparten: underlaget sier hvor kadensen faktisk fastsettes. assert.match(md, /hovedinstruks/i, 'underlaget maa peke paa hovedinstruksen for antall og form'); }); test('(9c) styringsparametere kobles mot tildelingsbrevet, og oppfinnes ikke', () => { const md = underlag(); const kropp = seksjonsKropp(md, '## Styringsparametere mot tildelingsbrevets krav'); assert.ok(kropp, 'mangler seksjonen som kobler Objectives til tildelingsbrevets krav'); assert.match(kropp, /tildelingsbrev/i); // Generatoren leser ikke tildelingsbrevet -- den har bare syklusdataene. Da er // koblingen et felt den RESERVERER, aldri et den fyller. Hvert Objective staar // i tabellen; kravet ved siden av er tomt. assert.match(kropp, /Trafikksikkerhet og tunnelsikkerhet/); assert.match(kropp, /Digitalisering av tjenester/); assert.match(kropp, /[Ff]ylles ut/, 'koblingen maa vaere merket som utfylles av virksomheten'); }); test('(9d) etatsstyringsunderlag kaster ved tom eller ufullstendig syklus', () => { assert.throws(() => etatsstyringsunderlag({ id: 'T1-2026', okrer: [] }), /ingen OKR/i); assert.throws(() => etatsstyringsunderlag(undefined), /syklus/i); }); // --- (10) Sandbagging-vakten (steg 13) --- // // Briefens navngitte suksesskriterium: en aspirational-OKR med lav score // presenteres ALDRI som avvik. Regelen staar i okr-offentlig-governance.md:148-156; // her far den mekanisk handhevelse i alle tre rapportformene. // // Vakten er bygget i tre ledd, og rekkefolgen er ikke tilfeldig: // (10a) POSITIV -- et committed KR under kravet STAAR i avviks-seksjonen. // Uten dette leddet ville (10b) bestaatt trivielt paa en // seksjon som ikke finnes eller er tom, og vakten vaert prosa. // (10b) NEGATIV -- fixturens aspirational-KR med lav score staar der ikke. // (10c) MUTASJON -- samme KR, flyttet til committed, DUKKER OPP. Det beviser at // filteret henger paa TYPEN og ikke paa navnet, og at // seksjonslokatoren i (10b) faktisk fant noe aa lete i. // // De tre formene har strukturelt ulike avviks-regioner, saa lokatoren ankrer paa // overskriftsformen (`## Avvik...`), som er felles. function avvikKropp(md) { const overskrift = md.split('\n').find((l) => /^##\s+Avvik/.test(l)); assert.ok(overskrift, 'rapportformen har ingen avviks-seksjon aa filtrere'); const kropp = seksjonsKropp(md, overskrift.trim()); assert.ok(kropp && kropp.length > 0, 'avviks-seksjonen er tom'); return kropp; } // Fixturens KR-er, med de rollene steg 7 ga dem. const COMMITTED_UNDER_KRAV = 'Tunneler med oppgradert sikkerhetsutrustning'; const ASPIRATIONAL_LAV_SCORE = ['Andel digitale soeknader', 'Gjennomsnittlig saksbehandlingstid']; for (const [navn, bygger] of FORMER) { test(`(10a/${navn}) committed KR under kravet STAAR i avviks-seksjonen`, () => { const kropp = avvikKropp(byggFor(bygger)); assert.match( kropp, new RegExp(COMMITTED_UNDER_KRAV), `committed KR under kravet mangler i avviks-seksjonen:\n${kropp}`, ); }); test(`(10b/${navn}) aspirational KR med lav score staar IKKE i avviks-seksjonen`, () => { const kropp = avvikKropp(byggFor(bygger)); for (const kr of ASPIRATIONAL_LAV_SCORE) { assert.doesNotMatch( kropp, new RegExp(kr), `aspirational KR presentert som avvik -- sandbagging-vakten er brutt:\n${kropp}`, ); } }); test(`(10c/${navn}) mutasjon: samme KR som committed ER et avvik`, () => { const syklus = lesSyklus(SYKLUS_DIR); const mutert = { ...syklus, okrer: syklus.okrer.map((okr) => ({ ...okr, krer: okr.krer.map((kr) => ({ ...kr, type: 'committed' })), })), }; const kropp = avvikKropp(bygger(mutert, { naa: NAA })); for (const kr of ASPIRATIONAL_LAV_SCORE) { assert.match( kropp, new RegExp(kr), `filteret henger ikke paa typen -- KR-et uteble ogsaa som committed:\n${kropp}`, ); } }); // Uten den kausale begrunnelsen er vakten en norm mottakeren kan overproeve. // Bogsnes' poeng (okr-framework.md:158-161) er at re-bundling av // aspirational-scoren i rapporteringen henter sandbaggingen tilbake // strukturelt -- det argumentet holder for en Riksrevisjon-leser. test(`(10d/${navn}) rapporten baerer forventningen til aspirational, ikke bare tallet`, () => { const md = byggFor(bygger); assert.match(md, /0\.7/, 'forventningen om ~0.7 maa staa eksplisitt'); assert.match(md, /sandbagging/i, 'begrunnelsen for at lav aspirational-score ikke er avvik mangler'); }); } // Avviks-seksjonen viser til «forventningen til aspirational under». Den // seksjonen finnes bare naar syklusen HAR aspirational-KR. En syklus med bare // committed -- fullt lovlig, og typisk for en ren etterlevelses-syklus -- ville // faatt et styringsdokument som peker paa en seksjon som ikke er der. Fixturen // kan ikke fange det; den har begge typene. const KUN_COMMITTED = { id: 'T-kun-committed', okrer: [{ fil: 'okr-etterlevelse.md', tittel: 'Etterlevelse', krer: [{ n: 1, navn: 'Palagte tilsyn gjennomfoert (antall)', baseline: 0, target: 12, naa: 9, type: 'committed' }], }], }; for (const [navn, bygger] of FORMER) { test(`(10f/${navn}) ingen henvisning til en aspirational-seksjon som ikke finnes`, () => { const md = bygger(KUN_COMMITTED, { naa: NAA }); const viser = /forventningen til aspirational under/i.test(md); const finnes = /^##+\s+Aspirational/m.test(md); assert.equal( viser && !finnes, false, 'avviks-seksjonen viser til en aspirational-seksjon rapporten ikke har', ); }); } // Generatoren handhever regelen; governance-fila baerer begrunnelsen. Staar // begrunnelsen bare i koden, kan en leser overproeve vakten uten aa moete // argumentet -- og det er nettopp en Riksrevisjon-vendt leser som vil sporre. test('(10e) governance-fila begrunner regelen kausalt, ikke bare normativt', () => { const doc = readFileSync( join(ROOT, 'skills/okr-offentlig-sektor/references/okr-offentlig-governance.md'), 'utf8', ); assert.match(doc, /re-bundl/i, 'den kausale mekanismen (re-bundling) mangler'); assert.match(doc, /okr-framework\.md/, 'begrunnelsen maa peke til kanon, ikke duplisere den'); }); // --- (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); }); // Alle tre formene er implementert etter steg 12; den gjenvaerende bruksfeilen er // en form som ikke finnes. Meldingen navngir de gyldige formene, saa exit 2 kan // handteres uten aa lese kildekoden. test('(7b) CLI: exit 2 ved ukjent form, med formene navngitt', async () => { const r = await kjoerCli([SYKLUS_DIR, 'tullball']); assert.equal(r.code, 2); for (const form of ['tertial', 'arsrapport', 'etatsstyring']) { assert.match(r.stderr, new RegExp(form), `feilmeldingen mangler formen ${form}`); } }); 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/); }); // --- (7g/7h) historikk-stien er avledet, og avledningen er en determinisme-risiko --- // // historikk/ er SOESKEN av syklus/, altsaa /../../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'); }); test('(7i) CLI: etatsstyring skrives til sin egen fil', 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, 'etatsstyring']); assert.equal(r.code, 0, `forventet exit 0, fikk ${r.code}: ${r.stderr ?? ''}`); const innhold = les(join(dir, 'rapport-etatsstyring.md'), 'utf8'); assert.match(innhold, /^---\ntype: Status\n/, 'skrevet fil mangler OKF-frontmatter'); assert.match(innhold, /Styringsparametere mot tildelingsbrevets krav/); // Tre former, tre filnavn: en form som overskrev en annens fil ville gjort den // siste kjoeringen til den eneste som fantes. assert.equal(lesSyklus(dir).okrer.length, 2, 'rapporten ble lest som en OKR'); }); // Fixturens historikk er FLAT prosa-filer. Den EKTE formen kommer fra // /okr:oppsett arkiver (commands/oppsett.md:514-518), som lager BEGGE deler: // katalogen `historikk/[id]/` med syklusens filer, OG den flate // `historikk/[id]-resultater.md` med sammendraget. Sammendraget er riktig // granularitet for et flerarig perspektiv; katalogen er raamaterialet under. // Uten denne testen ville fixtur, kode og (7h) vaert tre artefakter som er enige // med hverandre -- og ingen av dem den kommandoen som faktisk skriver treet. test('(7j) historikk leses fra arkiveringens EKTE form: sammendragsfil, ikke katalog', 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-arkiv-')); const dir = join(rot, 'syklus', 'T1-2026'); const hist = join(rot, 'historikk'); mkdirSync(dir, { recursive: true }); mkdirSync(join(hist, 'T3-2025'), { recursive: true }); for (const f of readdirSync(SYKLUS_DIR)) copyFileSync(join(SYKLUS_DIR, f), join(dir, f)); // Kopien av syklusen ligger i katalogen; sammendraget flatt ved siden av. writeFileSync(join(hist, 'T3-2025', 'okr-gammel.md'), ['---', 'type: OKR', 'title: Gammel OKR', '---', ''].join('\n')); writeFileSync(join(hist, 'T3-2025-resultater.md'), ['---', 'type: Retrospektiv', 'title: Resultater T3-2025', '---', '# Resultater T3-2025', ''].join('\n')); t.after(() => rmSync(rot, { recursive: true, force: true })); assert.equal((await kjoerCli([dir, 'arsrapport'])).code, 0); const kropp = les(join(dir, 'rapport-arsrapport.md'), 'utf8'); assert.match(kropp, /Resultater T3-2025/, 'sammendragsfila fra arkiveringen ble ikke funnet'); // Den arkiverte syklus-katalogen er raamateriale, ikke en post i det flerarige // perspektivet -- og `[id]/` ender ikke paa .md, saa den filtreres bort som seg selv. assert.doesNotMatch(kropp, /Gammel OKR/, 'generatoren gikk inn i den arkiverte syklus-katalogen'); }); // 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'); });