.# RAVEN — وحدة التحكم في مشغل الموسيقى (14 أغنية) ودمج صفحة المشروع
يتضمن هذا المستودع:
- التجربة المعزولة لوحدة التحكم في المشغل (
player-controller.mjsوplayer-controller.test.mjs): إصلاح شامل لسباقات العمليات غير المتزامنة (audio.play()Promises) على قائمة من 14 أغنية، مع اختبارات حتمية عبرnode:test. - دمج صفحة مشروع RAVEN الكاملة (
/raven/#top) ومعاينة الحوار (/dialogue): خادم موحّد (server.ts) يعمل على المنفذ3000ويقدّم صفحة RAVEN السينمائية الأصلية ومشغلها المدمج ومعاينة الحوار التفاعلية ومختبر فحص وحدة التحكم (/player-lab).
في النموذج الأصلي المبسط:
async function play() {
await audio.play();
playing = true;
}لا تتغير قيمة playing إلى true إلا بعد تسوية Promise الخاص بـ audio.play(). وقد أثبتنا عبر الاختبارين الأولين في player-controller.test.mjs عيبين رئيسيين بالنتائج الفعلية وتسلسل الأحداث:
- تسلسل الأحداث الفعلي في المصدر الأصلي:
legacy.play()يستدعيaudio.play()#1، لكنplayingتبقىfalseطالما أن الوعد معلّق.- المستخدم يضغط
legacy.toggle()بنية الإيقاف، فيفحص المشغلif (playing)فيجدهاfalse، فيستدعيplay()للمرة الثانية (audio.play()#2) بدل استدعاءpause(). - عند تسوية الوعدين (
resolve#1,resolve#2)، ينتهي المشغل في الحالة{ index: 0, playing: true }معplayCalls = 2وpauseCalls = 0، رغم أن آخر أمر للمستخدم كان الإيقاف.
- سجل الأحداث المسجل في الاختبار:
src:track-01 -> play()#1 -> play()#2 -> resolve#1 -> resolve#2
- تسلسل الأحداث الفعلي في المصدر الأصلي:
legacy.play()يبدأ طلب تشغيل الأغنية0، وتظلplaying === falseأثناء انتظار التسوية.legacy.select(1)يقرأconst resume = playingفيحصل علىfalse، فيستدعيpause()ويغيّرindex = 1وaudio.src = tracks[1].src، لكنه يتجاهل تشغيل الأغنية الجديدة لأنresume === false.- عندما ينجح وعد
audio.play()القديم الخاص بالأغنية0متأخرًا، ينفذ السطرplaying = true، فتصبح حالة المشغل{ index: 1, playing: true }كذبًا، رغم أن الأغنية1لم يُستدعَ لهاplay()أبدًا (playCalls = 1).
في createPlayer(audio, tracks) داخل player-controller.mjs:
-
معرّف العملية المتزايد (
operationId):- كل أمر يغيّر حالة التشغيل (
play,pause,select,dispose) يزيد العدادoperationId += 1. - عند استدعاء
play()، يُحفظ المعرف المحليconst currentOp = operationId. - عندما يُسوى وعد
audio.play()(سواء بالنجاحthenأو بالرفضcatch)، يتحقق المتحكم أولًا من:if (disposed || operationId !== currentOp) { return false; }
- إذا كان المستخدم قد ضغط إيقافًا، أو بدّل الأغنية، أو أطلق طلب تشغيل أحدث، أو استدعى
dispose()، فإنoperationIdيكون قد تغيّر، فتُهمل نتيجة الوعد القديم تمامًا دون المساس بالحالة الأحدث.
- كل أمر يغيّر حالة التشغيل (
-
منع استدعاءات
play()المكررة أثناء الانتظار:- إذا كانت الحالة
loadingويوجد طلب جارٍ (activePlayPromise)، فإن استدعاءplay()المباشر يعيد نفس الوعد الجاهز دون إطلاق استدعاءaudio.play()إضافي. - أما الضغط على
toggle()أثناءloadingفيقرأdesiredPlaying === trueويترجمه فورًا إلى أمر إيقاف (pause()).
- إذا كانت الحالة
يفصل المشغل المصلح بين ثلاث طبقات مستقلة في getState():
| الحقل | النوع | الدلالة |
|---|---|---|
status |
'paused' | 'loading' | 'playing' | 'error' | 'disposed' |
الحالة الصريحة للمشغل في اللحظة الحالية |
desiredPlaying |
boolean |
نية المستخدم: true منذ لحظة طلب التشغيل (loading أو playing)، و false عند الإيقاف أو الخطأ أو التخلص (dispose) |
pending |
boolean |
انتظار بدء التشغيل: true فقط أثناء انتظار تسوية audio.play() (status === 'loading') |
playing |
boolean |
التشغيل المؤكد: true فقط بعد نجاح audio.play() للعملية الأحدث (status === 'playing') |
index |
number |
فهرس الأغنية الحالية (0 إلى tracks.length - 1) |
error |
Error | null |
كائن الخطأ عند رفض play() أو رميه استثناءً متزامنًا (دون إعادة محاولة تلقائية) |
disposed |
boolean |
true بعد استدعاء dispose() وفك مستمع ended وإيقاف الصوت |
operationId |
number |
رقم العملية الحالية لعزل التسويات المتأخرة |
trackCount |
number |
عدد الأغاني المستخرج ديناميكيًا من tracks.length (14 في بيانات التجربة) |
src |
string |
مسار الأغنية المختارة حاليًا |
- القائمة الفارغة أو غير الصالحة: يرمي
createPlayer(audio, [])خطأRangeErrorواضحًا، ويرميTypeErrorإذا لم تكن القائمة مصفوفة أو خلا أي مدخل منsrcنصي غير فارغ. - الفهرس غير الصحيح في
select(nextIndex): أي قيمة غير صحيحة (-1,14,1.5,NaN,'2',null,undefined) ترميRangeErrorقبل تنفيذ أي تعديل، فتبقى الأغنية الحالية وحالة التشغيل دون أي تغيير أو انقطاع.
تم التنفيذ والتحقق فعليًا في بيئة Linux باستخدام:
- إصدار Node.js:
v22.23.3 - أمر الاختبار:
node --test player-controller.test.mjs
TAP version 13
# Subtest: أولًا: إثبات العيوب في المصدر الأصلي (Legacy Bug Reproduction)
# Subtest: عيب 1 (المصدر الأصلي): الضغط على تشغيل ثم toggle قبل اكتمال Promise الأول يستدعي play() مرتين وينتهي بحالة تشغيل بدل الإيقاف
ok 1 - عيب 1 (المصدر الأصلي): الضغط على تشغيل ثم toggle قبل اكتمال Promise الأول يستدعي play() مرتين وينتهي بحالة تشغيل بدل الإيقاف
# Subtest: عيب 2 (المصدر الأصلي): بدء تشغيل الأغنية الأولى ثم اختيار أغنية أخرى قبل اكتمال Promise يفقد نية التشغيل وتغيّر النتيجة القديمة الحالة
ok 2 - عيب 2 (المصدر الأصلي): بدء تشغيل الأغنية الأولى ثم اختيار أغنية أخرى قبل اكتمال Promise يفقد نية التشغيل وتغيّر النتيجة القديمة الحالة
1..2
ok 1 - أولًا: إثبات العيوب في المصدر الأصلي (Legacy Bug Reproduction)
# Subtest: ثانيًا وثالثًا: اختبارات الوحدة المصلحة (Fixed Player Controller)
# Subtest: 1. الحالة الابتدائية دون تشغيل تلقائي
ok 1 - 1. الحالة الابتدائية دون تشغيل تلقائي
# Subtest: 2. تشغيل ناجح ثم إيقاف
ok 2 - 2. تشغيل ناجح ثم إيقاف
# Subtest: 3. تشغيل معلق ثم إيقاف عبر toggle قبل اكتماله (دون استدعاء play إضافي)
ok 3 - 3. تشغيل معلق ثم إيقاف عبر toggle قبل اكتماله (دون استدعاء play إضافي)
# Subtest: 4. نجاح قديم يصل بعد الإيقاف الصريح لا يعيد المشغل إلى playing
ok 4 - 4. نجاح قديم يصل بعد الإيقاف الصريح لا يعيد المشغل إلى playing
# Subtest: 5. رفض قديم يصل بعد بدء طلب أحدث لا يلوث حالة الطلب الأحدث
ok 5 - 5. رفض قديم يصل بعد بدء طلب أحدث لا يلوث حالة الطلب الأحدث
# Subtest: 6. تغيير الأغنية أثناء انتظار التشغيل ينقل قصد التشغيل للأغنية الجديدة ويتجاهل نجاح القديم
ok 6 - 6. تغيير الأغنية أثناء انتظار التشغيل ينقل قصد التشغيل للأغنية الجديدة ويتجاهل نجاح القديم
# Subtest: 7. تغيير الأغنية أثناء الإيقاف يبقي المشغل متوقفًا دون استدعاء play()
ok 7 - 7. تغيير الأغنية أثناء الإيقاف يبقي المشغل متوقفًا دون استدعاء play()
# Subtest: 8. عدة ضغطات تشغيل وإيقاف متتالية (Rapid Toggle Sequence)
ok 8 - 8. عدة ضغطات تشغيل وإيقاف متتالية (Rapid Toggle Sequence)
# Subtest: 9. تبديلات سريعة بين أغنيات متعددة أثناء انتظار التشغيل
ok 9 - 9. تبديلات سريعة بين أغنيات متعددة أثناء انتظار التشغيل
# Subtest: 10. رفض التشغيل يترك حالة خطأ واضحة دون إعادة محاولة تلقائية، ويسمح بإعادة محاولة صريحة ناجحة
ok 10 - 10. رفض التشغيل يترك حالة خطأ واضحة دون إعادة محاولة تلقائية، ويسمح بإعادة محاولة صريحة ناجحة
# Subtest: 11. خطأ متزامن (Synchronous Throw) من audio.play() يُلتقط بأمان ويسمح بالمحاولة مجددًا
ok 11 - 11. خطأ متزامن (Synchronous Throw) من audio.play() يُلتقط بأمان ويسمح بالمحاولة مجددًا
# Subtest: 12. الانتقال عبر الأغنيات الأربع عشرة عند انتهاء كل أغنية ثم العودة من 14 إلى الأولى
ok 12 - 12. الانتقال عبر الأغنيات الأربع عشرة عند انتهاء كل أغنية ثم العودة من 14 إلى الأولى
# Subtest: 13. حدث ended بعد طلب المستخدم الإيقاف (أو أثناء الانتظار) لا يعيد التشغيل ولا يغير الأغنية
ok 13 - 13. حدث ended بعد طلب المستخدم الإيقاف (أو أثناء الانتظار) لا يعيد التشغيل ولا يغير الأغنية
# Subtest: 14. dispose() أثناء انتظار التشغيل يوقف الصوت ويفك المستمعين ويمنع النتائج المتأخرة من تغيير الحالة
ok 14 - 14. dispose() أثناء انتظار التشغيل يوقف الصوت ويفك المستمعين ويمنع النتائج المتأخرة من تغيير الحالة
# Subtest: 15. القائمة الفارغة والفهرس غير الصحيح يرميان أخطاء واضحة دون إفساد حالة المشغل
ok 15 - 15. القائمة الفارغة والفهرس غير الصحيح يرميان أخطاء واضحة دون إفساد حالة المشغل
1..15
ok 2 - ثانيًا وثالثًا: اختبارات الوحدة المصلحة (Fixed Player Controller)
1..2
# tests 17
# suites 2
# pass 17
# fail 0
- ما تثبته اختبارات
FakeAudio(player-controller.test.mjs):- صحة آلة الحالة (
State Machine) وعزل الوعود غير المتزامنة (Promises) وترتيب التسويات مهما تأخرت أو تداخلت. - عدم ضياع نية المستخدم عند التبديل السريع بين الأغاني الأربع عشرة أو الضغط المتتالي على
toggle(). - الالتفاف الصحيح من الأغنية 14 إلى الأغنية 1 عند حدث
ended، والتنظيف الكامل عبرdispose().
- صحة آلة الحالة (
- ما لا تثبته اختبارات
FakeAudioويحتاج إلى متصفح وأجهزة فعلية:- وجود ملفات الصوت الحقيقية أو سلامة ترميزها عبر الشبكة (
HTTP 200/206 Range Requests). - سياسات التشغيل التلقائي (
Autoplay Policy/NotAllowedError) التي يفرضها المتصفح قبل تفاعل المستخدم (User Gesture). - سلوك
HTMLMediaElementوAudioContextالفعلي على متصفح Safari في iOS/iPadOS (مثل قيودaudio.volumeوإطلاق أحداثwaitingوstalledوAbortErrorعند تغييرaudio.src).
- وجود ملفات الصوت الحقيقية أو سلامة ترميزها عبر الشبكة (