نفس سؤال الـ LLD المشهور، بس من غير State Pattern ومن غير objects بتغيّر في بعض.
الآلة كلها step(machine, event) → [machine, effects]: function نقية بتاخد الحالة وحدث، وبترجّع حالة جديدة وقائمة بالحاجات المطلوب تنفيذها.
الصفحة دي بتشغّل نفس الكود اللي عليه التستات، ومش بتخزّن state خالص: بتخزّن الـ events وتعيد تشغيلها.
الكود: github.com/abo3ish/vending-machine
Imperative shell · the machine
each click = one coin · $1.10 = $1 then 10¢
الـ sensor متمثّل هنا: بعد ما تدوس BUY بثانية، بيبعت event اسمه dispensed (أو jammed لو علّمت فوق). الـ core مش بيعرف إن فيه motor ولا sensor، هو بيستقبل events وخلاص، سواء جاية من الزبون أو من الآلة.
Functional core · step()
عشان step نقية، التستات مش محتاجة mocks ولا setup. fast-check بيولّد آلاف الآلات وتسلسلات الـ events العشوائية (عملات وقت الـ dispensing، و sensor بيضرب من غير سبب، وخانات مش موجودة) وبيتأكد إن القواعد دي ثابتة في كل لحظة:
| القاعدة | بتمسك إيه | تسلسلات |
|---|---|---|
| مفيش عملة بتتخلق ولا بتضيع (لكل فئة لوحدها) | باقي غلط، أو refund ناقص، أو escrow ضاع | 2,000 |
| الصندوق بيزيد بسعر اللي اتباع بالظبط | منتج نزل ببلاش، أو سعر غلط | 2,000 |
| المخزون والعملات عمرهم ما يبقوا بالسالب | بيع من خانة فاضية، أو باقي من عملات مش موجودة | 2,000 |
| مفيش dispense غير لمنتج موجود ومدفوع تمنه | أي طريق بيوصل للـ motor من غير فلوس | 2,000 |
| نفس الـ events بتطلع نفس التاريخ | أي اعتماد مخفي على وقت أو random | 300 |
makeChange بيلاقي باقي كل ما يكون فيه حل | greedy بيقول "مفيش باقي" وهو موجود | 2,000 |
للتأكد إنها مش تستات شكلية: لو شلت سطر خصم الباقي من الصندوق، 3 تستات بتقع على طول. ولو شلت أي case من الـ switch، الكومبايلر بيرفض يبني.
الحل المعتاد للسؤال ده: كلاس VendingMachine فيه selectedProduct و totalCoins و currentState، و State Pattern بأربع حالات (Idle و Ready و Dispense و ReturnChange)، وكل حالة بتعمل machine.setState(new ...). ده كويس، بس فيه حاجات أحسن:
switch اترفض في الحل الكلاسيكي بحجة Open/Closed. بس مع discriminated unions، الكومبايلر بيجبرك تتعامل مع كل (حالة، حدث). لو ضفت حالة جديدة ونسيت تتعامل معاها في أي مكان، الكود مش هيبني. ده أأمن من OCP، لأن الحالات هنا مجموعة مقفولة مش plugin system.نسخة OOP كويسة ممكن تقرّب جداً من ده: states immutable، وكل method بترجّع الـ state اللي بعدها بدل ما تعمل setState. الفرق اللي بيفضل إن الـ effects بتتنفذ جوه الـ method بدل ما ترجع data.
selectedProduct و currentState حقلين منفصلين، تقدر تكتب آلة في Idle ومعاها منتج مختار. حالة مستحيلة، بس الكود بيسمح بيها، فلازم تفضل فاكرها في كل method.idle مالهاش أي بيانات، و paying معاها slot و inserted. الحالة المستحيلة ماينفعش تتكتب أصلاً.type Phase =
| { tag: 'idle' }
| { tag: 'paying'; slot; inserted }
| { tag: 'dispensing'; slot; inserted; change }
ReturnChangeState مش مكان الآلة بتقف فيه، ده حاجة بتعملها. هنا بقى effect: { tag: 'returnCoins', coins }.dispensing حالة حقيقية فعلاً، بس لسبب مختلف: الآلة مستنية تأكيد من الـ sensor، والفلوس محجوزة في escrow لحد ما المنتج ينزل.dispensed يوصل من الـ sensor.jammed، الآلة بترجع idle وبترجّع نفس العملات اللي اتحطت بالظبط. مفيش transaction ولا compensation: الـ atomicity جاية من التصميم نفسه.totalCoins = 135 مش بيقولك تقدر ترجّع 35 قرش ولا لأ. لازم تعرف الصندوق فيه كام عملة من كل فئة. من غير كده حالة "Exact change only" مش هتلاقي ليها مكان في التصميم.makeChange بيبدأ greedy وبيرجع خطوة لو اتزنق، فبيلاقي 10+10+10. شوف سيناريو "باقي صعب".0.1 + 0.2 !== 0.3.step واحد ورا التاني، والـ core مفيهوش shared mutable state، فمفيش حاجة تتقفل عليها أصلاً.run(start, events) وتعرض اللحظة اللي إنت واقف عليها.