Hooken teller alle rader merket At Risk/Off Track under ett og sier «vurder tiltak». okr-framework.md:605 sier samtidig at de fleste stretch-KR BOER staa At Risk, og at permanent groent er sandbagging-signalet -- saa for en sunn aspirational-portefoelje peker varselet feil vei: eneste maate aa dempe det paa er aa merke stretch-KR groenne. Kollisjonen er ikke ny i art (en haandskrevet status.md ble talt likt), men fra 1.10.0 fylles kolonnen deterministisk, saa den inntreffer alltid. Fikset hoerer hjemme i hooken, ikke i skriveleddet, og foeres som patch-lane-post. Operatoerbeslutning: dokumenter naa, fiks i egen oekt med egen TDD-syklus. Ogsaa: sporing.md stavet samme KR-navn paa to maater i to eksempler (fixturens ASCII-form lakk inn i det ene). Oppslaget er eksakt match paa kr*_navn, saa to stavemaater i samme fil er en felle.
220 lines
11 KiB
JavaScript
220 lines
11 KiB
JavaScript
// 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 ---
|
|
// MERK (S60, kjent avvik): tallet 3 under koder hookens NAAVAERENDE oppfoersel,
|
|
// ikke den oenskede. En av de tre radene er et aspirational KR, og
|
|
// okr-framework.md:605 sier at de fleste stretch-KR BOER staa At Risk -- saa
|
|
// hookens «OBS: N KR er At Risk/Off Track, vurder tiltak» peker feil vei for en
|
|
// sunn aspirational-portefoelje. Fikset hoerer hjemme i hooken (patch-lane), og
|
|
// naar det kommer skal denne assertionen endres MED den. Testen her vokter at
|
|
// fila er TELLBAR, ikke at 3 er riktig svar.
|
|
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');
|
|
});
|