ms-ai-architect/tests/kb-update/test-user-data.test.mjs
Kjell Tore Guttormsen 544934dc57
refactor(examples): replace sector-specific example material with generic, fictitious examples
Reference files, test fixtures, the playground demo project and one design
document now use generic, fictitious examples (buildings, energy, water,
grants, municipal services). The playground demo (17 fixtures plus the
embedded demo state) tells one consistent story: a municipal customer
chatbot that pre-screens housing-benefit applications, classified under
Annex III point 5(a). The embedded demo copies were edited in place rather
than regenerated, because they already carry newer AI Act dates than the
fixture files.

Legal text is unchanged. Test semantics are unchanged. Four dark-theme
onboarding screenshots with outdated placeholder text are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:03:53 +02:00

460 lines
20 KiB
JavaScript

// tests/kb-update/test-user-data.test.mjs
// Spor C fase C2.1: user-owned storage + ambient org-context injection (#1 + #2).
//
// lib/user-data.mjs is the PURE resolver every later C2 sub-session builds on.
// It NEVER writes — it only resolves user-owned paths and builds a compact,
// deterministic org-context summary from file contents handed to it. This suite
// proves:
// (A) the resolvers point at ~/.claude/ms-ai-architect/ (a USER-owned dir,
// independent of pluginRoot => survives plugin reinstall — acceptance K2),
// and the config filename matches C1's so loadScheduleConfig stays compat,
// (B) buildOrgSummary is deterministic, length-capped, tolerant, and pure
// (no fs) so the SessionStart hook can inject it ambiently (K1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { homedir } from 'node:os';
import {
USER_DATA_DIRNAME,
CONFIG_FILENAME,
ORG_FILES,
FREE_CONTEXT_FILE,
resolveUserDataDir,
resolveOrgDir,
resolveConfigPath,
buildOrgSummary,
orderOrgFiles,
} from '../../scripts/kb-update/lib/user-data.mjs';
// ===========================================================================
// (A) Resolvers — pure, homedir-injectable, user-owned (survives reinstall)
// ===========================================================================
test('USER_DATA_DIRNAME — the plugin folder under ~/.claude', () => {
assert.equal(USER_DATA_DIRNAME, 'ms-ai-architect');
});
test('resolveUserDataDir — user-owned dir under ~/.claude, independent of pluginRoot (K2)', () => {
assert.equal(resolveUserDataDir('/Users/x'), join('/Users/x', '.claude', 'ms-ai-architect'));
});
test('resolveUserDataDir — defaults to os.homedir()', () => {
assert.equal(resolveUserDataDir(), join(homedir(), '.claude', 'ms-ai-architect'));
});
test('resolveOrgDir — org/ under the user data dir', () => {
assert.equal(resolveOrgDir('/Users/x'), join('/Users/x', '.claude', 'ms-ai-architect', 'org'));
});
test('resolveConfigPath — config under user data dir; filename matches C1 (backward-compat)', () => {
assert.equal(
resolveConfigPath('/Users/x'),
join('/Users/x', '.claude', 'ms-ai-architect', CONFIG_FILENAME),
);
assert.equal(CONFIG_FILENAME, 'ms-ai-architect.local.md', 'must match detection-schedule CONFIG_FILENAME');
});
test('ORG_FILES — the five canonical onboarding files in deterministic order', () => {
assert.deepEqual([...ORG_FILES], [
'organization-profile.md',
'technology-stack.md',
'security-compliance.md',
'architecture-decisions.md',
'business-references.md',
]);
});
// ===========================================================================
// (B) buildOrgSummary — deterministic, capped, pure
// ===========================================================================
const PROFILE = [
'---',
'category: organization-profile',
'completed: true',
'last_updated: 2026-06-22',
'---',
'',
'# Virksomhetsprofil',
'',
'## Sektortype',
'Offentlig sektor',
'',
'## Sektor',
'Statlig',
'',
'## Virksomhet',
'Eksempeldirektoratet — nasjonal forvaltning',
'',
'## Størrelse',
'2000-10000',
'',
'## Regulatoriske krav',
'Personopplysningsloven/GDPR, Arkivloven, Forvaltningsloven',
'',
].join('\n');
test('buildOrgSummary — no files / null content => empty string', () => {
assert.equal(buildOrgSummary({}), '');
assert.equal(buildOrgSummary({ 'organization-profile.md': null }), '');
});
test('buildOrgSummary — null/undefined/garbage input is tolerated (returns "")', () => {
assert.equal(buildOrgSummary(null), '');
assert.equal(buildOrgSummary(undefined), '');
assert.equal(buildOrgSummary({ 'organization-profile.md': 12345 }), '');
});
test('buildOrgSummary — strips frontmatter + H1, extracts H2 section => value lines', () => {
const s = buildOrgSummary({ 'organization-profile.md': PROFILE });
assert.doesNotMatch(s, /category:/, 'frontmatter stripped');
assert.doesNotMatch(s, /completed:/, 'frontmatter stripped');
assert.doesNotMatch(s, /Virksomhetsprofil/, 'H1 not included');
assert.match(s, /Sektortype: Offentlig sektor/);
assert.match(s, /Sektor: Statlig/);
assert.match(s, /Virksomhet: Eksempeldirektoratet/);
assert.match(s, /Størrelse: 2000-10000/);
assert.match(s, /Regulatoriske krav: .*Arkivloven/);
});
test('buildOrgSummary — deterministic order follows ORG_FILES regardless of input key order', () => {
const TECH = '---\ncategory: technology-stack\n---\n## Lisenstype\nE5\n';
const out = buildOrgSummary({ 'technology-stack.md': TECH, 'organization-profile.md': PROFILE });
assert.ok(
out.indexOf('Sektortype') < out.indexOf('Lisenstype'),
'organization-profile sections precede technology-stack sections',
);
});
test('buildOrgSummary — non-.md keys and non-string values contribute nothing', () => {
assert.equal(buildOrgSummary({ 'notater.txt': '## Hemmelig\nverdi' }), '');
assert.equal(buildOrgSummary({ 'ekstra.md': 12345 }), '');
});
test('buildOrgSummary — skips empty sections (header with no body)', () => {
const c = '## Sektor\n\n## Størrelse\n500-2000\n';
const s = buildOrgSummary({ 'organization-profile.md': c });
assert.doesNotMatch(s, /Sektor:/, 'empty section omitted');
assert.match(s, /Størrelse: 500-2000/);
});
test('buildOrgSummary — collapses a multi-line section body to one line', () => {
const c = '## Virksomhet\nLinje en\nLinje to\n';
const s = buildOrgSummary({ 'organization-profile.md': c });
assert.match(s, /Virksomhet: Linje en Linje to/);
});
test('buildOrgSummary — caps a long free-text value with an ellipsis (budget guard)', () => {
const long = 'x'.repeat(500);
const s = buildOrgSummary(
{ 'business-references.md': `## Referansearkitektur\n${long}\n` },
{ maxTotalLen: 80 },
);
const line = s.split('\n').find((l) => l.includes('Referansearkitektur'));
assert.ok(line, 'value line present');
assert.match(line, /…$/, 'truncation marker appended');
// label "Referansearkitektur: " + value(<=80)
assert.ok(line.length <= 'Referansearkitektur: '.length + 80, `line too long: ${line.length}`);
});
test('buildOrgSummary — caps total line count and marks omitted fields', () => {
const many = Array.from({ length: 40 }, (_, i) => `## Felt${i}\nverdi${i}`).join('\n\n');
const s = buildOrgSummary({ 'organization-profile.md': many }, { cap: 10 });
const lines = s.split('\n').filter(Boolean);
assert.ok(lines.length <= 10, `capped to 10, got ${lines.length}`);
assert.match(s, /flere/, 'truncation marker indicates omitted fields');
});
test('buildOrgSummary — default cap keeps the summary compact (<= 25 lines)', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 8 }, (_, i) => `## ${f}-Felt${i}\nverdi${i}`).join('\n\n');
}
const s = buildOrgSummary(all); // default cap
const lines = s.split('\n').filter(Boolean);
assert.ok(lines.length <= 25, `default cap should keep <=25 lines, got ${lines.length}`);
});
// ===========================================================================
// (C) Free-prose context (C2.2) — the optional sixth file (#3, acceptance K3)
// ===========================================================================
test('FREE_CONTEXT_FILE — optional free-prose filename, NOT part of ORG_FILES', () => {
assert.equal(FREE_CONTEXT_FILE, 'free-context.md');
assert.ok(
!ORG_FILES.includes(FREE_CONTEXT_FILE),
'free-context is optional and must not count toward onboarding completeness',
);
});
const FREE = [
'---',
'category: free-context',
'completed: true',
'last_updated: 2026-06-22',
'---',
'',
'# Fri kontekst',
'',
'## Fri kontekst',
'Vi migrerer fra on-prem til Azure i 2026.',
'',
].join('\n');
test('buildOrgSummary — surfaces free-context as a "Fri kontekst" field', () => {
const s = buildOrgSummary({ 'free-context.md': FREE });
assert.match(s, /Fri kontekst: Vi migrerer fra on-prem til Azure i 2026\./);
});
test('buildOrgSummary — free-context is robust to raw prose without any H2 (K3: no silent drop)', () => {
// The whole point of #3 is a single free field; it must surface even when the
// agent writes a plain paragraph with no markdown structure at all.
const s = buildOrgSummary({
'free-context.md': 'Vi har hatt en hendelse med Copilot som lekket interne dokumenter.',
});
assert.match(s, /Fri kontekst: Vi har hatt en hendelse med Copilot/);
});
test('buildOrgSummary — free-context strips frontmatter and all header lines', () => {
const s = buildOrgSummary({
'free-context.md': '---\ncategory: free-context\n---\n# Fri kontekst\n## Fri kontekst\nKun prosaen skal vises.\n',
});
assert.doesNotMatch(s, /category:/, 'frontmatter stripped');
assert.equal(s, 'Fri kontekst: Kun prosaen skal vises.');
});
test('buildOrgSummary — free-context appears AFTER the structured fields', () => {
const out = buildOrgSummary({
'free-context.md': '## Fri kontekst\nTilleggsnotat.',
'organization-profile.md': PROFILE,
});
assert.ok(
out.indexOf('Sektortype') < out.indexOf('Fri kontekst'),
'structured context leads; free prose trails',
);
});
test('buildOrgSummary — long free-context is capped with an ellipsis (budget guard)', () => {
const s = buildOrgSummary({ 'free-context.md': 'y'.repeat(500) }, { maxTotalLen: 80 });
const line = s.split('\n').find((l) => l.startsWith('Fri kontekst:'));
assert.ok(line, 'free-context line present');
assert.match(line, /…$/, 'truncation marker appended');
assert.ok(line.length <= 'Fri kontekst: '.length + 80, `line too long: ${line.length}`);
});
test('buildOrgSummary — empty/whitespace/header-only free-context yields no field', () => {
assert.equal(buildOrgSummary({ 'free-context.md': ' \n\n' }), '');
assert.equal(
buildOrgSummary({ 'free-context.md': '---\ncategory: free-context\n---\n# Fri kontekst\n' }),
'',
'frontmatter + bare H1 (no prose) => nothing',
);
});
test('buildOrgSummary — free-context absent leaves the structured summary unchanged (back-compat)', () => {
const s = buildOrgSummary({ 'organization-profile.md': PROFILE });
assert.doesNotMatch(s, /Fri kontekst/);
});
test('buildOrgSummary — realistic full onboarding keeps free-context visible (K3)', () => {
const all = {};
for (const f of ORG_FILES) all[f] = `## ${f}-felt\nverdi for ${f}`;
all['free-context.md'] = '## Fri kontekst\nViktig tilleggskontekst som må være synlig.';
const s = buildOrgSummary(all); // default cap 25
assert.match(s, /Fri kontekst: Viktig tilleggskontekst/, 'free-context visible within default budget');
});
// ===========================================================================
// (D) Reduksjon — the total value budget replaces the per-field cap, and the
// file set is no longer frozen to ORG_FILES (measured 2026-08-26: a 160-
// char per-field cap threw away 47 % of a 300-char answer and 92 % of a
// 2000-char one while most of the budget stood unspent, and a seventh file
// in org/ never reached the model at all).
// ===========================================================================
test('buildOrgSummary — a long field survives whole while the total budget is unspent', () => {
const long = 'x'.repeat(1500);
const s = buildOrgSummary({
'organization-profile.md': '## Sektor\noffentlig',
'free-context.md': long,
});
assert.ok(s.includes(long), 'a single long answer must survive uncut when the budget allows it');
});
test('buildOrgSummary — unspent budget is redistributed, so every field beats the old 160 cap', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\n${'x'.repeat(1000)}`).join('\n\n');
}
const s = buildOrgSummary(all); // 20 fields, default budget 4000 => 200 each
const values = s.split('\n').map((l) => l.slice(l.indexOf(': ') + 2));
for (const v of values) {
assert.ok(v.length >= 200, `fair share of 4000 over 20 fields is 200, got ${v.length}`);
}
});
test('buildOrgSummary — the total value budget is enforced across all fields', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\n${'x'.repeat(5000)}`).join('\n\n');
}
const s = buildOrgSummary(all);
const total = s
.split('\n')
.reduce((sum, l) => sum + l.slice(l.indexOf(': ') + 2).length, 0);
assert.ok(total <= 4000, `total value chars must stay within the 4000 budget, got ${total}`);
});
test('buildOrgSummary — a short field is never padded away from its own length', () => {
const s = buildOrgSummary({
'organization-profile.md': '## Sektor\noffentlig',
'technology-stack.md': `## Stack\n${'x'.repeat(9000)}`,
});
assert.match(s, /^Sektor: offentlig$/m, 'a short field is kept whole, never trimmed to its share');
});
test('buildOrgSummary — maxTotalLen is overridable', () => {
const s = buildOrgSummary({ 'organization-profile.md': `## Sektor\n${'x'.repeat(500)}` }, { maxTotalLen: 50 });
const value = s.slice(s.indexOf(': ') + 2);
assert.equal(value.length, 50, `explicit budget must bind, got ${value.length}`);
});
test('buildOrgSummary — an extra .md file beyond ORG_FILES contributes its sections', () => {
const s = buildOrgSummary({ 'kundeportefolje.md': '## Kunder\nStore offentlige etater' });
assert.match(s, /Kunder: Store offentlige etater/);
});
test('buildOrgSummary — extra files come after ORG_FILES, before free-context, sorted by name', () => {
const s = buildOrgSummary({
'zeta.md': '## Zeta\nz',
'alfa.md': '## Alfa\na',
'organization-profile.md': '## Sektor\noffentlig',
'free-context.md': 'fritekst',
});
assert.deepEqual(
s.split('\n').map((l) => l.slice(0, l.indexOf(':'))),
['Sektor', 'Alfa', 'Zeta', 'Fri kontekst'],
);
});
test('orderOrgFiles — ORG_FILES first in canonical order, then extra .md sorted by name', () => {
assert.deepEqual(
orderOrgFiles(['zeta.md', 'technology-stack.md', 'alfa.md', 'organization-profile.md']),
['organization-profile.md', 'technology-stack.md', 'alfa.md', 'zeta.md'],
);
});
test('orderOrgFiles — free-context is excluded (callers append it last, labelled)', () => {
assert.deepEqual(orderOrgFiles([FREE_CONTEXT_FILE, 'alfa.md']), ['alfa.md']);
});
test('orderOrgFiles — non-.md entries and garbage input are dropped', () => {
assert.deepEqual(orderOrgFiles(['notater.txt', '.DS_Store', 'alfa.md']), ['alfa.md']);
assert.deepEqual(orderOrgFiles(null), []);
});
test('buildOrgSummary — Fri kontekst survives the overflow cut (it is added last, not least)', () => {
const many = Array.from({ length: 40 }, (_, i) => `## Felt${i}\nverdi${i}`).join('\n\n');
const s = buildOrgSummary({
'organization-profile.md': many,
'free-context.md': 'Den ene sloten der brukeren skriver fritt.',
});
const lines = s.split('\n');
assert.ok(lines.length <= 25, `line budget still binds, got ${lines.length}`);
assert.match(s, /Fri kontekst: Den ene sloten/, 'free-context must not be the first field dropped');
assert.match(lines[lines.length - 1], /flere felt/, 'marker still last');
assert.match(lines[lines.length - 2], /^Fri kontekst:/, 'free-context kept as the last field');
});
test('buildOrgSummary — overflow without free-context is unchanged (back-compat)', () => {
const many = Array.from({ length: 40 }, (_, i) => `## Felt${i}\nverdi${i}`).join('\n\n');
const lines = buildOrgSummary({ 'organization-profile.md': many }, { cap: 10 }).split('\n');
assert.equal(lines.length, 10);
assert.equal(lines[0], 'Felt0: verdi0');
assert.equal(lines[8], 'Felt8: verdi8');
assert.match(lines[9], /flere felt/);
});
// ===========================================================================
// (E) Reduksjon ved SKALA — the unit of reduction is the DOCUMENT, not the
// field. Measured 2026-08-26 (bake-off arm A2b, flat org/, n=55 corpus
// docs): 61 files and 183 fields went in, 24 fields came out, and those 24
// came from 8 files — 53 files were invisible while 1013 of the 4000 value
// chars stood unspent. What remained was selection by FIXED ORDER, which is
// relevance-blind, and the summary is built ambiently at session start —
// before any question exists — so there is no relevance signal to rank
// against. The honest substitute for ranking is COVERAGE.
// ===========================================================================
test('buildOrgSummary — every file contributes at least one field when the budget allows it', () => {
const all = {};
for (let f = 0; f < 40; f++) {
all[`fil${String(f).padStart(2, '0')}.md`] = Array.from(
{ length: 3 },
(_, i) => `## F${f}H${i}\nverdi fra fil ${f} felt ${i}`,
).join('\n\n');
}
const s = buildOrgSummary(all);
const missing = [];
for (let f = 0; f < 40; f++) {
if (!new RegExp(`^F${f}H\\d: `, 'm').test(s)) missing.push(f);
}
assert.deepEqual(missing, [], `every file must reach the summary; ${missing.length}/40 did not`);
});
test('buildOrgSummary — selection is breadth-first across files, not fixed order within one', () => {
const all = {
'organization-profile.md': '## A0\na0\n\n## A1\na1\n\n## A2\na2',
'technology-stack.md': '## B0\nb0\n\n## B1\nb1\n\n## B2\nb2',
'security-compliance.md': '## C0\nc0\n\n## C1\nc1\n\n## C2\nc2',
};
// cap 4 => 3 field lines + marker. Fixed order would spend all three on the
// first file; coverage spends one on each.
const lines = buildOrgSummary(all, { cap: 4 }).split('\n');
assert.deepEqual(lines.slice(0, 3), ['A0: a0', 'B0: b0', 'C0: c0']);
assert.match(lines[3], /flere felt/);
});
test('buildOrgSummary — the overflow marker reports how many FILES were cut, not only fields', () => {
const all = {};
for (let f = 0; f < 200; f++) all[`fil${String(f).padStart(3, '0')}.md`] = `## H${f}\nverdi ${f}`;
const s = buildOrgSummary(all);
const marker = s.split('\n').at(-1);
assert.match(marker, /flere felt/, 'field residue still reported');
assert.match(marker, /\d+ filer/, 'file residue must be named — a silent drop is the defect');
});
test('buildOrgSummary — coverage yields to the char budget, never starving lines to fragments', () => {
const all = {};
for (let f = 0; f < 200; f++) all[`fil${String(f).padStart(3, '0')}.md`] = `## H${f}\n${'x'.repeat(400)}`;
const s = buildOrgSummary(all);
const values = s.split('\n').filter((l) => /^H\d+: /.test(l)).map((l) => l.slice(l.indexOf(': ') + 2));
assert.ok(values.length > 0, 'fields emitted');
for (const v of values) {
assert.ok(v.length >= 60, `a coverage line must stay readable, got ${v.length}`);
}
assert.ok(
values.reduce((a, v) => a + v.length, 0) <= 4000,
'the total value budget still binds at scale',
);
});
test('buildOrgSummary — the designed onboarding size is untouched by the coverage rule', () => {
const all = {};
for (const f of ORG_FILES) {
all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\nverdi${i}`).join('\n\n');
}
const lines = buildOrgSummary(all).split('\n');
assert.equal(lines.length, 20, '20 fields over 5 files fit; no marker, no reordering');
assert.equal(lines[0], 'organization-profile.md-Felt0: verdi0');
assert.equal(lines[3], 'organization-profile.md-Felt3: verdi3');
});
test('buildOrgSummary — Fri kontekst still survives the cut at scale', () => {
const all = {};
for (let f = 0; f < 40; f++) all[`fil${String(f).padStart(2, '0')}.md`] = `## H${f}\nverdi ${f}`;
all['free-context.md'] = 'Den ene sloten der brukeren skriver fritt.';
const s = buildOrgSummary(all, { cap: 10 });
assert.match(s, /Fri kontekst: Den ene sloten/, 'free-context must not be the first field dropped');
});