// coaching-hook.test.mjs // Tester coaching-hook (SC4): graceful-exit-grener + gyldig-syklus-emisjon + // deterministisk fase via OKR_NOW klokke-seam. Spawner hooken som subprosess // med kontrollert cwd + env. Zero npm deps. Coaching leser kun cwd, saa én // temp-dir per test holder. Moenster: tests/inject-okr-context.test.mjs. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const HOOK = join( dirname(fileURLToPath(import.meta.url)), '..', 'hooks', 'scripts', 'coaching-hook.mjs', ); function writeConfig(workDir, body) { const p = join(workDir, '.claude'); mkdirSync(p, { recursive: true }); writeFileSync(join(p, 'okr.local.md'), body); } function runHook(cwd, okrNow) { // execFileSync returnerer stdout; hooken avslutter alltid med exit 0. const env = { ...process.env }; if (okrNow) env.OKR_NOW = okrNow; else delete env.OKR_NOW; return execFileSync('node', [HOOK], { cwd, env, encoding: 'utf8' }); } function withWork(fn) { const work = mkdtempSync(join(tmpdir(), 'okrcoach-')); try { fn(work); } finally { rmSync(work, { recursive: true, force: true }); } } test('ingen config: exit 0, tom stdout (graceful)', () => { withWork((work) => { const out = runHook(work); assert.equal(out.trim(), '', 'ingen config -> tom stdout, ingen blokkering'); }); }); test('config uten id: exit 0, tom stdout', () => { withWork((work) => { writeConfig(work, '---\nnavn: "Org"\n---\n'); const out = runHook(work); assert.equal(out.trim(), '', 'manglende id -> tom stdout'); }); }); test('ugyldig id: exit 0, tom stdout', () => { withWork((work) => { writeConfig(work, '---\nid: "ugyldig"\n---\n'); const out = runHook(work); assert.equal(out.trim(), '', 'id som ikke matcher T/Q-moenster -> tom stdout'); }); }); test('gyldig syklus: emitterer systemMessage', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); const out = runHook(work); assert.match(out, /OKR coaching/, 'gyldig syklus skal emittere coaching-melding'); }); }); test('OKR_NOW tidlig fase: early-coaching', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); const out = runHook(work, '2026-05-05'); assert.match(out, /Tidlig i syklusen/, 'uke 1 av T2 -> tidlig fase'); }); }); test('OKR_NOW midtveis fase: mid-coaching', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); const out = runHook(work, '2026-06-20'); assert.match(out, /Midtveis i syklusen/, 'uke ~8 av T2 -> midtveis fase'); }); }); test('at-risk telles per status-RAD (tabell), ikke raaforekomster i prosa (B2/M1)', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); const statusDir = join(work, '.claude', 'okr', 'syklus', 'T2-2026'); mkdirSync(statusDir, { recursive: true }); // 2 markerte rader + markoer-ord i forklaring OG prosa: kun radene skal telle. writeFileSync(join(statusDir, 'status.md'), [ '# Status T2-2026', '', 'Merk: "I fare" betyr flat trend; "Blokkert" betyr ingen fremgang.', '', '| KR | Baseline | Maal | Naa | Score | Status |', '|----|----------|------|-----|-------|--------|', '| KR1: Redusere ulykker | 40 | 30 | 35 | 0.50 | I fare |', '| KR2: Oppetid | 10 | 25 | 22 | 0.80 | Paa sporet |', '| KR3: Tilfredshet | 60% | 90% | 65% | 0.17 | Blokkert |', '', 'KR1 er i fare fordi trenden er flat. KR3 er blokkert av leverandoer.', '', ].join('\n')); const out = runHook(work, '2026-06-20'); // midtveis -> at-risk rapporteres assert.match(out, /OBS: 2 KR er merket/, '2 markerte rader -> teller noeyaktig 2 (ikke 6)'); }); }); // R1 (review.md af16d5e4): 1.8.0 skrev om status-malen (commands/sporing.md:86-88) til den // KANONISKE confidence-skalaen (okr-framework.md:389-392) — On Track / At Risk / Off Track. // Hooken talte fortsatt kun det gamle norske vokabularet, saa nudgen doede stille under 1.8.0. // Denne casen mater malen slik den faktisk genereres i dag; casen over beholder det gamle // vokabularet og daekker dermed bakover-kompatibilitet for arkiverte status-filer. test('at-risk telles paa kanonisk 1.8.0-vokabular (At Risk + Off Track, ikke On Track)', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); const statusDir = join(work, '.claude', 'okr', 'syklus', 'T2-2026'); mkdirSync(statusDir, { recursive: true }); // Emoji som \u-escapes: test-kilden holdes ASCII-ren (bash 3.2 set -u multibyte). const GUL = '\u{1F7E1}'; const GROENN = '\u{1F7E2}'; const ROED = '\u{1F534}'; writeFileSync(join(statusDir, 'status.md'), [ '# Status T2-2026', '', '| KR | Baseline | Target | Naa | Score | Status |', '|----|----------|--------|-----|-------|--------|', `| KR1: Redusere ulykker | 40 | 30 | 35 | 0.50 | At Risk ${GUL} |`, `| KR2: Fartshumper installert | 0% | 100% | 60% | 0.60 | On Track ${GROENN} |`, `| KR3: Foreldre-tilfredshet | 60% | 90% | 65% | 0.17 | Off Track ${ROED} |`, '', ].join('\n')); const out = runHook(work, '2026-06-20'); assert.match( out, /OBS: 2 KR er merket/, 'At Risk + Off Track teller (2); On Track skal IKKE telle', ); }); }); // --- Seksjons-bevisst telling (patch-lane #1) --- // // okr-framework.md:605: «De fleste stretch-KR boer derfor starte At Risk -- ikke // groent», og permanent groent ER sandbagging-signalet. En hook som teller ALLE // ikke-groenne rader under ett ber derfor operatoeren om «tiltak» for at // aspirational-portefoeljen oppfoerer seg slik doktrinen krever, og eneste maate // aa dempe nagget paa er aa merke stretch-KR groenne -- noeyaktig det kanon // advarer mot. Fra 1.10.0 fylles Confidence-kolonnen deterministisk av // /okr:sporing, saa kollisjonen inntreffer alltid, ikke bare av og til. // // Operatoerbeslutning (S61), to ledd: // (a) aspirational At Risk er FORVENTET og nagger ikke; aspirational Off Track // teller, fordi «blokkere oppstaar, progresjon stopper opp» (framework:610) // er et reelt signal ogsaa for et stretch-KR. // (b) en rad hooken ikke kan plassere i en seksjon telles som COMMITTED. // Haandskrevne status.md fra foer 1.10.0 har ingen seksjonsstruktur, og en // stille fil er den farlige feilen: den ser vurdert ut uten aa vaere det. // Samme verdi som dekningsvakten i statusrapport() haandhever. const GENERERT_HODE = [ '# Statusrapport T2-2026', '', '| KR | Baseline | Target | Naa | Score | Avvik | Confidence |', '|----|----------|--------|-----|-------|-------|------------|', ]; // Bygger en fil med generatorens seksjonsstruktur. `## Avvik som skal forklares` // ligger MELLOM de to seksjonene i ekte output og er med her med vilje: den // inneholder kun punktlister, og skal verken telles eller oedelegge // seksjons-tilhoerigheten for radene under. function generertStatus({ committed = [], aspirational = [] }) { const ut = ['# Statusrapport T2-2026', '']; if (committed.length > 0) { ut.push('## Committed Key Results', '', '### Trafikksikkerhet', '', ...GENERERT_HODE.slice(2), ...committed, ''); } ut.push( '## Avvik som skal forklares', '', '- Trafikksikkerhet: KR1 -- naa 17 mot krav 20 (score 0.63)', '', ); if (aspirational.length > 0) { ut.push('## Aspirational Key Results', '', '### Digitalisering', '', ...GENERERT_HODE.slice(2), ...aspirational, ''); } return ut.join('\n'); } function writeStatus(work, innhold) { const statusDir = join(work, '.claude', 'okr', 'syklus', 'T2-2026'); mkdirSync(statusDir, { recursive: true }); writeFileSync(join(statusDir, 'status.md'), innhold); } test('aspirational At Risk nagger IKKE -- kanon sier stretch-KR boer staa der', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); writeStatus(work, generertStatus({ committed: ['| KR1: Alvorlige hendelser | 0 | 0 | 0 | udefinert | Nei | On Track |'], aspirational: [ '| KR2: Andel digitale soeknader | 60 | 85 | 63 | 0.12 | - | At Risk |', '| KR3: Saksbehandlingstid | 14 | 5 | 11 | 0.33 | - | At Risk |', ], })); const out = runHook(work, '2026-06-20'); assert.doesNotMatch( out, /OBS:/, 'en sunn aspirational-portefoelje (At Risk) skal ikke utloese tiltaks-nagget', ); }); }); test('aspirational Off Track teller -- progresjonen har stoppet opp', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); writeStatus(work, generertStatus({ aspirational: [ '| KR2: Andel digitale soeknader | 60 | 85 | 63 | 0.12 | - | At Risk |', '| KR3: Saksbehandlingstid | 14 | 5 | 11 | 0.33 | - | Off Track |', ], })); const out = runHook(work, '2026-06-20'); assert.match(out, /OBS: 1 KR er merket/, 'kun Off Track-raden skal telle (1), ikke At Risk-raden'); }); }); test('committed teller baade At Risk og Off Track, aspirational At Risk holdes utenfor', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); writeStatus(work, generertStatus({ committed: [ '| KR1: Alvorlige hendelser | 0 | 0 | 0 | udefinert | Nei | Off Track |', '| KR2: Oppgradert utrustning | 12 | 20 | 17 | 0.63 | Ja | At Risk |', ], aspirational: ['| KR3: Andel digitale soeknader | 60 | 85 | 63 | 0.12 | - | At Risk |'], })); const out = runHook(work, '2026-06-20'); assert.match(out, /OBS: 2 KR er merket/, 'de to committed-radene teller; aspirational At Risk gjoer ikke'); }); }); // Fallback-beslutningen (b), som EGEN vakt. De to eldre at-risk-testene over // treffer den ogsaa, men de handler om noe annet (rad-form og vokabular), saa en // fremtidig endring kunne snudd fallback-semantikken uten at noen test NAVNGA // den. Moenster: S60s M3 -- en overlappet vakt er en udekket vakt. test('fallback: rader uten seksjonsoverskrift telles som committed, ikke bortfiltrert', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); // Haandskrevet form fra foer 1.10.0: ingen ## Committed / ## Aspirational. writeStatus(work, [ '# Status T2-2026', '', ...GENERERT_HODE.slice(2), '| KR1: Redusere ulykker | 40 | 30 | 35 | 0.50 | Ja | At Risk |', '| KR2: Oppetid | 10 | 25 | 22 | 0.80 | Nei | On Track |', '| KR3: Tilfredshet | 60 | 90 | 65 | 0.17 | Ja | At Risk |', '', ].join('\n')); const out = runHook(work, '2026-06-20'); assert.match( out, /OBS: 2 KR er merket/, 'uplasserbare At Risk-rader maa fortsatt telle -- en stille fil er den farlige feilen', ); }); }); test('OKR_NOW sen fase: late-coaching', () => { withWork((work) => { writeConfig(work, '---\nid: "T2-2026"\n---\n'); const out = runHook(work, '2026-08-25'); assert.match(out, /sluttspurt/, 'uke ~16 av T2 -> sen fase'); }); });