147 lines
6.9 KiB
TypeScript
147 lines
6.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
addStamp, createLoyaltyState, currentProgram, DEFAULT_TRACKS, generosity, issueCard,
|
|
joinProbability, returnVisitMultiplier, setProgram, StampTrack, validateTracks,
|
|
} from '../../src/sim/loyalty';
|
|
|
|
describe('loyalty', () => {
|
|
it('starts with 10 purchase stamps for a used game up to 10 €', () => {
|
|
const l = createLoyaltyState();
|
|
expect(currentProgram(l)).toEqual({
|
|
version: 1,
|
|
tracks: [{ stampType: 'buy_new', slots: 10, reward: { kind: 'free_used_game', maxValueCents: 1000 } }],
|
|
});
|
|
expect(generosity(currentProgram(l))).toBe(100);
|
|
expect(returnVisitMultiplier(currentProgram(l))).toBe(2);
|
|
expect(joinProbability(currentProgram(l))).toBeCloseTo(0.25);
|
|
});
|
|
|
|
it('issues numbered cards on the current program', () => {
|
|
const l = createLoyaltyState();
|
|
const card = issueCard(l);
|
|
expect(card).toEqual({ id: 'C0001', programVersion: 1, stamps: [0], pendingRewards: [], voucherCents: 0 });
|
|
expect(issueCard(l).id).toBe('C0002');
|
|
});
|
|
|
|
it('earns the reward on the last stamp and resets the track', () => {
|
|
const l = createLoyaltyState();
|
|
const card = issueCard(l);
|
|
for (let i = 0; i < 9; i++) expect(addStamp(l, card.id, 'buy_new')).toEqual([]);
|
|
expect(addStamp(l, card.id, 'buy_new')).toEqual([{ kind: 'free_used_game', maxValueCents: 1000 }]);
|
|
expect(card.stamps).toEqual([0]);
|
|
expect(card.pendingRewards).toHaveLength(1);
|
|
});
|
|
|
|
it('ignores stamp types the card does not track', () => {
|
|
const l = createLoyaltyState();
|
|
const card = issueCard(l);
|
|
expect(addStamp(l, card.id, 'trade')).toEqual([]);
|
|
expect(card.stamps).toEqual([0]);
|
|
});
|
|
|
|
it('supports two independent stamp tracks', () => {
|
|
const l = createLoyaltyState();
|
|
setProgram(l, [
|
|
{ stampType: 'buy_new', slots: 3, reward: { kind: 'discount_percent', percent: 20 } },
|
|
{ stampType: 'trade', slots: 3, reward: { kind: 'free_trade' } },
|
|
]);
|
|
const card = issueCard(l);
|
|
addStamp(l, card.id, 'buy_new');
|
|
addStamp(l, card.id, 'trade');
|
|
addStamp(l, card.id, 'trade');
|
|
expect(card.stamps).toEqual([1, 2]);
|
|
expect(addStamp(l, card.id, 'trade')).toEqual([{ kind: 'free_trade' }]);
|
|
});
|
|
|
|
it('turns voucher rewards into card credit', () => {
|
|
const l = createLoyaltyState();
|
|
setProgram(l, [{ stampType: 'buy_used', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]);
|
|
const card = issueCard(l);
|
|
for (let i = 0; i < 3; i++) addStamp(l, card.id, 'buy_used');
|
|
expect(card.voucherCents).toBe(500);
|
|
expect(card.pendingRewards).toEqual([]);
|
|
});
|
|
|
|
it('validates tracks', () => {
|
|
const reward = { kind: 'free_trade' } as const;
|
|
const t = (stampType: StampTrack['stampType'], slots: number): StampTrack => ({ stampType, slots, reward });
|
|
expect(validateTracks([])).toBe('1 ou 2 types de tampons');
|
|
expect(validateTracks([t('buy_new', 5), t('trade', 5), t('buy_back', 5)])).toBe('1 ou 2 types de tampons');
|
|
expect(validateTracks([t('buy_new', 5), t('buy_new', 5)])).toBe('Types de tampons en double');
|
|
expect(validateTracks([t('buy_new', 2)])).toBe('Entre 3 et 20 cases par type');
|
|
expect(validateTracks([t('buy_new', 21)])).toBe('Entre 3 et 20 cases par type');
|
|
expect(validateTracks([t('buy_new', 3)])).toBeNull();
|
|
expect(() => setProgram(createLoyaltyState(), [])).toThrow('1 ou 2 types de tampons');
|
|
});
|
|
|
|
it('keeps existing cards on the rules they were issued with', () => {
|
|
const l = createLoyaltyState();
|
|
const oldCard = issueCard(l);
|
|
setProgram(l, [{ stampType: 'buy_new', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]);
|
|
for (let i = 0; i < 3; i++) addStamp(l, oldCard.id, 'buy_new');
|
|
expect(oldCard.stamps).toEqual([3]);
|
|
expect(oldCard.voucherCents).toBe(0);
|
|
expect(issueCard(l).programVersion).toBe(2);
|
|
});
|
|
|
|
it('penalizes reputation when the program becomes less generous', () => {
|
|
const l = createLoyaltyState();
|
|
const lessGenerous = setProgram(l, [{ stampType: 'buy_new', slots: 20, reward: { kind: 'voucher', valueCents: 500 } }]);
|
|
expect(lessGenerous.reputationDelta).toBe(-2);
|
|
const moreGenerous = setProgram(l, [{ stampType: 'buy_new', slots: 5, reward: { kind: 'voucher', valueCents: 1000 } }]);
|
|
expect(moreGenerous.reputationDelta).toBe(0);
|
|
});
|
|
|
|
it('migrates a card to the current program once it completes a track', () => {
|
|
const l = createLoyaltyState();
|
|
const card = issueCard(l);
|
|
setProgram(l, [{ stampType: 'buy_new', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]);
|
|
for (let i = 0; i < 9; i++) expect(addStamp(l, card.id, 'buy_new')).toEqual([]);
|
|
expect(addStamp(l, card.id, 'buy_new')).toEqual([{ kind: 'free_used_game', maxValueCents: 1000 }]);
|
|
expect(card.programVersion).toBe(2);
|
|
expect(card.stamps).toEqual([0]);
|
|
expect(card.pendingRewards).toHaveLength(1);
|
|
addStamp(l, card.id, 'buy_new');
|
|
addStamp(l, card.id, 'buy_new');
|
|
expect(addStamp(l, card.id, 'buy_new')).toEqual([{ kind: 'voucher', valueCents: 500 }]);
|
|
expect(card.voucherCents).toBe(500);
|
|
});
|
|
|
|
it('stores deep copies of tracks', () => {
|
|
const l = createLoyaltyState();
|
|
const tracks: StampTrack[] = [{ stampType: 'buy_new', slots: 5, reward: { kind: 'free_trade' } }];
|
|
setProgram(l, tracks);
|
|
tracks[0].slots = 99;
|
|
expect(currentProgram(l).tracks[0].slots).toBe(5);
|
|
const first = l.programs[0].tracks[0];
|
|
expect(first).not.toBe(DEFAULT_TRACKS[0]);
|
|
});
|
|
|
|
it('rejects non-integer slots and non-positive rewards', () => {
|
|
const t = (slots: number, reward: StampTrack['reward']): StampTrack => ({ stampType: 'buy_new', slots, reward });
|
|
expect(validateTracks([t(3.5, { kind: 'free_trade' })])).toBe('Entre 3 et 20 cases par type');
|
|
expect(validateTracks([t(5, { kind: 'free_used_game', maxValueCents: 0 })])).toBe('Récompense invalide');
|
|
expect(validateTracks([t(5, { kind: 'voucher', valueCents: -1 })])).toBe('Récompense invalide');
|
|
expect(validateTracks([t(5, { kind: 'discount_percent', percent: 0 })])).toBe('Récompense invalide');
|
|
expect(validateTracks([t(5, { kind: 'discount_percent', percent: 101 })])).toBe('Récompense invalide');
|
|
expect(validateTracks([t(5, { kind: 'discount_percent', percent: 100 })])).toBeNull();
|
|
});
|
|
|
|
it('keeps partial progress on other tracks when migrating, capped to the new slots', () => {
|
|
const l = createLoyaltyState();
|
|
setProgram(l, [
|
|
{ stampType: 'buy_new', slots: 8, reward: { kind: 'free_trade' } },
|
|
{ stampType: 'trade', slots: 4, reward: { kind: 'free_trade' } },
|
|
]);
|
|
const card = issueCard(l);
|
|
card.stamps = [7, 3];
|
|
setProgram(l, [
|
|
{ stampType: 'buy_new', slots: 5, reward: { kind: 'free_trade' } },
|
|
{ stampType: 'trade', slots: 4, reward: { kind: 'free_trade' } },
|
|
]);
|
|
expect(addStamp(l, card.id, 'trade')).toEqual([{ kind: 'free_trade' }]);
|
|
expect(card.programVersion).toBe(3);
|
|
expect(card.stamps).toEqual([4, 0]);
|
|
});
|
|
});
|