168 lines
7 KiB
JavaScript
168 lines
7 KiB
JavaScript
// syklus-data.test.mjs
|
|
// D5 (fase D, steg 7-8): datamodellen for styringssloeyfa. Testene deles i to lag
|
|
// som landet i hver sin commit:
|
|
// (A) FIXTURE-KONTRAKTEN (steg 7) -- asserterer paa fixture-INNHOLD, ikke paa
|
|
// lib-kode. Disse er roede foer fixturen utvides og groenne etterpaa, slik at
|
|
// steg 8 har data aa bygge mot (RED-baseline-disiplin, jf. innboks-ingest).
|
|
// (B) LESER + SCOREBEREGNING (steg 8) -- lib/syklus-data.mjs.
|
|
//
|
|
// Beslutning B-1: flate `krN_`-noekler i okr-*.md sin frontmatter. Verifisert kjoert,
|
|
// ikke bare lest: `^\s*type:` i lib/frontmatter.mjs:29 matcher ikke `kr1_type:`, saa
|
|
// OKF-noekkelen `type` og KR-noekkelen `krN_type` kolliderer ikke.
|
|
// Beslutning B-2: score BEREGNES, lagres aldri. target == baseline gir undefined.
|
|
//
|
|
// 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';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const SYKLUS = 'tests/fixtures/okf-realistic/syklus/T1-2026';
|
|
const OKR_FILER = ['okr-trafikksikkerhet.md', 'okr-digitalisering.md'];
|
|
|
|
const lesFixture = (navn) => readFileSync(join(ROOT, SYKLUS, navn), 'utf8');
|
|
|
|
// --- (A) Fixture-kontrakten (steg 7) ---
|
|
|
|
test('(A1) steg 7: begge OKR-fixturene baerer kr1_baseline', () => {
|
|
const missing = OKR_FILER.filter((f) => !/^\s*kr1_baseline\s*:/m.test(lesFixture(f)));
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
`fixture-OKR uten kr1_baseline: ${missing.join(', ')}`,
|
|
);
|
|
});
|
|
|
|
test('(A2) steg 7: begge KR-typene forekommer i fixturen', () => {
|
|
const alle = OKR_FILER.map(lesFixture).join('\n');
|
|
assert.match(alle, /^\s*kr\d+_type\s*:\s*committed\s*$/m, 'ingen committed KR i fixturen');
|
|
assert.match(
|
|
alle,
|
|
/^\s*kr\d+_type\s*:\s*aspirational\s*$/m,
|
|
'ingen aspirational KR i fixturen',
|
|
);
|
|
});
|
|
|
|
// B-2-kanten skal vaere dekket av DATA, ikke bare av enhetstest -- ellers moeter
|
|
// generatoren i steg 9-10 den foerste gang i produksjon.
|
|
test('(A3) steg 7: fixturen inneholder ett KR-par der target == baseline', () => {
|
|
const funn = [];
|
|
for (const f of OKR_FILER) {
|
|
const doc = lesFixture(f);
|
|
for (const m of doc.matchAll(/^\s*kr(\d+)_baseline\s*:\s*(\S+)\s*$/gm)) {
|
|
const t = new RegExp(`^\\s*kr${m[1]}_target\\s*:\\s*(\\S+)\\s*$`, 'm').exec(doc);
|
|
if (t && t[1] === m[2]) funn.push(`${f}:kr${m[1]}`);
|
|
}
|
|
}
|
|
assert.ok(
|
|
funn.length >= 1,
|
|
'ingen KR med target == baseline -- udefinert-score-kanten (B-2) er udekket av data',
|
|
);
|
|
});
|
|
|
|
// OKF-laget skal vaere uroert av KR-utvidelsen: `type:` er fortsatt OKR-typen, ikke
|
|
// en KR-type. Dette er antakelse 2 i planen, her bundet til faktisk fixture-innhold.
|
|
test('(A4) steg 7: krN_type forstyrrer ikke OKF-noekkelen type', async () => {
|
|
const { parseFrontmatter } = await import('../lib/frontmatter.mjs');
|
|
for (const f of OKR_FILER) {
|
|
const fm = parseFrontmatter(lesFixture(f));
|
|
assert.equal(fm.get('type'), 'OKR', `${f}: OKF-type forskjovet av krN_type`);
|
|
}
|
|
});
|
|
|
|
// --- (B) lib/syklus-data.mjs: leser + scoreberegning (steg 8) ---
|
|
|
|
const { lesSyklus, beregnScore } = await import('../lib/syklus-data.mjs');
|
|
|
|
const SYKLUS_DIR = join(ROOT, SYKLUS);
|
|
|
|
test('(B1) lesSyklus leser begge OKR-filene med KR-ene sine', () => {
|
|
const syklus = lesSyklus(SYKLUS_DIR);
|
|
assert.equal(syklus.id, 'T1-2026');
|
|
assert.equal(syklus.okrer.length, 2, 'forventet begge okr-*.md');
|
|
// Deterministisk rekkefolge (sortert filnavn) -- rapportgeneratoren i steg 9
|
|
// arver denne rekkefolgen, saa den er del av kontrakten, ikke en tilfeldighet.
|
|
assert.deepEqual(
|
|
syklus.okrer.map((o) => o.fil),
|
|
['okr-digitalisering.md', 'okr-trafikksikkerhet.md'],
|
|
);
|
|
const digi = syklus.okrer[0];
|
|
assert.equal(digi.tittel, 'Digitalisering av tjenester');
|
|
assert.equal(digi.krer.length, 2);
|
|
assert.deepEqual(digi.krer[0], {
|
|
n: 1,
|
|
navn: 'Andel digitale soeknader (prosent)',
|
|
baseline: 60,
|
|
target: 85,
|
|
naa: 63,
|
|
type: 'aspirational',
|
|
});
|
|
});
|
|
|
|
test('(B2) lesSyklus tolker tallene som tall, ikke strenger', () => {
|
|
const syklus = lesSyklus(SYKLUS_DIR);
|
|
for (const okr of syklus.okrer) {
|
|
for (const kr of okr.krer) {
|
|
for (const felt of ['baseline', 'target', 'naa']) {
|
|
assert.equal(typeof kr[felt], 'number', `${okr.fil} kr${kr.n}.${felt} er ikke tall`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('(B3) beregnScore: kanonisk formel paa et kjent KR-par', () => {
|
|
// Tunneler oppgradert: (17 - 12) / (20 - 12) = 0.625
|
|
assert.equal(beregnScore({ baseline: 12, target: 20, naa: 17 }), 0.625);
|
|
});
|
|
|
|
test('(B4) beregnScore: kapper til [0, 1.0]', () => {
|
|
assert.equal(beregnScore({ baseline: 0, target: 10, naa: 25 }), 1.0, 'over target -> 1.0');
|
|
assert.equal(beregnScore({ baseline: 10, target: 20, naa: 4 }), 0, 'under baseline -> 0');
|
|
});
|
|
|
|
// B-2, den kanoniske kanten (okr-framework.md:319). En score paa 0 ville lest som
|
|
// "ingen fremgang" naar sannheten er "forholdet er ikke definert" -- forskjellen
|
|
// mellom et KR som feilet og et KR som ikke kan scores som ratio.
|
|
test('(B5) beregnScore: target == baseline gir undefined, ikke 0', () => {
|
|
const score = beregnScore({ baseline: 0, target: 0, naa: 0 });
|
|
assert.equal(score, undefined);
|
|
assert.notEqual(score, 0, 'udefinert score maa ikke kollapse til 0');
|
|
});
|
|
|
|
test('(B6) beregnScore: nedadgaaende maal gir positiv score uten saertilfelle', () => {
|
|
// Saksbehandlingstid 14 -> 5 dager, naa 11: (11 - 14) / (5 - 14) = 0.333...
|
|
const score = beregnScore({ baseline: 14, target: 5, naa: 11 });
|
|
assert.ok(score > 0.33 && score < 0.34, `forventet ~0.333, fikk ${score}`);
|
|
});
|
|
|
|
test('(B7) beregnScore: binaert KR gir 0 eller 1', () => {
|
|
assert.equal(beregnScore({ baseline: 0, target: 1, naa: 0 }), 0);
|
|
assert.equal(beregnScore({ baseline: 0, target: 1, naa: 1 }), 1);
|
|
});
|
|
|
|
// Kast heller enn stille default (moenster: lib/innboks-frontmatter.mjs:64-72).
|
|
// En manglende target som stille ble 0 ville produsert en score som SER gyldig ut.
|
|
test('(B8) beregnScore kaster ved manglende eller ikke-numerisk felt', () => {
|
|
assert.throws(() => beregnScore({ baseline: 1, naa: 2 }), /target/);
|
|
assert.throws(() => beregnScore({ baseline: 1, target: 'x', naa: 2 }), /target/);
|
|
assert.throws(() => beregnScore(undefined), /KR/);
|
|
});
|
|
|
|
test('(B9) lesSyklus kaster ved ufullstendig KR i frontmatter', async (t) => {
|
|
const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs');
|
|
const { tmpdir } = await import('node:os');
|
|
const dir = mkdtempSync(join(tmpdir(), 'okr-syklus-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
writeFileSync(
|
|
join(dir, 'okr-ufullstendig.md'),
|
|
['---', 'type: OKR', 'title: Ufullstendig', 'kr1_navn: Mangler tall', 'kr1_baseline: 3', '---', '# Ufullstendig', ''].join('\n'),
|
|
);
|
|
assert.throws(() => lesSyklus(dir), /kr1/i);
|
|
});
|
|
|
|
test('(B10) lesSyklus kaster naar katalogen ikke finnes', () => {
|
|
assert.throws(() => lesSyklus(join(ROOT, 'tests/fixtures/finnes-ikke')), /finnes ikke/i);
|
|
});
|