80 lines
3.3 KiB
TypeScript
80 lines
3.3 KiB
TypeScript
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);
|
||
});
|
||
});
|