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>
296 lines
12 KiB
JavaScript
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 });
|
|
}
|
|
});
|