← back to Rentv Adintel

test/scoring.test.js

128 lines

'use strict';
/**
 * Tests for the Advertising Opportunity Score (spec §13).
 * Pure functions only — no DB, no network. Runs under the built-in
 * `node --test` runner (no jest/vitest/mocha).
 */

const test = require('node:test');
const assert = require('node:assert');

const {
  calculateAdvertiserOpportunityScore,
  explainScore,
  DEFAULT_WEIGHTS,
  clampScore,
  FACTOR_KEYS,
} = require('../lib/scoring');

// The ten factors the §13 formula weights.
const FULL_INPUT = {
  verifiedAdvertising: 100,
  verifiedConferenceSpendSignal: 100,
  recency: 100,
  repeatActivity: 100,
  californiaFit: 100,
  arizonaFit: 100,
  categoryFit: 100,
  rentvAudienceFit: 100,
  contactCompleteness: 100,
  evidenceQuality: 100,
};

test('all-100 input scores exactly 100 (weights sum to 1.0)', () => {
  assert.strictEqual(calculateAdvertiserOpportunityScore(FULL_INPUT), 100);
});

test('all-zero input scores exactly 0', () => {
  const zeroed = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
  assert.strictEqual(calculateAdvertiserOpportunityScore(zeroed), 0);
});

test('known input maps to the known §13 output', () => {
  // Verify the exact §13 spec formula by computing it independently.
  const input = {
    verifiedAdvertising: 80, // *0.22 = 17.6
    verifiedConferenceSpendSignal: 60, // *0.15 = 9.0
    recency: 90, // *0.12 = 10.8
    repeatActivity: 50, // *0.10 = 5.0
    californiaFit: 100, // *0.10 = 10.0
    arizonaFit: 0, // *0.05 = 0.0
    categoryFit: 70, // *0.08 = 5.6
    rentvAudienceFit: 40, // *0.08 = 3.2
    contactCompleteness: 100, // *0.04 = 4.0
    evidenceQuality: 90, // *0.06 = 5.4
  };
  // sum = 70.6 → round → 71
  assert.strictEqual(calculateAdvertiserOpportunityScore(input), 71);
});

test('DEFAULT_WEIGHTS sum to ~1.0', () => {
  const sum = Object.values(DEFAULT_WEIGHTS).reduce((a, b) => a + b, 0);
  assert.ok(Math.abs(sum - 1.0) < 1e-9, `weights sum to ${sum}, expected 1.0`);
});

test('FACTOR_KEYS covers every weighted factor (and nothing else)', () => {
  assert.strictEqual(FACTOR_KEYS.length, 10);
  assert.deepStrictEqual([...FACTOR_KEYS].sort(), Object.keys(DEFAULT_WEIGHTS).sort());
});

test('clampScore bounds values into [0,100]', () => {
  assert.strictEqual(clampScore(-50), 0);
  assert.strictEqual(clampScore(0), 0);
  assert.strictEqual(clampScore(50), 50);
  assert.strictEqual(clampScore(100), 100);
  assert.strictEqual(clampScore(150), 100);
  assert.strictEqual(clampScore(NaN), 0);
  assert.strictEqual(clampScore(undefined), 0);
});

test('out-of-range factor inputs are clamped before weighting', () => {
  const over = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 999]));
  assert.strictEqual(calculateAdvertiserOpportunityScore(over), 100);
  const under = Object.fromEntries(FACTOR_KEYS.map((k) => [k, -999]));
  assert.strictEqual(calculateAdvertiserOpportunityScore(under), 0);
});

test('admin weight overrides are applied', () => {
  // Put ALL weight on one factor; only that factor should matter.
  const weights = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
  weights.recency = 1.0;
  const input = Object.fromEntries(FACTOR_KEYS.map((k) => [k, 0]));
  input.recency = 77;
  assert.strictEqual(calculateAdvertiserOpportunityScore(input, weights), 77);
});

test('explainScore returns a per-factor contribution for "Show Me Why"', () => {
  const out = explainScore(FULL_INPUT);
  assert.strictEqual(out.score, 100);
  assert.strictEqual(out.factors.length, 10);
  for (const f of out.factors) {
    assert.ok(FACTOR_KEYS.includes(f.factor));
    assert.strictEqual(typeof f.value, 'number');
    assert.strictEqual(typeof f.weight, 'number');
    assert.strictEqual(typeof f.contribution, 'number');
  }
});

test('explainScore contributions sum to the score (±1 for rounding)', () => {
  const input = {
    verifiedAdvertising: 83,
    verifiedConferenceSpendSignal: 41,
    recency: 77,
    repeatActivity: 12,
    californiaFit: 95,
    arizonaFit: 8,
    categoryFit: 66,
    rentvAudienceFit: 54,
    contactCompleteness: 30,
    evidenceQuality: 71,
  };
  const out = explainScore(input);
  const sum = out.factors.reduce((a, f) => a + f.contribution, 0);
  assert.ok(
    Math.abs(sum - out.score) <= 1,
    `contributions ${sum} should be within 1 of score ${out.score}`
  );
});