import { TariffEngine } from './tariff.engine'; import { TariffDefinition } from './entities/tariff.entity'; // نستورد مصدر الحقيقة نفسه — الذي يزرعه التطبيق ويولّد منه الـSQL — لا // نسخة موازية منه تتباعد عنه بصمت. import { buildDefinition, COUNTRY_TARIFFS as countries, SERVICE_CLASSES as ServiceClasses, SEEDED_CLASSES, CLASS_MULTIPLIERS, WINDOWS, countryTariff as byPack, } from './default-tariffs'; const def = (pack: string, cls: string): TariffDefinition => buildDefinition(byPack(pack), cls); /** ظهيرة يوم عادي في كل منطقة — نافذة `normal_day`. */ const NOON_AMMAN = new Date('2026-07-19T09:00:00Z'); // 12:00 بتوقيت عمّان (+3) describe('تعرفة الانطلاق — الحدود الدنيا بقرار المالك', () => { it('الحدّ الأدنى: 1.10 دينار · 20 جنيهاً · 150 ليرة', () => { expect(def('jo', ServiceClasses.ECONOMY).min_fare).toBe(1.1); expect(def('eg', ServiceClasses.ECONOMY).min_fare).toBe(20); expect(def('sy', ServiceClasses.ECONOMY).min_fare).toBe(150); }); it('رحلة قصيرة جداً تُرفع للحدّ الأدنى لا تُحاسَب بالعدّاد', () => { for (const [pack, floor] of [['jo', 1.1], ['eg', 20], ['sy', 150]] as const) { const q = TariffEngine.quote(def(pack, ServiceClasses.ECONOMY), { distanceKm: 0.3, durationMin: 2, at: NOON_AMMAN, }); expect(q.total).toBeGreaterThanOrEqual(floor); } }); it('**لا فتحة عدّاد ولا رسم حجز** — الأرضية هي الآلية', () => { // `startPrice = 0` في كل صفوف `kazan`، وقرار المالك أن «ما في رحلة أقل // من 20 جنيهاً» تُنفَّذ كأرضية لا كرسم يُضاف فوق كل أجرة. for (const country of countries) { const d = def(country.countryPack, ServiceClasses.ECONOMY); expect(d.booking_fee).toBe(0); for (const w of d.windows) expect(w.flag).toBe(0); } }); it('الأرضية ترفع الرحلة القصيرة فقط — الطويلة تُحاسَب بالعدّاد', () => { const d = def('eg', ServiceClasses.ECONOMY); const short = TariffEngine.quote(d, { distanceKm: 1, durationMin: 3, at: NOON_AMMAN }); const long = TariffEngine.quote(d, { distanceKm: 30, durationMin: 40, at: NOON_AMMAN }); expect(short.total).toBe(20); // الأرضية expect(long.total).toBeGreaterThan(100); // العدّاد وحده }); }); describe('تعرفة الانطلاق — ترتيب الفئات', () => { // الخلل الذي كسر النسخة السابقة: الفان أرخص من الاقتصادي في مصر وسوريا، // فيصير الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل. // الترتيب من نِسَب `kazan` (صفّ الأردن): الفان **أغلى من VIP** — مركبة // بسبعة مقاعد أعلى كلفة من سيارة أفخم. const ordered = [ ServiceClasses.SAVER, ServiceClasses.DELIVERY, ServiceClasses.ECONOMY, ServiceClasses.LADY, ServiceClasses.ELECTRIC, ServiceClasses.COMFORT, ServiceClasses.VIP, ServiceClasses.VAN, ]; for (const country of countries) { it(`${country.countryPack}: السعر يتصاعد مع الفئة بلا انعكاس`, () => { const fares = ordered.map( (cls) => TariffEngine.quote(def(country.countryPack, cls), { distanceKm: 40, // مسافة كبيرة تتجاوز الأرضية في كل الفئات durationMin: 50, at: NOON_AMMAN, }).total, ); for (let i = 1; i < fares.length; i++) { expect(fares[i]).toBeGreaterThan(fares[i - 1]); } }); } }); describe('النوافذ الزمنية — بالتوقيت المحلي لا UTC', () => { it('تغطي اليوم كاملاً بلا ثغرة ولا تداخل', () => { const toMin = (s: string) => Number(s.slice(0, 2)) * 60 + Number(s.slice(3)); for (let i = 0; i < WINDOWS.length; i++) { const next = WINDOWS[(i + 1) % WINDOWS.length]; // نهاية كل نافذة = بداية التالية بالضبط؛ أي فجوة تعني دقائق بلا سعر. expect(toMin(WINDOWS[i].to)).toBe(toMin(next.from)); } }); it('**الذروة المسائية تقع مساءً في عمّان لا بعد منتصف الليل**', () => { const d = def('jo', ServiceClasses.ECONOMY); // 17:00 بتوقيت عمّان = 14:00 UTC. بالحساب القديم (UTC) كانت الساعة // تُقرأ 14:00 فتقع في `normal_day` — أي ذروة لا تُحصَّل أبداً. const q = TariffEngine.quote(d, { distanceKm: 5, durationMin: 15, at: new Date('2026-07-19T14:00:00Z'), }); expect(q.window).toBe('evening_peak'); }); it('الظهيرة نافذة عادية، والفجر نافذة ليلية — في كل بلد بمنطقته', () => { expect( TariffEngine.quote(def('jo', ServiceClasses.ECONOMY), { distanceKm: 1, durationMin: 1, at: new Date('2026-07-19T09:00:00Z'), // 12:00 عمّان }).window, ).toBe('normal_day'); expect( TariffEngine.quote(def('eg', ServiceClasses.ECONOMY), { distanceKm: 1, durationMin: 1, at: new Date('2026-07-19T00:30:00Z'), // 02:30 القاهرة }).window, ).toBe('late_night'); }); it('الذروة أغلى من النافذة العادية لنفس الرحلة', () => { const d = def('jo', ServiceClasses.ECONOMY); const normal = TariffEngine.quote(d, { distanceKm: 8, durationMin: 20, at: new Date('2026-07-19T09:00:00Z'), }).total; const peak = TariffEngine.quote(d, { distanceKm: 8, durationMin: 20, at: new Date('2026-07-19T14:00:00Z'), }).total; expect(peak).toBeGreaterThan(normal); }); }); describe('السعر الثابت (fixed_quote)', () => { it('**لا يتغير بالمسافة ولا بالزمن** — هذا معنى «ثابت»', () => { const d = def('eg', ServiceClasses.FIXED); const short = TariffEngine.quote(d, { distanceKm: 2, durationMin: 5, at: NOON_AMMAN }); const long = TariffEngine.quote(d, { distanceKm: 40, durationMin: 90, at: NOON_AMMAN }); // سابقاً كان `fixed_quote` يسقط للفرع المتري فيُحسب بالعدّاد رغم اسمه. expect(short.total).toBe(long.total); expect(short.total).toBe(byPack('eg').fixedFare); }); it('الانتظار وحده يُضاف — وقت السائق تكلفة حقيقية مهما ثبت الخط', () => { const d = def('eg', ServiceClasses.FIXED); const withWait = TariffEngine.quote(d, { distanceKm: 10, durationMin: 20, waitingMin: 10, at: NOON_AMMAN, }); expect(withWait.total).toBeGreaterThan(byPack('eg').fixedFare); expect(withWait.distance).toBe(0); expect(withWait.time).toBe(0); }); it('لا حدّ أدنى يرفع السعر المعلن — المعلَن هو المتفَق عليه', () => { const d = def('sy', ServiceClasses.FIXED); expect(d.min_fare).toBe(0); expect( TariffEngine.quote(d, { distanceKm: 0.5, durationMin: 1, at: NOON_AMMAN }).total, ).toBe(byPack('sy').fixedFare); }); }); describe('العمولة', () => { it('تُحسب بنسبة البلد على أجرة الراكب', () => { const d = def('jo', ServiceClasses.ECONOMY); const q = TariffEngine.quote(d, { distanceKm: 10, durationMin: 25, at: NOON_AMMAN }); const c = TariffEngine.commission(d, q.total); expect(c.rate).toBe(14); expect(c.amount).toBeCloseTo(q.total * 0.14, 2); }); }); describe('مطابقة جدول kazan (بيانات سيرو الحيّة)', () => { it('العمولة مطابقة حرفياً: 11٪ سوريا · 12٪ مصر · 14٪ الأردن', () => { expect(byPack('sy').commissionPercent).toBe(11); expect(byPack('eg').commissionPercent).toBe(12); expect(byPack('jo').commissionPercent).toBe(14); }); it('**محورا الأردن مقروءان بالاتجاه الصحيح** — الكيلو أغلى من الدقيقة', () => { // في `kazan` كان صفّ الأردن مقلوباً: أعمدة الفئات تحمل سعر الدقيقة // (0.043) وأعمدة النوافذ سعر الكيلومتر (0.184). قراءته كما هو تجعل // الكيلومتر بـ0.043 ديناراً — أي رحلة 27 كم بأقلّ من دينارين. const w = def('jo', ServiceClasses.ECONOMY).windows[1]; // normal_day expect(w.per_km).toBe(0.184); expect(w.per_min).toBe(0.043); expect(w.per_km).toBeGreaterThan(w.per_min); }); it('سعر الكيلو في مصر 4 جنيهات (قرار المالك) لا 3.5', () => { expect(byPack('eg').perKm).toBe(4.0); }); it('**سعر الكيلو ثابت عبر النوافذ** — الوقت يحرّك الدقيقة لا المسافة', () => { // «الكيلو بأربعة جنيه» رقم يعرفه السوق، فلا يصحّ أن يتذبذب بالساعة. for (const country of countries) { const d = def(country.countryPack, ServiceClasses.ECONOMY); const kms = new Set(d.windows.map((w) => w.per_km)); expect(kms.size).toBe(1); // بينما الدقيقة تتغيّر فعلاً بين النوافذ (حيث تختلف أرقام البلد). const mins = new Set(d.windows.map((w) => w.per_min)); expect(mins.size).toBeGreaterThan(1); } }); it('نِسَب الفئات معطوبة في kazan لمصر وسوريا — نِسَب الأردن هي المعتمدة', () => { // الخام: الكهربائي 0.23 والفان 0.25–0.40 من الاقتصادي، أي فان أرخص من // اقتصادي. المعتمد هنا متصاعد ولا ينعكس. expect(CLASS_MULTIPLIERS.van).toBeGreaterThan(CLASS_MULTIPLIERS.economy); expect(CLASS_MULTIPLIERS.electric).toBeGreaterThan(CLASS_MULTIPLIERS.economy); expect(CLASS_MULTIPLIERS.delivery).toBeLessThan(CLASS_MULTIPLIERS.economy); }); it('سعر الوقود محفوظ لبوت استخبارات الأسعار', () => { expect(byPack('sy').fuelPrice).toBe(110); expect(byPack('eg').fuelPrice).toBe(13); expect(byPack('jo').fuelPrice).toBe(1); }); }); describe('فئة fixed لا تُزرع', () => { it('**مستثناة من الزرع** — سعر ثابت بلا جدول خطوط تسريب', () => { // أثبت الاختبار الحيّ: رحلة 55.8 كم بـ2.5 ديناراً مقابل 23.2 للاقتصادي. expect(SEEDED_CLASSES).not.toContain(ServiceClasses.FIXED); expect(SEEDED_CLASSES).toHaveLength(8); }); it('تبقى معرَّفة في الكتالوج — تُفعَّل حين يُبنى جدول الخطوط', () => { expect(Object.values(ServiceClasses)).toContain(ServiceClasses.FIXED); expect(def('jo', ServiceClasses.FIXED).mode).toBe('fixed_quote'); }); });