Tredje ledger-kolleksjon (courses), additivt ved siden av decisions (URL,
Spor A) og actions (skill, Spor B). De tre rene helperne speiler
recordAction-trioen 1:1:
- recordCourseLead(led, uid, lead) (ren, UID-nøklet)
- setCourseLeadStatus(led, uid, status, at) (ren transisjon, kaster på ukjent UID)
- isCourseLeadDecided(led, uid) (dedup policy A: any status)
createLedger()->courses:{}; loadDecisions backfiller courses ??= {} (bakoverkompat).
Strukturell ingen-ingest-invariant: apply-pathene leser ALDRI courses
(apply-skill-op.mjs->kun actions, discover-new-urls.mjs->kun decisions),
så et godkjent kurs-lead trigger aldri fetch/transform/KB-skriving.
commands/kb-update.md §3c: operatør-gate som leser course-detection-report.json,
dedup'er via isCourseLeadDecided, skriver godkjente leads via recordCourseLead
(--dry-run viser leads uten å skrive).
Tester (+16): test-decisions-courses.test.mjs. Eksisterende test-decisions-io
+ test-decisions-actions URØRT grønne (ikke-regresjons-bevis, samme mønster
som Spor B). kb-update 291->307 · validate 239/0 · kb-eval 100/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
231 lines
10 KiB
JavaScript
231 lines
10 KiB
JavaScript
// tests/kb-update/test-decisions-courses.test.mjs
|
|
// Spor C / C3.5 (Sesjon 36): the ADDITIVE course-lead layer on the lag-2
|
|
// decision ledger. Courses are a THIRD entity type (UID-keyed) — the URL-keyed
|
|
// `decisions` map (Spor A) and the skill-keyed `actions` map (Spor B) are both
|
|
// untouched. Their contracts stay covered by test-decisions-io.test.mjs and
|
|
// test-decisions-actions.test.mjs, which remain green UNMODIFIED, proving this
|
|
// extension is non-regressive. Here we cover only the new `courses` collection:
|
|
// recordCourseLead (pure), isCourseLeadDecided (dedup policy A per UID),
|
|
// setCourseLeadStatus (pure transition), and backward-compatible normalization
|
|
// of pre-course ledgers.
|
|
//
|
|
// WHY a separate collection (spec §4.5): an approved course lead must NEVER
|
|
// reach the doc-transform/ingest pipeline (auto-ingest is an explicit non-goal).
|
|
// Keeping leads UID-keyed in `courses` holds them structurally out of the
|
|
// `decisions` stream the apply-path reads — the apply-path (apply-skill-op.mjs,
|
|
// discover-new-urls.mjs) reads only `actions`/`decisions`, never `courses`.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
createLedger,
|
|
loadDecisions,
|
|
saveDecisions,
|
|
recordDecision,
|
|
isDecided,
|
|
recordAction,
|
|
isActionDecided,
|
|
actionKey,
|
|
recordCourseLead,
|
|
isCourseLeadDecided,
|
|
setCourseLeadStatus,
|
|
} from '../../scripts/kb-update/lib/decisions-io.mjs';
|
|
|
|
function withTmp(fn) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dec-crs-test-'));
|
|
try {
|
|
return fn(dir);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const UID = 'learn.wwl.introduction-to-azure-openai';
|
|
const LEAD = {
|
|
kind: 'new', // "new" | "updated"
|
|
status: 'pending', // pending | approved | rejected (dedup policy A)
|
|
title: 'Introduction to Azure OpenAI Service',
|
|
url: 'https://learn.microsoft.com/training/modules/intro-azure-openai/',
|
|
products: ['azure-openai'],
|
|
suggested_skill: 'ms-ai-engineering',
|
|
suggested_category: 'azure-ai-services',
|
|
updated_at: '2026-06-20T08:00:00Z',
|
|
detected_at: '2026-06-23T12:00:00Z',
|
|
decided_at: null,
|
|
note: '',
|
|
};
|
|
|
|
// --- scaffold now carries an empty courses collection (additive) ---
|
|
|
|
test('createLedger — additive courses:{} alongside untouched decisions:{} + actions:{}', () => {
|
|
const led = createLedger();
|
|
assert.equal(led.version, 1, 'version stays 1 — no migration needed');
|
|
assert.deepEqual(led.decisions, {}, 'URL-keyed map unchanged');
|
|
assert.deepEqual(led.actions, {}, 'skill-op map unchanged');
|
|
assert.deepEqual(led.courses, {}, 'new UID-keyed course-lead collection present and empty');
|
|
});
|
|
|
|
// --- isCourseLeadDecided — dedup gate (policy A: any status counts) ---
|
|
|
|
test('isCourseLeadDecided — false when absent, true for ANY recorded status (policy A)', () => {
|
|
let led = createLedger();
|
|
assert.equal(isCourseLeadDecided(led, UID), false, 'absent UID is undecided');
|
|
led = recordCourseLead(led, UID, { ...LEAD, status: 'rejected', decided_at: '2026-06-23' });
|
|
assert.equal(isCourseLeadDecided(led, UID), true, 'rejected lead still dedups (policy A)');
|
|
});
|
|
|
|
test('isCourseLeadDecided — true for pending and approved alike (policy A)', () => {
|
|
let led = createLedger();
|
|
led = recordCourseLead(led, 'uid-pending', { ...LEAD, status: 'pending', decided_at: '2026-06-23' });
|
|
led = recordCourseLead(led, 'uid-approved', { ...LEAD, status: 'approved', decided_at: '2026-06-23' });
|
|
assert.equal(isCourseLeadDecided(led, 'uid-pending'), true, 'pending dedups (policy A)');
|
|
assert.equal(isCourseLeadDecided(led, 'uid-approved'), true, 'approved dedups (policy A)');
|
|
});
|
|
|
|
// --- recordCourseLead — pure, mirrors recordAction ---
|
|
|
|
test('recordCourseLead — records under UID, returns new ledger, does not mutate input', () => {
|
|
const led = createLedger();
|
|
const next = recordCourseLead(led, UID, LEAD);
|
|
assert.deepEqual(led.courses, {}, 'input untouched');
|
|
assert.notEqual(next, led);
|
|
assert.equal(next.courses[UID].kind, 'new');
|
|
assert.equal(next.courses[UID].status, 'pending');
|
|
assert.equal(next.courses[UID].suggested_skill, 'ms-ai-engineering');
|
|
assert.deepEqual(next.courses[UID].products, ['azure-openai']);
|
|
});
|
|
|
|
test('recordCourseLead — advances updated_at to the decision date', () => {
|
|
const next = recordCourseLead(createLedger(), UID, {
|
|
...LEAD,
|
|
status: 'approved',
|
|
decided_at: '2026-06-23',
|
|
});
|
|
assert.equal(next.updated_at, '2026-06-23');
|
|
});
|
|
|
|
test('recordCourseLead — a pending lead (decided_at null) leaves updated_at unchanged', () => {
|
|
const next = recordCourseLead(createLedger(), UID, LEAD); // decided_at: null
|
|
assert.equal(next.updated_at, null, 'no decision date -> ledger updated_at untouched');
|
|
});
|
|
|
|
test('recordCourseLead — keyed by stable UID, not URL (dedup survives a URL change)', () => {
|
|
let led = recordCourseLead(createLedger(), UID, LEAD);
|
|
// Same course re-detected later with a changed URL but the SAME UID.
|
|
led = recordCourseLead(led, UID, { ...LEAD, url: 'https://learn.microsoft.com/training/modules/renamed/' });
|
|
assert.equal(Object.keys(led.courses).length, 1, 'same UID dedups to one entry despite URL change');
|
|
assert.equal(isCourseLeadDecided(led, UID), true);
|
|
});
|
|
|
|
test('recordCourseLead — does NOT touch the URL-keyed decisions or skill-keyed actions maps', () => {
|
|
let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', {
|
|
status: 'approved',
|
|
decided_at: '2026-06-19',
|
|
});
|
|
led = recordAction(led, {
|
|
operation_type: 'sanitize_skill',
|
|
status: 'approved',
|
|
decided_at: '2026-06-20',
|
|
targets: { skill: 'ms-ai-governance' },
|
|
});
|
|
const next = recordCourseLead(led, UID, LEAD);
|
|
assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives');
|
|
assert.equal(isActionDecided(next, actionKey({ operation_type: 'sanitize_skill', targets: { skill: 'ms-ai-governance' } })), true, 'action survives');
|
|
assert.equal(isCourseLeadDecided(next, UID), true, 'course lead recorded alongside');
|
|
});
|
|
|
|
// --- ACCEPTANCE CRITERION (gate dedup): a decided course is NOT re-proposed ---
|
|
// Mirrors the discovery dedup-diff acceptance test (test-decisions-io.test.mjs):
|
|
// the gate filters report candidates through isCourseLeadDecided, so a course
|
|
// the operator has already touched (any status) never resurfaces.
|
|
|
|
test('dedup-diff — a decided course UID is surfaced in round 1 but filtered in round 2', () => {
|
|
const filterUndecidedCourses = (ledger, candidates) =>
|
|
candidates.filter((c) => !isCourseLeadDecided(ledger, c.uid));
|
|
|
|
// Round 1: empty ledger — the course is a fresh candidate.
|
|
let led = createLedger();
|
|
const round1 = filterUndecidedCourses(led, [{ uid: UID }]);
|
|
assert.deepEqual(round1.map((c) => c.uid), [UID], 'round 1 surfaces the new course');
|
|
|
|
// Operator rejects it through the gate — the ONLY write path.
|
|
led = recordCourseLead(led, UID, { ...LEAD, status: 'rejected', decided_at: '2026-06-23' });
|
|
|
|
// Round 2: same detection output, the decided course must be filtered out.
|
|
const round2 = filterUndecidedCourses(led, [{ uid: UID }]);
|
|
assert.deepEqual(round2, [], 'round 2 must NOT re-propose the decided course');
|
|
});
|
|
|
|
// --- setCourseLeadStatus — pure status transition (mirrors setActionStatus) ---
|
|
|
|
test('setCourseLeadStatus — transitions status, preserves every other field, is pure', () => {
|
|
const led = recordCourseLead(createLedger(), UID, { ...LEAD, status: 'pending' });
|
|
const next = setCourseLeadStatus(led, UID, 'approved', '2026-06-24');
|
|
|
|
assert.equal(led.courses[UID].status, 'pending', 'input ledger untouched (pure)');
|
|
assert.notEqual(next, led);
|
|
assert.equal(next.courses[UID].status, 'approved');
|
|
assert.equal(next.courses[UID].title, LEAD.title, 'title preserved');
|
|
assert.deepEqual(next.courses[UID].products, LEAD.products, 'products preserved');
|
|
assert.equal(next.courses[UID].suggested_skill, LEAD.suggested_skill, 'suggested_skill preserved');
|
|
});
|
|
|
|
test('setCourseLeadStatus — advances entry decided_at and ledger updated_at', () => {
|
|
const led = recordCourseLead(createLedger(), UID, { ...LEAD, status: 'pending' });
|
|
const next = setCourseLeadStatus(led, UID, 'approved', '2026-06-24');
|
|
assert.equal(next.courses[UID].decided_at, '2026-06-24', 'entry date advances');
|
|
assert.equal(next.updated_at, '2026-06-24');
|
|
});
|
|
|
|
test('setCourseLeadStatus — without a date keeps the entry decided_at and ledger updated_at', () => {
|
|
let led = recordCourseLead(createLedger(), UID, { ...LEAD, status: 'pending', decided_at: '2026-06-23' });
|
|
const next = setCourseLeadStatus(led, UID, 'approved');
|
|
assert.equal(next.courses[UID].decided_at, '2026-06-23', 'no date -> keep original');
|
|
});
|
|
|
|
test('setCourseLeadStatus — throws on an unknown UID (cannot transition a non-entry)', () => {
|
|
const led = recordCourseLead(createLedger(), UID, LEAD);
|
|
assert.throws(() => setCourseLeadStatus(led, 'uid-ghost', 'approved'), /no course|ghost/i);
|
|
});
|
|
|
|
test('setCourseLeadStatus — leaves decisions, actions, and sibling courses untouched', () => {
|
|
let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', {
|
|
status: 'approved',
|
|
decided_at: '2026-06-19',
|
|
});
|
|
led = recordCourseLead(led, UID, { ...LEAD, status: 'pending' });
|
|
led = recordCourseLead(led, 'uid-sibling', { ...LEAD, status: 'pending' });
|
|
const next = setCourseLeadStatus(led, UID, 'approved', '2026-06-24');
|
|
|
|
assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives');
|
|
assert.equal(next.courses['uid-sibling'].status, 'pending', 'sibling course untouched');
|
|
});
|
|
|
|
// --- backward compatibility: a pre-course ledger on disk loads cleanly ---
|
|
|
|
test('loadDecisions — normalizes a pre-course (no courses key) ledger to courses:{}', () => {
|
|
withTmp((dir) => {
|
|
// Simulate an on-disk ledger written before the course layer existed.
|
|
writeFileSync(
|
|
join(dir, 'decisions.json'),
|
|
JSON.stringify({ version: 1, updated_at: null, decisions: {}, actions: {} }, null, 2),
|
|
);
|
|
const led = loadDecisions(dir);
|
|
assert.deepEqual(led.courses, {}, 'missing courses key is backfilled, not undefined');
|
|
// and recording a course lead on the loaded ledger works
|
|
const next = recordCourseLead(led, UID, LEAD);
|
|
assert.equal(isCourseLeadDecided(next, UID), true);
|
|
});
|
|
});
|
|
|
|
test('saveDecisions + loadDecisions — courses round-trip through disk', () => {
|
|
withTmp((dir) => {
|
|
const led = recordCourseLead(createLedger(), UID, LEAD);
|
|
saveDecisions(led, dir);
|
|
const back = loadDecisions(dir);
|
|
assert.deepEqual(back, led);
|
|
});
|
|
});
|