import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_LEVELS, evaluateCondition, jalaliMonthKeyOf, levelFor, limitBlockReason, matchesConditions, needsUsage, normalizeLevels, periodStart, previousPeriod, rankRows, routineStreak, sanitizeConditions, } from '../src/gamification/gamification-engine'; /** پنجشنبه ۱۶ مهر ۱۴۰۵، ساعت ۱۲:۳۰ تهران */ const NOW = new Date('2026-10-08T09:00:00Z'); describe('gamification conditions', () => { it('compares numbers (also numeric strings with commas)', () => { assert.equal(evaluateCondition({ field: 'amount', op: 'gt', value: 1000 }, { amount: 1500 }), true); assert.equal(evaluateCondition({ field: 'amount', op: 'gte', value: '1,500' }, { amount: 1500 }), true); assert.equal(evaluateCondition({ field: 'amount', op: 'lt', value: 1000 }, { amount: 1500 }), false); assert.equal(evaluateCondition({ field: 'amount', op: 'gt', value: 10 }, {}), false); }); it('handles text, in-lists and booleans', () => { assert.equal(evaluateCondition({ field: 'source', op: 'eq', value: 'Instagram' }, { source: 'instagram ' }), true); assert.equal(evaluateCondition({ field: 'source', op: 'neq', value: 'site' }, { source: 'instagram' }), true); assert.equal(evaluateCondition({ field: 'stageName', op: 'contains', value: 'مذاکره' }, { stageName: 'در حال مذاکره' }), true); assert.equal(evaluateCondition({ field: 'priority', op: 'in', value: 'high، urgent' }, { priority: 'urgent' }), true); assert.equal(evaluateCondition({ field: 'priority', op: 'in', value: 'high,urgent' }, { priority: 'low' }), false); assert.equal(evaluateCondition({ field: 'onTime', op: 'is_true' }, { onTime: true }), true); assert.equal(evaluateCondition({ field: 'onTime', op: 'is_false' }, { onTime: false }), true); assert.equal(evaluateCondition({ field: 'onTime', op: 'is_false' }, {}), true); }); it('requires all conditions (AND)', () => { const conds = [ { field: 'amount', op: 'gte' as const, value: 100 }, { field: 'onTime', op: 'is_true' as const }, ]; assert.equal(matchesConditions(conds, { amount: 200, onTime: true }), true); assert.equal(matchesConditions(conds, { amount: 200, onTime: false }), false); assert.equal(matchesConditions([], {}), true); }); it('sanitizes unknown fields/ops and caps the list', () => { const raw = [ { field: 'amount', op: 'gt', value: 5 }, { field: 'hack', op: 'gt', value: 5 }, { field: 'amount', op: 'drop', value: 5 }, { field: 'onTime', op: 'is_true', value: { x: 1 } }, ...Array.from({ length: 20 }, () => ({ field: 'hour', op: 'lt', value: 10 })), ]; const out = sanitizeConditions(raw); assert.equal(out.length, 10); assert.deepEqual(out[0], { field: 'amount', op: 'gt', value: 5 }); assert.deepEqual(out[1], { field: 'onTime', op: 'is_true', value: null }); assert.deepEqual(sanitizeConditions('nope'), []); }); }); describe('gamification limits', () => { const base = { maxPerDay: null, maxCoinsPerDay: null, maxPerUser: null, cooldownMinutes: null, firstTimeOnly: false, coins: 0, }; const usage = { todayCount: 0, todayCoins: 0, totalCount: 0, lastAt: null }; it('allows when no limit is set', () => { assert.equal(limitBlockReason(base, { ...usage, todayCount: 99, totalCount: 999 }, NOW), null); assert.equal(needsUsage(base), false); }); it('blocks per day, per user, first time and coin caps', () => { assert.equal(limitBlockReason({ ...base, maxPerDay: 3 }, { ...usage, todayCount: 3 }, NOW), 'max_per_day'); assert.equal(limitBlockReason({ ...base, maxPerDay: 3 }, { ...usage, todayCount: 2 }, NOW), null); assert.equal(limitBlockReason({ ...base, maxPerUser: 1 }, { ...usage, totalCount: 1 }, NOW), 'max_per_user'); assert.equal(limitBlockReason({ ...base, firstTimeOnly: true }, { ...usage, totalCount: 1 }, NOW), 'first_time_only'); assert.equal( limitBlockReason({ ...base, coins: 10, maxCoinsPerDay: 50 }, { ...usage, todayCoins: 45 }, NOW), 'max_coins_per_day', ); assert.equal(limitBlockReason({ ...base, coins: 5, maxCoinsPerDay: 50 }, { ...usage, todayCoins: 45 }, NOW), null); assert.equal(needsUsage({ ...base, maxPerDay: 1 }), true); }); it('respects the cooldown window', () => { const rule = { ...base, cooldownMinutes: 30 }; const recent = new Date(NOW.getTime() - 10 * 60_000); const old = new Date(NOW.getTime() - 31 * 60_000); assert.equal(limitBlockReason(rule, { ...usage, lastAt: recent }, NOW), 'cooldown'); assert.equal(limitBlockReason(rule, { ...usage, lastAt: old }, NOW), null); }); }); describe('gamification periods (Jalali, Tehran)', () => { it('computes period starts at Tehran midnight', () => { assert.equal(periodStart('today', NOW)?.toISOString(), '2026-10-07T20:30:00.000Z'); assert.equal(periodStart('week', NOW)?.toISOString(), '2026-10-02T20:30:00.000Z'); assert.equal(periodStart('month', NOW)?.toISOString(), '2026-09-22T20:30:00.000Z'); assert.equal(periodStart('quarter', NOW)?.toISOString(), '2026-09-22T20:30:00.000Z'); assert.equal(periodStart('year', NOW)?.toISOString(), '2026-03-20T20:30:00.000Z'); assert.equal(periodStart('all', NOW), null); }); it('computes the previous equal-sized period', () => { const month = previousPeriod('month', NOW); assert.equal(month?.from.toISOString(), '2026-08-22T20:30:00.000Z'); assert.equal(month?.to.toISOString(), '2026-09-22T20:30:00.000Z'); const week = previousPeriod('week', NOW); assert.equal(week?.from.toISOString(), '2026-09-25T20:30:00.000Z'); assert.equal(previousPeriod('all', NOW), null); }); it('builds the Jalali month key', () => { assert.equal(jalaliMonthKeyOf(NOW), '1405-07'); }); }); describe('gamification levels and ranking', () => { it('maps score to level with progress', () => { const l0 = levelFor(0, DEFAULT_LEVELS); assert.equal(l0.level, 1); assert.equal(l0.toNext, 250); const mid = levelFor(900, DEFAULT_LEVELS); assert.equal(mid.name, 'نقره‌ای'); assert.equal(mid.progress, 50); const top = levelFor(10_000, DEFAULT_LEVELS); assert.equal(top.level, DEFAULT_LEVELS.length); assert.equal(top.progress, 100); assert.equal(top.nextName, null); }); it('normalizes custom levels', () => { const levels = normalizeLevels([ { name: 'B', minScore: 100 }, { name: ' ', minScore: 5 }, { name: 'A', minScore: 40 }, ]); assert.deepEqual(levels, [ { name: 'A', minScore: 0 }, { name: 'B', minScore: 100 }, ]); assert.equal(normalizeLevels(null), DEFAULT_LEVELS); }); it('uses competition ranking for ties', () => { const ranked = rankRows([ { userId: 'a', score: 50 }, { userId: 'b', score: 80 }, { userId: 'c', score: 50 }, { userId: 'd', score: 10 }, ]); assert.deepEqual( ranked.map((r) => [r.userId, r.rank]), [ ['b', 1], ['a', 2], ['c', 2], ['d', 4], ], ); }); }); describe('routine streak', () => { const today = '2026-10-08'; it('counts consecutive full days, skipping empty days and an unfinished today', () => { const days = new Map([ ['2026-10-08', { total: 3, completed: 1 }], ['2026-10-07', { total: 2, completed: 2 }], ['2026-10-06', { total: 0, completed: 0 }], ['2026-10-05', { total: 1, completed: 1 }], ['2026-10-04', { total: 2, completed: 1 }], ['2026-10-03', { total: 2, completed: 2 }], ]); assert.equal(routineStreak(days, today), 2); }); it('includes today when finished', () => { const days = new Map([ ['2026-10-08', { total: 1, completed: 1 }], ['2026-10-07', { total: 1, completed: 1 }], ]); assert.equal(routineStreak(days, today), 2); }); });