feat(okr): aarsrapport del III-generator
This commit is contained in:
parent
8ef8f7a405
commit
77031b0c9a
3 changed files with 384 additions and 65 deletions
|
|
@ -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