import { describe, expect, it } from 'vitest'; import { rankCandidates, suggestCandidate, type Candidate } from './assignment.ts'; import { cancelPolicy, canRescheduleSelf } from './cancellation.ts'; import { CATALOG } from './catalog.ts'; import { decodeStartPayload, encodeStartPayload, pickUtm } from './deeplink.ts'; import { agencyCommission, decideGift, discountFor, isAgencyAttributionActive, membershipLevel, payoutFor } from './membership.ts'; import { defaultParams, validateParams } from './params.ts'; import { fromPrice, PricingError, quote } from './pricing.ts'; import { checkPromo, combineDiscounts, type PromoLike } from './promo.ts'; import { DEFAULT_SETTINGS, resolveSettings } from './settings.ts'; import { daySlots, type ProviderAvailability } from './slots.ts'; import { fromYerevan, HOUR, toYerevan, yerevanDateKey } from './time.ts'; const cleaning = CATALOG.find((s) => s.code === 'cleaning')!; describe('pricing (ORD-4)', () => { it('считает базовую цену по типу и комнатам', () => { expect(quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 })).toEqual({ basePrice: 15000, discountPct: 0, discountAmount: 0, totalPrice: 15000, durationMinutes: 120, }); expect(quote(cleaning.pricing, { cleaningType: 'general', rooms: 3 }).basePrice).toBe(25000 + 2 * 7000); }); it('вычитает скидку Barev+ и округляет её вниз', () => { const q = quote({ kind: 'fixed', price: 15005, minutes: 60 }, {}, 10); expect(q.discountAmount).toBe(1500); expect(q.totalPrice).toBe(13505); expect(q.basePrice - q.discountAmount).toBe(q.totalPrice); }); it('отклоняет неизвестный вариант, плохие комнаты и скидку вне 0–100', () => { expect(() => quote(cleaning.pricing, { cleaningType: 'deluxe', rooms: 1 })).toThrow(PricingError); expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 0 })).toThrow(PricingError); expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 }, 101)).toThrow(PricingError); }); it('«от N ֏» — минимальный тариф', () => { expect(fromPrice(cleaning.pricing)).toBe(15000); }); }); describe('params (ORD-2)', () => { it('дефолты и валидация', () => { expect(defaultParams(cleaning.paramsSchema)).toEqual({ cleaningType: 'regular', rooms: 2 }); expect(validateParams(cleaning.paramsSchema, { cleaningType: 'general', rooms: '3', junk: 1 })).toEqual({ ok: true, values: { cleaningType: 'general', rooms: 3 }, }); expect(validateParams(cleaning.paramsSchema, { cleaningType: 'x', rooms: 9 })).toEqual({ ok: false, errors: [ { key: 'cleaningType', code: 'invalid' }, { key: 'rooms', code: 'invalid' }, ], }); expect(validateParams(cleaning.paramsSchema, {})).toMatchObject({ ok: false }); }); }); describe('time (Asia/Yerevan)', () => { it('UTC+4 без летнего времени', () => { const d = fromYerevan(2026, 7, 1, 9 * 60); expect(d.toISOString()).toBe('2026-07-01T05:00:00.000Z'); expect(toYerevan(new Date('2026-01-15T21:30:00Z'))).toMatchObject({ day: 16, hour: 1, minute: 30, weekday: 4 }); expect(yerevanDateKey(new Date('2026-12-31T20:00:00Z'))).toBe('2027-01-01'); }); }); describe('slots (ORD-3)', () => { // 2026-10-19 — понедельник const now = fromYerevan(2026, 10, 18, 12 * 60); const fullDay = [{ weekday: 0, startMinute: 9 * 60, endMinute: 20 * 60 }]; const config = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 }; it('нет исполнителей — нет слотов', () => { expect(daySlots({ dateKey: '2026-10-19', config, durationMinutes: 120, now, providers: [], unassigned: [] })).toEqual([]); }); it('слоты по окну услуги, длительности и графику', () => { const p: ProviderAvailability = { id: 'a', schedule: fullDay, busy: [] }; const slots = daySlots({ dateKey: '2026-10-19', config, durationMinutes: 120, now, providers: [p], unassigned: [] }); // 9:00 … 18:00 (последний слот заканчивается в 20:00) expect(slots).toHaveLength(10); expect(toYerevan(slots[0]!.start).hour).toBe(9); expect(toYerevan(slots.at(-1)!.start).hour).toBe(18); }); it('занятость исполнителя и неназначенные заказы уменьшают мощность', () => { const busy = [{ start: fromYerevan(2026, 10, 19, 10 * 60), end: fromYerevan(2026, 10, 19, 12 * 60) }]; const a: ProviderAvailability = { id: 'a', schedule: fullDay, busy }; const b: ProviderAvailability = { id: 'b', schedule: fullDay, busy: [] }; const unassigned = [{ start: fromYerevan(2026, 10, 19, 14 * 60), end: fromYerevan(2026, 10, 19, 15 * 60) }]; const slots = daySlots({ dateKey: '2026-10-19', config, durationMinutes: 60, now, providers: [a, b], unassigned }); const cap = (h: number) => slots.find((s) => toYerevan(s.start).hour === h)?.capacity ?? 0; expect(cap(9)).toBe(2); expect(cap(10)).toBe(1); expect(cap(14)).toBe(1); }); it('учитывает минимальный запас до начала', () => { const p: ProviderAvailability = { id: 'a', schedule: [{ weekday: 6, startMinute: 0, endMinute: 1440 }], busy: [] }; // сегодня воскресенье 12:00 → первый слот не раньше 15:00 const slots = daySlots({ dateKey: '2026-10-18', config, durationMinutes: 60, now, providers: [p], unassigned: [] }); expect(toYerevan(slots[0]!.start).hour).toBe(15); }); }); describe('assignment (ADM-3)', () => { const base: Omit = { kind: 'provider', languages: ['ru'], districtIds: [], load: 0, rating: 4.5, free: true, onShift: true, }; it('язык клиента важнее района, район — важнее загрузки', () => { const c: Candidate[] = [ { ...base, id: '1', name: 'A', languages: ['ru'], districtIds: ['kentron'] }, { ...base, id: '2', name: 'B', languages: ['en'], districtIds: ['kentron'] }, { ...base, id: '3', name: 'C', languages: ['en'], districtIds: [], load: 0 }, ]; const ranked = rankCandidates(c, { locale: 'en', districtId: 'kentron', isOnline: false }); expect(ranked.map((r) => r.id)).toEqual(['2', '3', '1']); expect(ranked[0]!.reasons).toEqual(expect.arrayContaining(['language', 'district'])); }); it('занятые исключаются, загрузка снижает приоритет', () => { const c: Candidate[] = [ { ...base, id: '1', name: 'A', free: false }, { ...base, id: '2', name: 'B', load: 3 }, { ...base, id: '3', name: 'C', load: 0 }, ]; expect(suggestCandidate(c, { locale: 'ru', districtId: null, isOnline: false })?.id).toBe('3'); expect(rankCandidates(c, { locale: 'ru', districtId: null, isOnline: false })).toHaveLength(2); }); it('нет свободных — нет подсказки', () => { expect(suggestCandidate([{ ...base, id: '1', name: 'A', free: false }], { locale: 'ru', districtId: null, isOnline: false })).toBeNull(); }); }); describe('cancellation (ORD-6)', () => { const now = new Date('2026-10-17T10:00:00Z'); const at = (h: number) => new Date(now.getTime() + h * HOUR); it('за 24 ч и раньше — бесплатно', () => { expect(cancelPolicy({ scheduledAt: at(24), now, freeHours: 24, lateFeePct: 30, totalPrice: 20000 })).toEqual({ kind: 'free', fee: 0 }); }); it('позже — удержание 30%', () => { expect(cancelPolicy({ scheduledAt: at(23), now, freeHours: 24, lateFeePct: 30, totalPrice: 20001 })).toEqual({ kind: 'late', fee: 6000, feePct: 30, }); }); it('после начала — только через оператора', () => { expect(cancelPolicy({ scheduledAt: at(-1), now, freeHours: 24, lateFeePct: 30, totalPrice: 1 })).toEqual({ kind: 'operator_only' }); }); it('перенос сам — только вне порога', () => { expect(canRescheduleSelf(at(25), now, 24)).toBe(true); expect(canRescheduleSelf(at(5), now, 24)).toBe(false); }); }); describe('deep-link payload (LND-3 / ONB-1)', () => { it('кодирует и декодирует язык и агентство', () => { const p = encodeStartPayload({ locale: 'hy', agency: 'domik1' }); expect(p).toBe('l-hy_a-DOMIK1'); expect(decodeStartPayload(p)).toEqual({ locale: 'hy', agency: 'DOMIK1' }); }); it('токен для UTM', () => { expect(decodeStartPayload(encodeStartPayload({ token: 'k3j9x2', locale: 'en' }))).toEqual({ token: 'k3j9x2', locale: 'en' }); }); it('игнорирует мусор и неизвестный язык', () => { expect(decodeStartPayload('l-fr_a-