342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
|
|
import { optimizeTour } from './optimize-tour';
|
|
import type { OptimizerCandidate } from './types';
|
|
|
|
const BERKELEY = { slug: 'berkeley', lat: 37.8716, lon: -122.2727 };
|
|
|
|
function cand(p: Partial<OptimizerCandidate> & { slug: string; lat: number; lon: number; score: number }): OptimizerCandidate {
|
|
return {
|
|
slug: p.slug,
|
|
city: p.city ?? p.slug,
|
|
country: p.country ?? 'US',
|
|
isoCountryCode: p.isoCountryCode ?? 'US',
|
|
lat: p.lat,
|
|
lon: p.lon,
|
|
wealthScore: p.wealthScore ?? 50,
|
|
marketSaturationScore: p.marketSaturationScore ?? 50,
|
|
sexPositiveScore: p.sexPositiveScore ?? 50,
|
|
swLegalTier: p.swLegalTier ?? 'criminalized-buyer',
|
|
personalFitScore: p.personalFitScore ?? 50,
|
|
lastVisitAt: p.lastVisitAt ?? null,
|
|
lastRevenuePerDayUsd: p.lastRevenuePerDayUsd ?? null,
|
|
clearsBar: p.clearsBar ?? true,
|
|
gradeStepDelta: p.gradeStepDelta ?? 0,
|
|
computedOpportunityScore: p.score,
|
|
};
|
|
}
|
|
|
|
const WINDOW_30D = { start: '2026-06-01', end: '2026-06-30' };
|
|
|
|
describe('optimizeTour — input validation', () => {
|
|
it('throws when homebase has non-finite coords', async () => {
|
|
const promise = optimizeTour({
|
|
candidates: [],
|
|
homebase: { slug: 'broken', lat: Number.NaN, lon: -122 },
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
});
|
|
await expect(promise).rejects.toThrow(/broken/);
|
|
});
|
|
|
|
it('throws when window dates are malformed', async () => {
|
|
const promise = optimizeTour({
|
|
candidates: [],
|
|
homebase: BERKELEY,
|
|
window: { start: 'not-a-date', end: '2026-06-30' },
|
|
strategy: 'max-exposure',
|
|
});
|
|
await expect(promise).rejects.toThrow(/invalid window/);
|
|
});
|
|
|
|
it('wraps internal errors in a descriptive top-level error', async () => {
|
|
const promise = optimizeTour({
|
|
candidates: [],
|
|
homebase: { slug: 'broken', lat: Number.NaN, lon: Number.NaN },
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
});
|
|
await expect(promise).rejects.toThrow(/optimizeTour failed/);
|
|
});
|
|
});
|
|
|
|
describe('optimizeTour — empty / degenerate', () => {
|
|
it('returns empty itinerary when candidate list is empty', async () => {
|
|
const r = await optimizeTour({
|
|
candidates: [],
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
refinementBudgetMs: 50,
|
|
});
|
|
expect(r.visits).toEqual([]);
|
|
expect(r.totalPrize).toBe(0);
|
|
expect(r.totalTravelHours).toBe(0);
|
|
expect(r.skipped).toEqual([]);
|
|
expect(r.strategy).toBe('max-exposure');
|
|
});
|
|
|
|
it('routes a single candidate as a round-trip', async () => {
|
|
const r = await optimizeTour({
|
|
candidates: [cand({ slug: 'sf', lat: 37.7879, lon: -122.4075, score: 50 })],
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
refinementBudgetMs: 50,
|
|
});
|
|
expect(r.visits.length).toBe(1);
|
|
expect(r.visits[0]!.slug).toBe('sf');
|
|
expect(r.totalTravelHours).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('optimizeTour — skip reasons', () => {
|
|
it('candidates without coords land in skipped (low-value)', async () => {
|
|
const candidates: OptimizerCandidate[] = [
|
|
cand({ slug: 'sf', lat: 37.7879, lon: -122.4075, score: 50 }),
|
|
cand({ slug: 'ghost', lat: Number.NaN, lon: Number.NaN, score: 99 }),
|
|
];
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
refinementBudgetMs: 50,
|
|
});
|
|
const ghost = r.skipped.find((s) => s.slug === 'ghost');
|
|
expect(ghost?.reason).toBe('low-value');
|
|
});
|
|
|
|
it('strategy-filtered candidates appear in skipped with reason strategy-filter', async () => {
|
|
const r = await optimizeTour({
|
|
candidates: [
|
|
cand({ slug: 'dubai', lat: 25.26, lon: 55.29, score: 90, swLegalTier: 'criminalized-full', isoCountryCode: 'AE' }),
|
|
],
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'permissive-only',
|
|
refinementBudgetMs: 50,
|
|
});
|
|
expect(r.visits).toEqual([]);
|
|
expect(r.skipped.find((s) => s.slug === 'dubai')?.reason).toBe('strategy-filter');
|
|
});
|
|
|
|
it('over-maxStops candidates carry that reason, not no-time', async () => {
|
|
const candidates: OptimizerCandidate[] = [
|
|
cand({ slug: 'a', lat: 37.78, lon: -122.4, score: 90 }),
|
|
cand({ slug: 'b', lat: 37.34, lon: -121.9, score: 85 }),
|
|
cand({ slug: 'c', lat: 37.65, lon: -122.1, score: 70 }),
|
|
];
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
maxStops: 1,
|
|
refinementBudgetMs: 50,
|
|
});
|
|
expect(r.visits.length).toBe(1);
|
|
const reasons = new Set(r.skipped.map((s) => s.reason));
|
|
expect(reasons.has('over-max-stops')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('optimizeTour — date arithmetic', () => {
|
|
it('arrive dates accumulate forward from window.start', async () => {
|
|
const candidates: OptimizerCandidate[] = [
|
|
cand({ slug: 'sf', lat: 37.7879, lon: -122.4075, score: 50 }),
|
|
cand({ slug: 'la', lat: 34.0522, lon: -118.2437, score: 80 }),
|
|
];
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
maxStops: 2,
|
|
minDwellNights: 2,
|
|
refinementBudgetMs: 50,
|
|
});
|
|
expect(r.visits.length).toBe(2);
|
|
const first = r.visits[0]!;
|
|
const second = r.visits[1]!;
|
|
expect(first.arrive >= '2026-06-01').toBe(true);
|
|
expect(first.depart > first.arrive).toBe(true);
|
|
expect(second.arrive >= first.depart).toBe(true);
|
|
});
|
|
|
|
it('depart - arrive is at least minDwellNights days', async () => {
|
|
const r = await optimizeTour({
|
|
candidates: [cand({ slug: 'sf', lat: 37.7879, lon: -122.4075, score: 50 })],
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
minDwellNights: 5,
|
|
refinementBudgetMs: 50,
|
|
});
|
|
const v = r.visits[0]!;
|
|
const arriveMs = Date.parse(v.arrive + 'T00:00:00Z');
|
|
const departMs = Date.parse(v.depart + 'T00:00:00Z');
|
|
const diffDays = (departMs - arriveMs) / (1000 * 60 * 60 * 24);
|
|
expect(diffDays).toBeGreaterThanOrEqual(5);
|
|
});
|
|
|
|
it('itinerary fits within the window when budget allows', async () => {
|
|
const candidates: OptimizerCandidate[] = [
|
|
cand({ slug: 'sf', lat: 37.7879, lon: -122.4075, score: 50 }),
|
|
cand({ slug: 'la', lat: 34.0522, lon: -118.2437, score: 80 }),
|
|
cand({ slug: 'vegas', lat: 36.1716, lon: -115.1391, score: 75 }),
|
|
];
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
maxStops: 3,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
for (const v of r.visits) {
|
|
expect(v.depart <= '2026-06-30').toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('optimizeTour — strategy semantics over real-shaped data', () => {
|
|
const REGIONS: OptimizerCandidate[] = [
|
|
cand({ slug: 'sf', lat: 37.7879, lon: -122.4075, score: 70, wealthScore: 85, swLegalTier: 'criminalized-buyer', clearsBar: true }),
|
|
cand({ slug: 'cinci', lat: 39.1031, lon: -84.512, score: 55, wealthScore: 50, swLegalTier: 'criminalized-full', clearsBar: true, gradeStepDelta: 3, lastRevenuePerDayUsd: 1800 }),
|
|
cand({ slug: 'dubai', lat: 25.26, lon: 55.29, score: 80, wealthScore: 95, swLegalTier: 'criminalized-full', isoCountryCode: 'AE', clearsBar: false }),
|
|
cand({ slug: 'amsterdam', lat: 52.37, lon: 4.89, score: 65, wealthScore: 85, swLegalTier: 'legal-regulated', isoCountryCode: 'NL', clearsBar: true }),
|
|
];
|
|
|
|
it('permissive-only never selects criminalized-full cities', async () => {
|
|
const r = await optimizeTour({
|
|
candidates: REGIONS,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'permissive-only',
|
|
maxStops: 4,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
for (const v of r.visits) {
|
|
expect(['dubai', 'cinci']).not.toContain(v.slug);
|
|
}
|
|
});
|
|
|
|
it('niche-dominance picks cinci (delta=3) over the others', async () => {
|
|
const r = await optimizeTour({
|
|
candidates: REGIONS,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'niche-dominance',
|
|
maxStops: 4,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
expect(r.visits.map((v) => v.slug)).toContain('cinci');
|
|
// The other cities lack gradeStepDelta ≥ 1 / clearsBar=true → filtered out.
|
|
const filtered = r.skipped.filter((s) => s.reason === 'strategy-filter').map((s) => s.slug);
|
|
expect(filtered).toContain('dubai');
|
|
});
|
|
|
|
it('max-margin biases towards revenue-bearing cinci', async () => {
|
|
const r = await optimizeTour({
|
|
candidates: REGIONS,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-margin',
|
|
maxStops: 2,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
expect(r.visits.map((v) => v.slug)).toContain('cinci');
|
|
// dubai fails clears_bar filter
|
|
expect(r.skipped.find((s) => s.slug === 'dubai')?.reason).toBe('strategy-filter');
|
|
});
|
|
});
|
|
|
|
describe('optimizeTour — pinning', () => {
|
|
it('honours multiple forceInclude entries', async () => {
|
|
const candidates: OptimizerCandidate[] = [
|
|
cand({ slug: 'a', lat: 37.78, lon: -122.4, score: 5 }),
|
|
cand({ slug: 'b', lat: 34.05, lon: -118.24, score: 5 }),
|
|
cand({ slug: 'c', lat: 36.17, lon: -115.13, score: 90 }),
|
|
];
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'expand-from-anchor',
|
|
forceInclude: ['a', 'b'],
|
|
maxStops: 5,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
const slugs = new Set(r.visits.map((v) => v.slug));
|
|
expect(slugs.has('a')).toBe(true);
|
|
expect(slugs.has('b')).toBe(true);
|
|
const pinned = r.visits.filter((v) => v.pinned).map((v) => v.slug);
|
|
expect(new Set(pinned)).toEqual(new Set(['a', 'b']));
|
|
});
|
|
|
|
it('pinned flag is false for non-pinned visits', async () => {
|
|
const candidates: OptimizerCandidate[] = [
|
|
cand({ slug: 'a', lat: 37.78, lon: -122.4, score: 90 }),
|
|
cand({ slug: 'b', lat: 34.05, lon: -118.24, score: 50 }),
|
|
];
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'edit-existing',
|
|
forceInclude: ['b'],
|
|
maxStops: 5,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
const a = r.visits.find((v) => v.slug === 'a');
|
|
expect(a?.pinned).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('optimizeTour — totals invariants', () => {
|
|
const candidates: OptimizerCandidate[] = [
|
|
cand({ slug: 'sf', lat: 37.7879, lon: -122.4075, score: 50 }),
|
|
cand({ slug: 'la', lat: 34.0522, lon: -118.2437, score: 80 }),
|
|
cand({ slug: 'vegas', lat: 36.1716, lon: -115.1391, score: 75 }),
|
|
];
|
|
|
|
it('totalPrize equals sum of visit prizes (within rounding)', async () => {
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
maxStops: 3,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
const sum = r.visits.reduce((s, v) => s + v.prize, 0);
|
|
expect(Math.abs(r.totalPrize - sum)).toBeLessThan(1.5);
|
|
});
|
|
|
|
it('totalTravelHours is non-negative and finite', async () => {
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
maxStops: 3,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
expect(r.totalTravelHours).toBeGreaterThanOrEqual(0);
|
|
expect(Number.isFinite(r.totalTravelHours)).toBe(true);
|
|
});
|
|
|
|
it('totalTravelCostUsd is non-negative and finite', async () => {
|
|
const r = await optimizeTour({
|
|
candidates,
|
|
homebase: BERKELEY,
|
|
window: WINDOW_30D,
|
|
strategy: 'max-exposure',
|
|
maxStops: 3,
|
|
refinementBudgetMs: 100,
|
|
});
|
|
expect(r.totalTravelCostUsd).toBeGreaterThanOrEqual(0);
|
|
expect(Number.isFinite(r.totalTravelCostUsd)).toBe(true);
|
|
});
|
|
});
|