// distribution-stats.test.mjs — Tests for scanners/lib/distribution-stats.mjs // Zero external dependencies: node:test + node:assert only. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { jensenShannonDivergence, buildDistribution } from '../../scanners/lib/distribution-stats.mjs'; // --------------------------------------------------------------------------- // buildDistribution // --------------------------------------------------------------------------- describe('distribution-stats — buildDistribution', () => { it('empty array → empty map', () => { const d = buildDistribution([]); assert.equal(d.size, 0); }); it('single category normalizes to 1.0', () => { const d = buildDistribution(['Read', 'Read', 'Read']); assert.equal(d.size, 1); assert.equal(d.get('Read'), 1.0); }); it('two equal categories normalize to 0.5 each', () => { const d = buildDistribution(['Read', 'Bash', 'Read', 'Bash']); assert.equal(d.size, 2); assert.equal(d.get('Read'), 0.5); assert.equal(d.get('Bash'), 0.5); }); it('unequal distribution normalizes correctly', () => { const d = buildDistribution(['Read', 'Read', 'Read', 'Bash']); assert.equal(d.get('Read'), 0.75); assert.equal(d.get('Bash'), 0.25); }); it('sum of probabilities equals 1.0', () => { const d = buildDistribution(['Read', 'Bash', 'Write', 'Grep', 'Bash']); let sum = 0; for (const v of d.values()) sum += v; assert.ok(Math.abs(sum - 1.0) < 1e-10, `Sum ${sum} should be ~1.0`); }); }); // --------------------------------------------------------------------------- // jensenShannonDivergence // --------------------------------------------------------------------------- describe('distribution-stats — jensenShannonDivergence', () => { it('identical distributions → JSD = 0', () => { const P = buildDistribution(['Read', 'Bash', 'Read', 'Bash']); const Q = buildDistribution(['Read', 'Bash', 'Read', 'Bash']); const jsd = jensenShannonDivergence(P, Q); assert.ok(Math.abs(jsd) < 1e-10, `JSD ${jsd} should be ~0`); }); it('fully disjoint distributions → JSD = 1', () => { const P = buildDistribution(['Read', 'Read', 'Read']); const Q = buildDistribution(['Bash', 'Bash', 'Bash']); const jsd = jensenShannonDivergence(P, Q); assert.ok(Math.abs(jsd - 1.0) < 1e-10, `JSD ${jsd} should be ~1.0`); }); it('partially overlapping distributions → 0 < JSD < 1', () => { const P = buildDistribution(['Read', 'Read', 'Bash']); const Q = buildDistribution(['Read', 'Bash', 'Bash']); const jsd = jensenShannonDivergence(P, Q); assert.ok(jsd > 0, `JSD ${jsd} should be > 0`); assert.ok(jsd < 1, `JSD ${jsd} should be < 1`); }); it('JSD is symmetric: JSD(P,Q) = JSD(Q,P)', () => { const P = buildDistribution(['Read', 'Read', 'Read', 'Bash']); const Q = buildDistribution(['Read', 'Bash', 'Bash', 'Bash']); const jsd1 = jensenShannonDivergence(P, Q); const jsd2 = jensenShannonDivergence(Q, P); assert.ok(Math.abs(jsd1 - jsd2) < 1e-10, `JSD(P,Q)=${jsd1} should equal JSD(Q,P)=${jsd2}`); }); it('two empty distributions → JSD = 0', () => { const P = new Map(); const Q = new Map(); const jsd = jensenShannonDivergence(P, Q); assert.equal(jsd, 0); }); it('one empty + one non-empty → JSD = 0.5', () => { const P = buildDistribution(['Read']); const Q = new Map(); const jsd = jensenShannonDivergence(P, Q); assert.ok(Math.abs(jsd - 0.5) < 1e-10, `JSD ${jsd} should be 0.5`); }); it('three categories with different distributions', () => { const P = buildDistribution(['Read', 'Read', 'Read', 'Write', 'Write', 'Bash']); const Q = buildDistribution(['Read', 'Write', 'Write', 'Write', 'Bash', 'Bash']); const jsd = jensenShannonDivergence(P, Q); assert.ok(jsd > 0, `JSD ${jsd} should be > 0`); assert.ok(jsd < 1, `JSD ${jsd} should be < 1`); }); it('diverse vs concentrated → high JSD', () => { const P = buildDistribution(['Read', 'Write', 'Bash', 'Grep', 'Glob']); const Q = buildDistribution(['Read', 'Read', 'Read', 'Read', 'Read']); const jsd = jensenShannonDivergence(P, Q); assert.ok(jsd > 0.3, `JSD ${jsd} should be > 0.3 for diverse vs concentrated`); }); });