// syklus-status.test.mjs // S60: det uwirede leddet -- generator -> /okr:sporing -> status.md. // // Kjeden var beskrevet i prosa og manglet et mekanisk ledd: ingenting foerte en // generert rad inn i `status.md`, fila coaching-hook.mjs, /okr:moeter og // /okr:oppsett arkiver alle leser. Operatoerbeslutning (S60): et deterministisk // merge-steg eier skrivingen -- ALDRI en LLM som skriver av tallene. // // Testene binder fire ting: // (1) aritmetikken re-typas aldri: status-radene er tertialrapportens rader // med confidence-cellen fylt, tegn for tegn ellers. // (2) beslutning 9 staar: generatoren PLASSERER en etikett den faar utenfra, // og OPPFINNER aldri en. Uten input kaster den. // (3) det lukkede vokabularet (okr-framework.md:564, eneste sannhetskilde). // (4) kuratert-fil-vern: en haandholdt status.md klobbes aldri (moenster fra // lib/innboks-write.mjs:88-91). // // 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 { spawnSync } from 'node:child_process'; import { copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { lesSyklus } from '../lib/syklus-data.mjs'; import { statusrapport, tertialrapport } from '../lib/syklus-rapport.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const FIXTURE = join(ROOT, 'tests/fixtures/okf-realistic/syklus/T1-2026'); const CLI = join(ROOT, 'scripts/syklus-status.mjs'); const NAA = '2026-05-01T10:00:00.000Z'; // Fixturen har sin egen status.md (okf-retrieval-testene leser den). Kopier KUN // okr-*.md til en temp-katalog, saa skrivetestene aldri roerer fixturen. function lagSyklus(t) { const dir = mkdtempSync(join(tmpdir(), 'okr-status-')); t.after(() => rmSync(dir, { recursive: true, force: true })); for (const fil of readdirSync(FIXTURE)) { if (fil.startsWith('okr-') && fil.endsWith('.md')) { copyFileSync(join(FIXTURE, fil), join(dir, fil)); } } return dir; } const FULL = { 'Alvorlige tunnelhendelser (antall)': 'Off Track', 'Tunneler med oppgradert sikkerhetsutrustning (antall)': 'At Risk', 'Andel digitale soeknader (prosent)': 'At Risk', 'Gjennomsnittlig saksbehandlingstid (dager)': 'On Track', }; const syklus = () => lesSyklus(FIXTURE); const bygg = (confidence = FULL, opts = {}) => statusrapport(syklus(), { naa: NAA, confidence, ...opts }); const kjor = (dir, stdin, env = {}) => spawnSync( process.execPath, [CLI, dir], { input: typeof stdin === 'string' ? stdin : JSON.stringify(stdin), encoding: 'utf8', env: { ...process.env, OKR_NOW: NAA, ...env } }, ); // En KR-rad er en tabellrad som verken er hode eller skillelinje. const erKrRad = (linje) => /^\s*\|/.test(linje) && !/^\s*\|[\s|:-]+\|\s*$/.test(linje) && !/\|\s*KR\s*\|/.test(linje); const krRader = (md) => md.split('\n').filter(erKrRad); // Raden uten siste celle -- alt generatoren eier. const utenConfidence = (rad) => rad.replace(/\|[^|]*\|\s*$/, '|'); // Hookens EGNE predikater, lest fra kilden. En kopiert regex ville bevist at // testen er enig med seg selv (moenster: syklus-rapport.test.mjs). 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, 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]) }; } // --- (1) LOAD-BEARING: aritmetikken kommer fra generatoren, aldri fra input --- // // Dette er hele grunnen til at leddet er et script og ikke en instruksjon til en // LLM: tallene i status.md skal vaere de SAMME bytene generatoren beregnet. test('(1) status-radene er tertialrapportens rader med bare confidence-cellen endret', () => { const tertial = krRader(tertialrapport(syklus(), { naa: NAA })).map(utenConfidence); const status = krRader(bygg()).map(utenConfidence); assert.ok(tertial.length >= 4, `parser-sanity: fant ${tertial.length} KR-rader`); assert.deepEqual(status, tertial, 'status.md re-typer aritmetikk i stedet for aa arve den'); }); test('(2) hver KR-rad baerer etiketten som ble gitt for den KR-en', () => { const md = bygg(); for (const [navn, etikett] of Object.entries(FULL)) { const rad = krRader(md).find((l) => l.includes(navn)); assert.ok(rad, `fant ingen rad for ${navn}`); assert.match(rad, new RegExp(`\\|\\s*${etikett}\\s*\\|\\s*$`), `feil etikett i siste celle for ${navn}`); } }); // --- (3) Beslutning 9: plasserer, oppfinner aldri --- test('(3) statusrapport uten confidence-input kaster, den gjetter aldri', () => { assert.throws(() => statusrapport(syklus(), { naa: NAA }), /confidence/i); assert.throws(() => statusrapport(syklus(), { naa: NAA, confidence: {} }), /confidence|mangler/i); }); // Mutasjon M3 (S60) viste at vokab-sjekken OGSAA feller manglende dekning: // `undefined` er ikke en lovlig etikett. Behovet for en egen dekningssjekk er // likevel reelt -- den lister ALLE manglende KR i én melding, mens vokab-vakten // bare rapporterer den foerste som en type-feil. Assertionen paa meldingen er // det som gjoer den dedikerte vakten dekket for seg; uten den ville M3 gaatt // groenn og vakten vaert uverifisert. test('(4) manglende etikett for ett KR kaster, og feilen navngir KR-en', () => { const delvis = { ...FULL }; delete delvis['Andel digitale soeknader (prosent)']; assert.throws(() => bygg(delvis), /mangler confidence for 1 KR/); assert.throws(() => bygg(delvis), /Andel digitale soeknader/); }); test('(4b) manglende etiketter rapporteres samlet, ikke én om gangen', () => { assert.throws(() => bygg({ 'Andel digitale soeknader (prosent)': 'At Risk' }), /mangler confidence for 3 KR/); }); // Et ukjent navn er nesten alltid en skrivefeil. Uten denne vakten ville KR-en // den var MENT for staatt uten etikett, og feilen blitt oppdaget av en leser. test('(5) ukjent KR-navn i inputen kaster, og feilen navngir det ukjente navnet', () => { assert.throws(() => bygg({ ...FULL, 'KR som ikke finnes': 'On Track' }), /KR som ikke finnes/); }); // --- (6) Lukket vokabular: okr-framework.md:564 er eneste sannhetskilde --- test('(6) etikett utenfor det kanoniske vokabularet kaster', () => { for (const ugyldig of ['Groenn', 'on-track', 'OK', 'At risk 70%', '']) { assert.throws( () => bygg({ ...FULL, 'Andel digitale soeknader (prosent)': ugyldig }), /confidence|etikett/i, `godtok ugyldig etikett: ${JSON.stringify(ugyldig)}`, ); } }); test('(6b) de tre kanoniske etikettene godtas', () => { for (const gyldig of ['On Track', 'At Risk', 'Off Track']) { assert.doesNotThrow(() => bygg({ ...FULL, 'Andel digitale soeknader (prosent)': gyldig })); } }); // --- (7) Hook-kontrakten: fila MAA vaere tellbar for coaching-hook.mjs --- test('(7) hooken kan telle radene, og teller kun de ikke-groenne', () => { const { radForm, fareOrd } = hookPredikater(); const rader = krRader(bygg()); const utellbare = rader.filter((l) => !radForm.test(l)); assert.deepEqual(utellbare, [], `rader hooken ikke kan telle:\n${utellbare.join('\n')}`); // FULL har 1 Off Track + 2 At Risk = 3 ikke-groenne, 1 On Track. const talt = rader.filter((l) => radForm.test(l) && fareOrd.test(l)); assert.equal(talt.length, 3, 'hooken ville talt feil antall KR i fare'); }); // --- (8-12) CLI-kontrakten --- test('(8) CLI skriver status.md som et OKF-konsept med provenans', (t) => { const dir = lagSyklus(t); const r = kjor(dir, FULL); assert.equal(r.status, 0, `exit ${r.status}: ${r.stderr}`); const md = readFileSync(join(dir, 'status.md'), 'utf8'); // type: Status -- ellers feller okf-check hele roten. assert.match(md, /^type:\s*Status$/m, 'mangler OKF type: Status'); // Provenansen er det kuratert-fil-vernet leser. assert.match(md, /^kilde:\s*sporing$/m, 'mangler provenans-noekkel'); assert.match(md, new RegExp(`^timestamp:.*${NAA}`, 'm'), 'klokke-soemmen OKR_NOW er ikke fulgt'); assert.equal(krRader(md).length, 4, 'feil antall KR-rader i skrevet fil'); }); test('(9) determinisme: to kjoeringer med samme OKR_NOW gir byte-identisk fil', (t) => { const dir = lagSyklus(t); assert.equal(kjor(dir, FULL).status, 0); const foerste = readFileSync(join(dir, 'status.md'), 'utf8'); assert.equal(kjor(dir, FULL).status, 0); assert.equal(readFileSync(join(dir, 'status.md'), 'utf8'), foerste); }); // Moenster fra lib/innboks-write.mjs:88-91: en eksisterende fil UTEN provenans // er haandkuratert, og et stille datatap er verre enn en avvist kjoering. test('(10) kuratert status.md avvises, og fila staar uendret', (t) => { const dir = lagSyklus(t); const haandholdt = '---\ntype: Status\n---\n\n# Skrevet for haand\n'; writeFileSync(join(dir, 'status.md'), haandholdt, 'utf8'); const r = kjor(dir, FULL); assert.equal(r.status, 1, 'kuratert fil maa gi domenefeil'); assert.match(r.stderr, /kuratert|haandholdt|overskriv/i); assert.equal(readFileSync(join(dir, 'status.md'), 'utf8'), haandholdt, 'fila ble endret likevel'); }); test('(11) egen tidligere status.md overskrives', (t) => { const dir = lagSyklus(t); assert.equal(kjor(dir, FULL).status, 0); const endret = { ...FULL, 'Gjennomsnittlig saksbehandlingstid (dager)': 'Off Track' }; const r = kjor(dir, endret); assert.equal(r.status, 0, `exit ${r.status}: ${r.stderr}`); const rad = krRader(readFileSync(join(dir, 'status.md'), 'utf8')) .find((l) => l.includes('Gjennomsnittlig saksbehandlingstid')); assert.match(rad, /\|\s*Off Track\s*\|\s*$/); }); test('(12) exit-koder: bruksfeil 2, domenefeil 1', (t) => { const dir = lagSyklus(t); assert.equal(spawnSync(process.execPath, [CLI], { input: '{}', encoding: 'utf8' }).status, 2, 'manglende argument'); assert.equal(kjor(join(dir, 'finnes-ikke'), FULL).status, 2, 'katalog finnes ikke'); assert.equal(kjor(dir, 'ikke json').status, 2, 'ugyldig JSON er bruksfeil'); assert.equal(kjor(dir, { ...FULL, 'Andel digitale soeknader (prosent)': 'Groenn' }).status, 1, 'ugyldig etikett er domenefeil'); });