import { describe, expect, it } from 'vitest'; import { abandonPenalty, abandonPenaltyPct, ABANDON_PENALTY_MIN, ABANDON_PENALTY_SPAN, elapsedFractionFor } from './penalty'; // The v0.12.0 abandon penalty: 15% of the payout right after creation, scaling // linearly to 60% at the deadline; expired packages cost nothing. Pure math — // the server action and the client preview both call these same functions. const T0 = 1_750_000_000_000; const WINDOW_MS = 10 * 3_600_000; // created → deadline span const REWARD = 1_000; const pkg = (status: string, over: Partial<{ reward: number; createdSimTime: number; deadlineSimTime: number }> = {}) => ({ status, reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS, ...over, }); describe('abandonPenaltyPct', () => { it('is exactly 15% at the start of the window (fraction 0)', () => { expect(abandonPenaltyPct(0)).toBeCloseTo(ABANDON_PENALTY_MIN, 9); }); it('is exactly 60% at the deadline (fraction 1)', () => { expect(abandonPenaltyPct(1)).toBeCloseTo(ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN, 9); }); it('clamps out-of-range fractions to [0, 1]', () => { expect(abandonPenaltyPct(-5)).toBeCloseTo(ABANDON_PENALTY_MIN, 9); expect(abandonPenaltyPct(2)).toBeCloseTo(ABANDON_PENALTY_MIN + ABANDON_PENALTY_SPAN, 9); }); it('is monotonic non-decreasing across the window', () => { let prev = -1; for (let i = 0; i <= 100; i++) { const pct = abandonPenaltyPct(i / 100); expect(pct).toBeGreaterThanOrEqual(prev); prev = pct; } }); }); describe('elapsedFractionFor', () => { it('is 0 at creation, 0.5 at the midpoint, 1 at and past the deadline', () => { expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0)).toBe(0); expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS / 2)).toBeCloseTo(0.5, 9); expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS)).toBe(1); expect(elapsedFractionFor(T0, T0 + WINDOW_MS, T0 + WINDOW_MS * 3)).toBe(1); // clamped }); it('treats a zero/negative span as fully elapsed (max penalty)', () => { expect(elapsedFractionFor(T0, T0, T0)).toBe(1); expect(elapsedFractionFor(T0 + 5, T0, T0 + 5)).toBe(1); }); }); describe('abandonPenalty', () => { it('charges exactly 15% of the payout right after creation', () => { expect(abandonPenalty(pkg('loaded'), T0)).toBe(Math.round(REWARD * 0.15 * 100) / 100); }); it('scales to exactly 37.5% at the midpoint and 60% at/past the deadline', () => { expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS / 2)).toBe(Math.round(REWARD * 0.375 * 100) / 100); expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS)).toBe(Math.round(REWARD * 0.6 * 100) / 100); expect(abandonPenalty(pkg('loaded'), T0 + WINDOW_MS * 2)).toBe(Math.round(REWARD * 0.6 * 100) / 100); // clamped }); it('is free for an already-expired package regardless of the clock', () => { expect(abandonPenalty(pkg('expired'), T0)).toBe(0); expect(abandonPenalty(pkg('expired'), T0 + WINDOW_MS * 5)).toBe(0); }); it('rounds to cents and scales with the reward, not a fixed amount', () => { // €999.99 × 15% = 149.9985 → 150.00; €333 × 60% = 199.8 exactly. expect(abandonPenalty(pkg('loaded', { reward: 999.99 }), T0)).toBe(150); expect(abandonPenalty(pkg('loaded', { reward: 333 }), T0 + WINDOW_MS)).toBe(199.8); }); });