Files
CRM_Montzar/apps/api/test/gamification-engine.test.ts
T
MahdiandCursor 60d797f184 feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشان‌ها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency
- اتصال به لید، مشتری، معامله، فعالیت، روتین، پیش‌فاکتور، فاکتور و تیکت
- صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو
- انتقال فرم‌ساز سرنخ به ابزارها

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 21:28:49 +03:30

197 lines
7.8 KiB
TypeScript

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