ms-ai-architect/tests/kb-update/test-course-diff.test.mjs
Kjell Tore Guttormsen 94c7f42128 feat(ms-ai-architect): C3.2 — course-diff (ren) + course-registry load/save (TDD) [skip-docs]
Pure diff/fold + run-mode selector for course detection (C3.2 av GODKJENT C3-spec).

- lib/course-diff.mjs (REN, null imports): normalizeCourse, selectMode,
  isFullEnumDue, diffCourses, updateRegistry, FULL_ENUM_MAX_AGE_DAYS=30.
  Full-enum-pin (§4.2): removed beregnes KUN i full-modus; inkrementell og
  baseline gir removed:[]. Baseline emitterer ingen leads (§8 b).
  updateRegistry ren (muterer aldri): incremental merger, baseline/full bygger
  fra API-sett + dropper retirerte UIDs, stamper last_full_enum.
- registry-io.mjs +loadCourseRegistry/saveCourseRegistry (additivt, speiler
  loadRegistry/saveRegistry; fil course-registry.json).
- test-course-diff.test.mjs (23) + test-courses-invariant (+3: diff-modul ren).

Gate §7 C3.2 møtt. kb-update 237->263, validate PASSED, null regresjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:12:49 +02:00

296 lines
12 KiB
JavaScript

// tests/kb-update/test-course-diff.test.mjs
// Unit tests for scripts/kb-update/lib/course-diff.mjs (C3.2) — the PURE course
// diff + registry-fold + run-mode selector, plus the course-registry load/save
// round-trip (registry-io). Mirrors the diff semantics in c3-course-detection-plan.md
// §4.1/§4.2 and the §8 decisions:
// (b) a baseline run emits NO leads (registry-establishing only),
// (c) `removed` is computed ONLY on a full enumeration — never incrementally,
// where an updatedAt.gt response omits everything unchanged and a naive
// removed-diff would flag nearly the whole registry (false-positive flood).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
diffCourses,
updateRegistry,
selectMode,
isFullEnumDue,
normalizeCourse,
FULL_ENUM_MAX_AGE_DAYS,
} from '../../scripts/kb-update/lib/course-diff.mjs';
import { loadCourseRegistry, saveCourseRegistry } from '../../scripts/kb-update/lib/registry-io.mjs';
const NOW = '2026-06-23T12:00:00Z';
// A normalized course (the shape diffCourses/updateRegistry operate on).
function course(uid, updated_at, over = {}) {
return {
uid,
type: 'module',
title: `t-${uid}`,
url: `https://learn.microsoft.com/training/modules/${uid}`,
products: ['azure-openai'],
updated_at,
...over,
};
}
// A stored registry entry.
function entry(updated_at, over = {}) {
return {
type: 'module',
title: 'stored',
url: 'stored-url',
products: [],
updated_at,
first_seen: '2026-01-01T00:00:00Z',
last_seen: '2026-06-20T00:00:00Z',
...over,
};
}
function registryWith(courses = {}, meta = {}) {
return {
version: 1,
created_at: '2026-01-01T00:00:00Z',
last_run: '2026-06-20T00:00:00Z',
last_full_enum: '2026-06-20T00:00:00Z',
courses,
...meta,
};
}
// --- normalizeCourse -------------------------------------------------------
test('normalizeCourse — maps API fields to registry shape (products flattened)', () => {
const api = {
id: 'learn.az.x',
title: 'X',
url: 'https://learn.microsoft.com/training/modules/x',
updatedAt: '2026-06-01T00:00:00Z',
products: [{ id: 'azure-openai' }, { id: 'ai-builder' }],
};
const c = normalizeCourse(api, 'module');
assert.equal(c.uid, 'learn.az.x');
assert.equal(c.type, 'module');
assert.equal(c.title, 'X');
assert.equal(c.url, 'https://learn.microsoft.com/training/modules/x');
assert.equal(c.updated_at, '2026-06-01T00:00:00Z');
assert.deepEqual(c.products, ['azure-openai', 'ai-builder']);
});
test('normalizeCourse — missing fields degrade safely', () => {
const c = normalizeCourse({ id: 'u1' }, 'learning-path');
assert.equal(c.uid, 'u1');
assert.equal(c.type, 'learning-path');
assert.equal(c.title, '');
assert.equal(c.url, '');
assert.deepEqual(c.products, []);
assert.equal(c.updated_at, null);
});
// --- diffCourses: new / updated / no-op ------------------------------------
test('diffCourses — new UID (in API, not in registry) → new lead', () => {
const out = diffCourses([course('u1', '2026-06-22T00:00:00Z')], registryWith({}), { mode: 'incremental' });
assert.equal(out.new.length, 1);
assert.equal(out.new[0].uid, 'u1');
assert.equal(out.updated.length, 0);
assert.deepEqual(out.removed, []);
});
test('diffCourses — newer updatedAt on a known UID → updated lead', () => {
const reg = registryWith({ u1: entry('2026-06-01T00:00:00Z') });
const out = diffCourses([course('u1', '2026-06-22T00:00:00Z')], reg, { mode: 'incremental' });
assert.equal(out.updated.length, 1);
assert.equal(out.updated[0].uid, 'u1');
assert.equal(out.updated[0].updated_at, '2026-06-22T00:00:00Z');
assert.equal(out.new.length, 0);
});
test('diffCourses — identical updatedAt → no-op (no lead)', () => {
const reg = registryWith({ u1: entry('2026-06-01T00:00:00Z') });
const out = diffCourses([course('u1', '2026-06-01T00:00:00Z')], reg, { mode: 'incremental' });
assert.equal(out.new.length, 0);
assert.equal(out.updated.length, 0);
});
test('diffCourses — older updatedAt → no-op (never a downgrade lead)', () => {
const reg = registryWith({ u1: entry('2026-06-10T00:00:00Z') });
const out = diffCourses([course('u1', '2026-06-01T00:00:00Z')], reg, { mode: 'incremental' });
assert.equal(out.new.length, 0);
assert.equal(out.updated.length, 0);
});
// --- diffCourses: baseline emits no leads (§8 (b)) -------------------------
test('diffCourses — baseline (empty registry) emits NO leads (§8 (b) tom-baseline)', () => {
const empty = { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} };
const out = diffCourses(
[course('u1', '2026-06-01T00:00:00Z'), course('u2', '2026-06-02T00:00:00Z')],
empty,
{ mode: 'baseline' },
);
assert.deepEqual(out, { new: [], updated: [], removed: [] });
});
test('diffCourses — baseline emits no leads even against a non-empty registry', () => {
const reg = registryWith({ u1: entry('2026-01-01T00:00:00Z') });
const out = diffCourses([course('u1', '2026-06-22T00:00:00Z'), course('u2', '2026-06-22T00:00:00Z')], reg, { mode: 'baseline' });
assert.deepEqual(out, { new: [], updated: [], removed: [] });
});
// --- full-enum-pin: `removed` only on full enumeration (§4.2) --------------
test('diffCourses — removed is emitted ONLY in full mode (§4.2 correctness pin)', () => {
const reg = registryWith({
gone: entry('2026-01-01T00:00:00Z', { title: 'Gone', url: 'gone-url' }),
keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', url: 'keep-url' }),
});
const apiSet = [course('keep', '2026-01-01T00:00:00Z')]; // 'gone' absent from the full API set
const out = diffCourses(apiSet, reg, { mode: 'full' });
assert.deepEqual(out.removed.map((r) => r.uid), ['gone']);
assert.equal(out.removed[0].title, 'Gone');
assert.equal(out.removed[0].url, 'gone-url');
});
test('diffCourses — incremental NEVER emits removed, even when every registry UID is absent from the filtered response', () => {
const reg = registryWith({ a: entry('2026-01-01T00:00:00Z'), b: entry('2026-01-01T00:00:00Z') });
// An updatedAt.gt response with nothing changed returns []: all registry UIDs
// are "missing" — a naive removed-diff would flag the whole registry.
const out = diffCourses([], reg, { mode: 'incremental' });
assert.deepEqual(out.removed, []);
});
test('diffCourses — baseline never emits removed', () => {
const reg = registryWith({ a: entry('2026-01-01T00:00:00Z') });
const out = diffCourses([], reg, { mode: 'baseline' });
assert.deepEqual(out.removed, []);
});
// --- selectMode / isFullEnumDue (full-enum kadens) -------------------------
test('selectMode — no last_run → baseline', () => {
assert.equal(selectMode({ last_run: null, last_full_enum: null, courses: {} }, NOW), 'baseline');
});
test('selectMode — recent full enum → incremental', () => {
const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: '2026-06-20T00:00:00Z' });
assert.equal(selectMode(reg, NOW), 'incremental');
});
test('selectMode — last_full_enum missing but has run → full', () => {
const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: null });
assert.equal(selectMode(reg, NOW), 'full');
});
test('selectMode — last_full_enum older than 30 days → full', () => {
const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: '2026-05-01T00:00:00Z' });
assert.equal(selectMode(reg, NOW), 'full');
});
test('isFullEnumDue — exactly 30 days is NOT due; one second over IS due', () => {
// NOW = 2026-06-23T12:00:00Z; exactly 30 days earlier = 2026-05-24T12:00:00Z.
assert.equal(isFullEnumDue({ last_full_enum: '2026-05-24T12:00:00Z' }, NOW), false);
assert.equal(isFullEnumDue({ last_full_enum: '2026-05-24T11:59:59Z' }, NOW), true);
});
test('FULL_ENUM_MAX_AGE_DAYS — pinned at 30', () => {
assert.equal(FULL_ENUM_MAX_AGE_DAYS, 30);
});
// --- updateRegistry (pure fold) --------------------------------------------
test('updateRegistry — incremental merges (keep untouched, update changed, add new) and leaves last_full_enum', () => {
const reg = registryWith(
{
keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z' }),
chg: entry('2026-01-01T00:00:00Z', { title: 'Old', first_seen: '2026-02-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z' }),
},
{ last_full_enum: '2026-06-20T00:00:00Z' },
);
const next = updateRegistry(
reg,
[course('chg', '2026-06-22T00:00:00Z', { title: 'New' }), course('add', '2026-06-21T00:00:00Z')],
NOW,
{ mode: 'incremental' },
);
// untouched survives, last_seen NOT advanced
assert.ok(next.courses.keep);
assert.equal(next.courses.keep.last_seen, '2026-06-20T00:00:00Z');
// changed: updated_at refreshed, first_seen preserved, last_seen advanced
assert.equal(next.courses.chg.title, 'New');
assert.equal(next.courses.chg.updated_at, '2026-06-22T00:00:00Z');
assert.equal(next.courses.chg.first_seen, '2026-02-01T00:00:00Z');
assert.equal(next.courses.chg.last_seen, NOW);
// new: first_seen = now
assert.equal(next.courses.add.first_seen, NOW);
assert.equal(next.courses.add.last_seen, NOW);
// metadata
assert.equal(next.last_run, NOW);
assert.equal(next.last_full_enum, '2026-06-20T00:00:00Z');
});
test('updateRegistry — full rebuilds from the API set (drops retired UIDs) and stamps last_full_enum', () => {
const reg = registryWith(
{
gone: entry('2026-01-01T00:00:00Z', { title: 'Gone', first_seen: '2026-01-01T00:00:00Z' }),
keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', first_seen: '2026-03-01T00:00:00Z' }),
},
{ last_full_enum: '2026-05-01T00:00:00Z' },
);
const next = updateRegistry(reg, [course('keep', '2026-01-01T00:00:00Z')], NOW, { mode: 'full' });
assert.deepEqual(Object.keys(next.courses), ['keep']); // 'gone' dropped
assert.equal(next.courses.keep.first_seen, '2026-03-01T00:00:00Z'); // preserved
assert.equal(next.courses.keep.last_seen, NOW);
assert.equal(next.last_full_enum, NOW);
assert.equal(next.last_run, NOW);
});
test('updateRegistry — baseline on empty registry seeds created_at + last_full_enum', () => {
const empty = { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} };
const next = updateRegistry(empty, [course('u1', '2026-06-01T00:00:00Z')], NOW, { mode: 'baseline' });
assert.equal(next.created_at, NOW);
assert.equal(next.last_run, NOW);
assert.equal(next.last_full_enum, NOW);
assert.ok(next.courses.u1);
assert.equal(next.courses.u1.first_seen, NOW);
});
test('updateRegistry — does not mutate the input registry', () => {
const reg = registryWith({ u1: entry('2026-01-01T00:00:00Z') });
const before = JSON.stringify(reg);
updateRegistry(reg, [course('u1', '2026-06-22T00:00:00Z')], NOW, { mode: 'incremental' });
assert.equal(JSON.stringify(reg), before);
});
// --- course-registry load/save round-trip (registry-io) --------------------
test('loadCourseRegistry — absent file returns the empty scaffold (schema incl. last_full_enum)', () => {
const dir = mkdtempSync(join(tmpdir(), 'course-reg-'));
try {
assert.deepEqual(loadCourseRegistry(dir), {
version: 1,
created_at: null,
last_run: null,
last_full_enum: null,
courses: {},
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('saveCourseRegistry → loadCourseRegistry round-trips deeply', () => {
const dir = mkdtempSync(join(tmpdir(), 'course-reg-'));
try {
const reg = updateRegistry(loadCourseRegistry(dir), [course('u1', '2026-06-01T00:00:00Z')], NOW, { mode: 'baseline' });
saveCourseRegistry(reg, dir);
assert.deepEqual(loadCourseRegistry(dir), reg);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});