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]); }); });