Files
pn-new-crm/tests/unit/services/berth-recommender.test.ts
Matt Ciaccio b1e787e55c feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.

Tier ladder (default):
  A : no interest history
  B : lost-only history (still recommendable + boosted by heat)
  C : active interest in early stage (open..eoi_signed)
  D : active interest at deposit_10pct or beyond (hidden by default)

Heat (only for tier B):
  recency        weight 30  full @ <=30 days, decays to 0 @ 365 days
  furthest stage weight 40  full when prior reached deposit
  interest count weight 15  saturates at 5+
  EOI count      weight 15  saturates at 3+

Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.

15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00

187 lines
4.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
classifyTier,
computeHeat,
DEFAULT_RECOMMENDER_SETTINGS,
} from '@/lib/services/berth-recommender.service';
describe('classifyTier', () => {
it('"A" when there is no interest history at all', () => {
expect(classifyTier({ activeInterestCount: 0, lostCount: 0, maxActiveStage: 0 })).toBe('A');
});
it('"B" when only lost interests exist (no active)', () => {
expect(classifyTier({ activeInterestCount: 0, lostCount: 2, maxActiveStage: 0 })).toBe('B');
});
it('"C" when an active interest is in an early stage', () => {
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 3 })).toBe('C');
});
it('"C" even when a prior interest was lost, if there is an active one', () => {
expect(classifyTier({ activeInterestCount: 1, lostCount: 5, maxActiveStage: 2 })).toBe('C');
});
it('"D" when an active interest is at deposit or beyond', () => {
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 6 })).toBe('D');
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 8 })).toBe('D');
});
it('still "C" at eoi_signed (stage 5) - tier D only kicks in at deposit', () => {
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 5 })).toBe('C');
});
});
describe('computeHeat', () => {
const w = DEFAULT_RECOMMENDER_SETTINGS;
const NOW = new Date('2026-05-05T00:00:00Z');
it('zero heat when nothing in history', () => {
const h = computeHeat(
{
latestFallthroughAt: null,
totalInterestCount: 0,
eoiSignedCount: 0,
fallthroughMaxStage: 0,
},
w,
NOW,
);
expect(h.total).toBe(0);
});
it('full recency for a fall-through within the last 30 days', () => {
const h = computeHeat(
{
latestFallthroughAt: new Date('2026-04-25T00:00:00Z'),
totalInterestCount: 0,
eoiSignedCount: 0,
fallthroughMaxStage: 1,
},
w,
NOW,
);
// recency component should be the full heat_weight_recency (30)
expect(h.recency).toBeCloseTo(30, 1);
});
it('zero recency for an ancient fall-through (>1 year)', () => {
const h = computeHeat(
{
latestFallthroughAt: new Date('2024-01-01T00:00:00Z'),
totalInterestCount: 0,
eoiSignedCount: 0,
fallthroughMaxStage: 1,
},
w,
NOW,
);
expect(h.recency).toBe(0);
});
it('full furthest-stage when the fall-through reached deposit', () => {
const h = computeHeat(
{
latestFallthroughAt: null,
totalInterestCount: 0,
eoiSignedCount: 0,
fallthroughMaxStage: 6, // deposit_10pct
},
w,
NOW,
);
expect(h.furthestStage).toBeCloseTo(40, 1);
});
it('saturates interest-count at 5+', () => {
const h = computeHeat(
{
latestFallthroughAt: null,
totalInterestCount: 10,
eoiSignedCount: 0,
fallthroughMaxStage: 0,
},
w,
NOW,
);
expect(h.interestCount).toBeCloseTo(15, 1); // full weight
});
it('saturates EOI-count at 3+', () => {
const h = computeHeat(
{
latestFallthroughAt: null,
totalInterestCount: 0,
eoiSignedCount: 5,
fallthroughMaxStage: 0,
},
w,
NOW,
);
expect(h.eoiCount).toBeCloseTo(15, 1);
});
it('total ≈ 100 when everything is maxed', () => {
const h = computeHeat(
{
latestFallthroughAt: new Date('2026-04-25T00:00:00Z'),
totalInterestCount: 5,
eoiSignedCount: 3,
fallthroughMaxStage: 6,
},
w,
NOW,
);
expect(h.total).toBeGreaterThanOrEqual(99);
expect(h.total).toBeLessThanOrEqual(100);
});
it('respects tunable per-port weights (skewed toward recency)', () => {
const skewed = {
heatWeightRecency: 100,
heatWeightFurthestStage: 0,
heatWeightInterestCount: 0,
heatWeightEoiCount: 0,
};
const recent = computeHeat(
{
latestFallthroughAt: new Date('2026-04-25T00:00:00Z'),
totalInterestCount: 0,
eoiSignedCount: 0,
fallthroughMaxStage: 0,
},
skewed,
NOW,
);
expect(recent.total).toBeCloseTo(100, 1);
const old = computeHeat(
{
latestFallthroughAt: new Date('2024-01-01T00:00:00Z'),
totalInterestCount: 5,
eoiSignedCount: 3,
fallthroughMaxStage: 6,
},
skewed,
NOW,
);
expect(old.total).toBe(0);
});
it('zero-weights guard (no division-by-zero blow-up)', () => {
const zeros = {
heatWeightRecency: 0,
heatWeightFurthestStage: 0,
heatWeightInterestCount: 0,
heatWeightEoiCount: 0,
};
const h = computeHeat(
{
latestFallthroughAt: new Date(),
totalInterestCount: 5,
eoiSignedCount: 3,
fallthroughMaxStage: 6,
},
zeros,
NOW,
);
expect(h.total).toBe(0);
});
});