Files
tripz-llc/backend/src/modules/tariff/tariff-seed.spec.ts
T
Hamza-AyedandClaude Opus 4.8 e9beeb90b4 feat: rebuild pricing on Siro's live kazan table + close two fare leaks
## الأرقام صارت من جدول `kazan` الحيّ لا من تقدير
العمولة 11٪/12٪/14٪ مطابقة حرفياً. وثلاثة تصحيحات على الخام:
- **محورا الأردن مقلوبان**: أعمدة الفئات تحمل سعر الدقيقة (0.043) وأعمدة
  النوافذ سعر الكيلومتر (0.184) — عكس مصر وسوريا. قراءتها كما هي تجعل
  رحلة 27 كم بأقلّ من دينارين.
- **نِسَب الفئات في مصر وسوريا معطوبة**: الكهربائي 0.23 والفان 0.25–0.40 من
  الاقتصادي، أي فان أرخص من اقتصادي. نِسَب الأردن وحدها متماسكة فاعتُمدت.
- سعر الكيلو في مصر 4 جنيهات لا 3.5 (قرار المالك).

## البنية صارت مطابقة لسيرو — وهي ما سيكتب فيه بوت استخبارات الأسعار
سعر الكيلو يتغيّر بالفئة فقط (رقم يعرفه السوق)، وسعر الدقيقة بالنافذة فقط.
**لا فتحة عدّاد** (`startPrice = 0` في كل صفوف سيرو): الحدّ الأدنى أرضية لا
رسم يُضاف — «ما في رحلة أقل من 20 جنيهاً». `fuelPrice` محفوظ كمدخل للبوت.

## تسريبان أغلقهما الاختبار الحيّ (بموافقة المالك)
1. **فئة مجهولة = رحلة مجانية**: `service_class` يأتي من الطلب بلا تحقّق،
   والاستثناء كان يُبتلع فتُنشأ رحلة بـ`quoted_fare = null` ثم تُسوّى بصفر.
   صار الطلب يُرفض بـ400 — الفشل الصريح أأمن من الصامت.
2. **`fixed` لا تُزرع**: سعر ثابت بلا جدول خطوط يعني رقماً واحداً لكل
   الوجهات — أظهر الاختبار رحلة 55.8 كم بـ2.5 ديناراً مقابل 23.2 للاقتصادي.

300 اختبار خضر.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:38:20 +03:00

250 lines
11 KiB
TypeScript

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