// 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, resolveUserDataDir, resolveOrgDir, resolveConfigPath, buildOrgSummary, } 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', 'Statens vegvesen — nasjonal veiforvaltning', '', '## 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: Statens vegvesen/); 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 — unknown filenames are ignored (only ORG_FILES contribute)', () => { const s = buildOrgSummary({ 'random.md': '## Hemmelig\nverdi' }); assert.equal(s, ''); }); 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` }, { maxValueLen: 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}`); });