{"id":"l-tj-3-3","courseId":"testing-javascript","lang":"ar","kind":"lesson","summary":"تختبر واجهة تتغيّر بعد تحميل البيانات أو فشلها، باستخدام استعلامات findBy وwaitFor بدل الانتظار الثابت، وتتحكّم في الاعتمادية غير المتزامنة لتختبر حالات التحميل والنجاح والخطأ.","html":"<p>تحمّل لوحة الأرصدة في Splitwise-lite بياناتها من الخادم. للحظة تعرض “Loading balances…”، ثم تعرض القائمة، أو، في قطار داخل نفق، “Could not load balances.” مع زر “Try again”. كل حالة من هذه الحالات شيء يراه المستخدم، لذا تستحق كل منها اختبارًا. الصعوبة في الزمن: الـ DOM الذي تريد فحصه لم يوجد بعد حين يصل الاختبار إلى السطر الذي يفحصه.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">الطريقة الخاطئة: الانتظار الثابت (sleep)</h2>\n<p>أول ما يخطر في البال هو الانتظار قليلًا:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">mountBalancePanel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, loadBalances);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> Promise</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">resolve</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> setTimeout</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(resolve, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)); </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// please be enough</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> })).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeInTheDocument</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span></code></pre></div>\n<p>على حاسوبك تصل البيانات في 5 ms، فيضيّع الاختبار 495. وعلى خادم CI مشغول تستغرق أحيانًا 600، فيفشل الاختبار بلا سبب. اضرب ذلك في بضع مئات من الاختبارات، فتحصل على مجموعة بطيئة حمراء كل ثلاثاء. <strong>انتظر الشرط، لا الساعة.</strong></p>\n<h2 id=\"sec-2\" tabindex=\"-1\">findBy: انتظر عنصرًا</h2>\n<p>استعلامات <code dir=\"ltr\">findBy</code> هي استعلامات <code dir=\"ltr\">getBy</code> تعيد المحاولة. تعيد promise يُحلّ بمجرد أن يوجد العنصر، ويُرفض إن لم يظهر خلال المهلة (1,000 ms افتراضيًا):</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">src/balance-panel.test.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// @vitest-environment jsdom</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { afterEach, test, expect, vi } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'vitest'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { screen } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/dom'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/jest-dom/vitest'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { mountBalancePanel } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './balance-panel.js'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">afterEach</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  document.body.innerHTML </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> ''</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'shows a loading message, then the balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> loadBalances</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> vi.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">fn</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">().</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">mockResolvedValue</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ Ana: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, Ben: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountBalancePanel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, loadBalances);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'status'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveTextContent</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Loading balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">findByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> })).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeInTheDocument</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">queryByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'status'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).not.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeInTheDocument</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p>الاختبار سريع حين يكون الكود سريعًا، وصبور حين يكون بطيئًا، ويُقرأ كقصة ما يراه المستخدم. والسطر الأخير مهم أيضًا: اللوحة التي تعرض البيانات لكنها تترك “Loading…” على الشاشة فيها خطأ حقيقي.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">waitFor: انتظر أي شيء آخر</h2>\n<p>يشغّل <code dir=\"ltr\">waitFor(callback)</code> الدالة، فإن رمت خطأً، يعيد تشغيلها كل 50 ms حتى تنجح أو تنتهي المهلة. استخدمه للشروط التي ليست “ظهر عنصر”:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> waitFor</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(loadBalances).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalledTimes</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">));</span></span></code></pre></div>\n<p>ولدى Vitest دالتها الخاصة <code dir=\"ltr\">vi.waitFor</code> التي تعمل بالطريقة نفسها خارج الـ DOM. وللحالة المعاكسة، ينتظر <code dir=\"ltr\">waitForElementToBeRemoved(() =&gt; screen.queryByRole('status'))</code> اختفاء شيء ما.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 200\" role=\"img\" aria-labelledby=\"l-tj-3-3-t1\"><defs><marker id=\"arrow-l-tj-3-3-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-tj-3-3-t1\">خط زمني: يُعاد تشغيل الاستعلام كل 50 ملّي ثانية؛ يفشل أثناء التحميل، ثم ينجح بمجرد عرض القائمة، قبل مهلة الـ 1000 ملّي ثانية بوقت طويل.</title>\n  <path class=\"d-line\" d=\"M40 100 L640 100\"/>\n  <text class=\"d-label-muted\" x=\"40\" y=\"130\">0 ms</text>\n  <text class=\"d-label-muted\" x=\"600\" y=\"130\">1000 ms</text>\n  <path class=\"d-line d-dashed\" d=\"M620 60 L620 140\"/>\n  <text class=\"d-label-muted\" x=\"620\" y=\"50\" text-anchor=\"middle\">المهلة</text>\n  <circle class=\"d-dot\" cx=\"60\" cy=\"100\" r=\"6\"/>\n  <circle class=\"d-dot\" cx=\"110\" cy=\"100\" r=\"6\"/>\n  <circle class=\"d-dot\" cx=\"160\" cy=\"100\" r=\"6\"/>\n  <circle class=\"d-dot\" cx=\"210\" cy=\"100\" r=\"6\"/>\n  <text class=\"d-label\" x=\"135\" y=\"80\" text-anchor=\"middle\">لم تظهر بعد</text>\n  <rect class=\"d-box-success\" x=\"250\" y=\"82\" width=\"150\" height=\"36\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"325\" y=\"105\" text-anchor=\"middle\">وُجدت القائمة</text>\n  <text class=\"d-label-muted\" x=\"325\" y=\"160\" text-anchor=\"middle\">يكمل الاختبار فورًا</text>\n</svg><figcaption>يستطلع findBy وwaitFor حتى يتحقّق الشرط</figcaption></figure>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">خطأ شائع</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>آثار جانبية داخل waitFor</span></p>\n<p>لأن <code dir=\"ltr\">waitFor</code> يعيد تشغيل دالته، فأي شيء بداخلها قد يحدث عدة مرات. النقر داخل <code dir=\"ltr\">waitFor</code> قد يُنفَّذ ثلاث مرات ويحوّل إعادة محاولة واحدة إلى ثلاث. اقصر الدالة على الاستعلامات والـ assertions؛ وانقر واكتب قبلها، مرة واحدة. ولا تلفّ <code dir=\"ltr\">findBy</code> داخل <code dir=\"ltr\">waitFor</code>: فهو ينتظر أصلًا.</p>\n</aside>\n<h2 id=\"sec-4\" tabindex=\"-1\">التحكّم في الزمن من جهة الاختبار</h2>\n<p>يتولّى <code dir=\"ltr\">findBy</code> <em>الانتظار</em>. ولتختبر كل حالة بشكل حتمي، تتحكّم أيضًا في <strong>الاعتمادية</strong>. ولأن <code dir=\"ltr\">loadBalances</code> تُمرَّر كمعامل، يستطيع <code dir=\"ltr\">vi.fn</code> أن يجعلها تفعل أي شيء:</p>\n<ul>\n<li><code dir=\"ltr\">mockResolvedValue(data)</code> للنجاح؛</li>\n<li><code dir=\"ltr\">mockRejectedValue(new Error('Offline'))</code> للفشل؛</li>\n<li>promise تحلّه يدويًا، لتجميد حالة التحميل:</li>\n</ul>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'keeps showing the loading message until data arrives'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  let</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> finish;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> loadBalances</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> vi.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">fn</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> Promise</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">resolve</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { finish </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> resolve; }));</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountBalancePanel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, loadBalances);</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'status'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveTextContent</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Loading balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  finish</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ Ana: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">findByText</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Ana is settled up'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeInTheDocument</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p>لمسار إعادة المحاولة، ابدأ بـ mock يرفض، وانتظر التنبيه، ثم حوّل الـ mock إلى النجاح، وانقر “Try again” مرة واحدة، ثم استخدم <code dir=\"ltr\">findBy</code> لإيجاد القائمة. كل خطوة شيء يفعله المستخدم أو يراه، ولا شيء يعتمد على سرعة الجهاز.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">نصيحة</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>مؤقّتات مزيّفة للواجهة المؤقّتة</span></p>\n<p>ما زال الوقت الحقيقي مهمًّا للواجهة التي تنتظر عن قصد، مثل رسالة منبثقة تختفي بعد خمس ثوانٍ. اجمع المؤقّتات المزيّفة مع user-event بتمرير <code dir=\"ltr\">advanceTimers: vi.advanceTimersByTime</code> إلى <code dir=\"ltr\">userEvent.setup()</code>، فتعمل تأخيرات الكتابة ومهلك كلها على الساعة المزيّفة.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">حين لا تكفي المهلة الافتراضية</h2>\n<p>يستسلم <code dir=\"ltr\">findBy</code> و<code dir=\"ltr\">waitFor</code> بعد ثانية واحدة. وهذا أكثر من كافٍ لكود تتحكّم في اعتمادياته، وإن احتاج اختبار مكوّن إلى وقت أطول، فالسبب المعتاد استدعاء شبكة حقيقي أو مؤقّت حقيقي تسرّب إليه. أصلح التسرّب أولًا. وحين يكون الانتظار مشروعًا فعلًا، مرّر خيارات: <code dir=\"ltr\">screen.findByRole('list', {}, { timeout: 3000 })</code> (المعامل الثالث هو خيارات الانتظار) أو <code dir=\"ltr\">waitFor(callback, { timeout: 3000 })</code>.</p>\n<p>في اختبارات React سترى أحيانًا تحذيرًا بأن تحديثًا “was not wrapped in act(…)”. يعني ذلك عادةً أن تحديثًا للـ state حدث <strong>بعد</strong> أن توقّف الاختبار عن النظر: promise ما حُلّ متأخرًا. والإصلاح لا يكون تقريبًا أبدًا بلفّ الأشياء في <code dir=\"ltr\">act</code> يدويًا؛ بل بانتظار الحالة النهائية التي سيراها المستخدم بـ <code dir=\"ltr\">await</code>، مع استعلام <code dir=\"ltr\">findBy</code>.</p>\n<h2 id=\"sec-6\" tabindex=\"-1\">في ساحة التجربة</h2>\n<p>يضيف الكود المبدئي للتمرين <code dir=\"ltr\">waitFor(callback)</code> و<code dir=\"ltr\">findByRole(root, role, options)</code> و<code dir=\"ltr\">findByText(root, text)</code> إلى بدائل الاستعلامات. تستطلع كل 20 ms وتستسلم بعد 500 ms، فيفشل الاختبار على نسخة معطوبة بسرعة.</p>\n<p>ينقل الدرس القادم هذا كله إلى مكوّنات React، وإلى استدعاءات الشبكة التي تقف خلف <code dir=\"ltr\">loadBalances</code>.</p>\n","toc":[{"id":"sec-1","text":"الطريقة الخاطئة: الانتظار الثابت (sleep)","level":2},{"id":"sec-2","text":"findBy: انتظر عنصرًا","level":2},{"id":"sec-3","text":"waitFor: انتظر أي شيء آخر","level":2},{"id":"sec-4","text":"التحكّم في الزمن من جهة الاختبار","level":2},{"id":"sec-5","text":"حين لا تكفي المهلة الافتراضية","level":2},{"id":"sec-6","text":"في ساحة التجربة","level":2}],"takeaways":["تعيد استعلامات <code dir=\"ltr\">findBy</code> وعدًا (promise) يُحلّ حين يظهر العنصر، وتعيد المحاولة حتى انتهاء المهلة (ثانية واحدة افتراضيًا).","يعيد <code dir=\"ltr\">waitFor(callback)</code> تشغيل الدالة حتى تتوقّف عن رمي الأخطاء؛ استخدمه مع assertions لا تتعلّق بظهور عنصر، مثل عدد استدعاءات mock.","لا تنتظر وقتًا ثابتًا أبدًا (sleep)؛ انتظر الشرط الذي يهمّك فعلًا.","تحكّم في الاعتمادية غير المتزامنة بنفسك (<code dir=\"ltr\">vi.fn</code> يُحلّ، أو يُرفض، أو يبقى معلّقًا)، كي تكون حالات التحميل والنجاح والخطأ وإعادة المحاولة كل منها اختبارًا حتميًّا واحدًا."],"further":[{"title":"Testing Library — Async methods","url":"https://testing-library.com/docs/dom-testing-library/api-async"},{"title":"Testing Library — Appearance and disappearance","url":"https://testing-library.com/docs/guide-disappearance"},{"title":"Vitest — vi.waitFor","url":"https://vitest.dev/api/vi#vi-waitfor"}],"quiz":[{"q":"<p>تظهر قائمة الأرصدة بعد أن يُحلّ <code dir=\"ltr\">loadBalances()</code>. أيّ سطر ينتظرها بشكل صحيح؟</p>","options":[{"text":"<code dir=\"ltr\">await new Promise((r) =&gt; setTimeout(r, 500)); screen.getByRole('list');</code>","why":"الانتظار الثابت أطول من اللازم على جهاز سريع، وأقصر من اللازم على خادم CI مشغول؛ وهو المصدر الكلاسيكي لاختبارات الواجهة المتقلّبة."},{"text":"<code dir=\"ltr\">const list = await screen.findByRole('list', { name: 'Balances' });</code>","why":"صحيح. يعيد <code dir=\"ltr\">findByRole</code> المحاولة حتى تظهر القائمة (أو تمرّ ثانية)، فينتظر بالضبط المدة اللازمة."},{"text":"<code dir=\"ltr\">const list = screen.getByRole('list', { name: 'Balances' });</code>","why":"يفحص <code dir=\"ltr\">getByRole</code> مرة واحدة، فورًا، قبل أن يُحلّ الـ promise، فيرمي خطأً."}],"answer":1},{"q":"<p>ما المشكلة هنا؟</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> waitFor</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  user.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">click</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Try again'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }));</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(loadBalances).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalledTimes</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n","options":[{"text":"لا يمكن أن يحتوي <code dir=\"ltr\">waitFor</code> على استدعاءات <code dir=\"ltr\">expect</code>.","why":"الـ assertions هي بالضبط ما يوضع داخل <code dir=\"ltr\">waitFor</code>؛ فهو يعيد تشغيل الدالة حتى تتوقّف عن رمي الأخطاء."},{"text":"<code dir=\"ltr\">toHaveBeenCalledTimes</code> لا يعمل مع الدوال غير المتزامنة.","why":"يعدّ الاستدعاءات على أي mock، متزامنًا كان أو غير متزامن؛ وهذا السطر سليم وحده."},{"text":"النقر أثر جانبي داخل دالة يُعاد تشغيلها، فقد يُنفَّذ عدة مرات ويضخّم عدد الاستدعاءات.","why":"صحيح. انقر مرة واحدة، خارجها، ثم <code dir=\"ltr\">await waitFor(() =&gt; expect(loadBalances).toHaveBeenCalledTimes(2))</code>."}],"answer":2},{"q":"<p>كيف تختبر أن رسالة “Loading balances…” تظهر <em>قبل</em> وصول البيانات، دون أن تتسابق مع الـ promise؟</p>","options":[{"text":"اجعل <code dir=\"ltr\">loadBalances</code> تعيد promise يحلّه الاختبار يدويًا، ثم أكّد رسالة التحميل، ثم حلّه.","why":"صحيح. الـ promise المؤجَّل يجمّد المكوّن في حالة التحميل للمدة التي تحتاجها، فيكون الاختبار حتميًّا."},{"text":"أضف تأخيرًا بمقدار 50 ms إلى <code dir=\"ltr\">loadBalances</code> الحقيقية في الاختبارات.","why":"أي طريقة تعتمد على التوقيت تتسابق: على خادم بطيء قد يفوت الـ assertion أو يصيب في اللحظة الخطأ."},{"text":"استخدم <code dir=\"ltr\">findByRole('status')</code> كي ينتظر الرسالة.","why":"الرسالة تظهر فورًا، فإيجادها ليس الجزء الصعب؛ الخطر أن تكون البيانات قد حلّت محلّها بحلول الوقت الذي تنظر فيه."}],"answer":0}],"exercise":{"kind":"web","mode":"test","subject":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '<p role=\"status\">Loading balances…</p>';\n    try {\n      const balances = await loadBalances();\n      renderBalances(container, balances);\n    } catch {\n      container.innerHTML =\n        '<p role=\"alert\">Could not load balances.</p><button type=\"button\">Try again</button>';\n      container.querySelector('button').addEventListener('click', load);\n    }\n  }\n  load();\n}\n","mutants":[{"id":"no-loading","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '';\n    try {\n      const balances = await loadBalances();\n      renderBalances(container, balances);\n    } catch {\n      container.innerHTML =\n        '<p role=\"alert\">Could not load balances.</p><button type=\"button\">Try again</button>';\n      container.querySelector('button').addEventListener('click', load);\n    }\n  }\n  load();\n}\n"},{"id":"stale-loading","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '<p role=\"status\">Loading balances…</p>';\n    try {\n      const balances = await loadBalances();\n      const box = document.createElement('div');\n      container.append(box);\n      renderBalances(box, balances);\n    } catch {\n      container.innerHTML =\n        '<p role=\"alert\">Could not load balances.</p><button type=\"button\">Try again</button>';\n      container.querySelector('button').addEventListener('click', load);\n    }\n  }\n  load();\n}\n"},{"id":"error-hidden","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '<p role=\"status\">Loading balances…</p>';\n    try {\n      const balances = await loadBalances();\n      renderBalances(container, balances);\n    } catch {\n      container.innerHTML = '';\n    }\n  }\n  load();\n}\n"},{"id":"no-retry","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '<p role=\"status\">Loading balances…</p>';\n    try {\n      const balances = await loadBalances();\n      renderBalances(container, balances);\n    } catch {\n      container.innerHTML =\n        '<p role=\"alert\">Could not load balances.</p><button type=\"button\">Try again</button>';\n    }\n  }\n  load();\n}\n"}],"starter":{"js":"// ── Tiny stand-ins for Testing Library queries (no npm in the playground) ──\nconst ROLE_SELECTORS = {\n  alert: '[role=\"alert\"]',\n  button: 'button, [role=\"button\"]',\n  list: 'ul, ol, [role=\"list\"]',\n  listitem: 'li, [role=\"listitem\"]',\n  status: '[role=\"status\"]',\n  textbox: 'input:not([type]), input[type=\"text\"], textarea, [role=\"textbox\"]',\n};\nfunction accessibleName(el) {\n  if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');\n  if (el.labels && el.labels.length) return el.labels[0].textContent.trim();\n  return el.textContent.trim();\n}\nfunction queryAllByRole(root, role, { name } = {}) {\n  return [...root.querySelectorAll(ROLE_SELECTORS[role])]\n    .filter((el) => !el.closest('[hidden]'))\n    .filter((el) => name === undefined || accessibleName(el) === name);\n}\nfunction queryByRole(root, role, options) {\n  return queryAllByRole(root, role, options)[0] ?? null;\n}\nfunction getAllByRole(root, role, options = {}) {\n  const found = queryAllByRole(root, role, options);\n  if (!found.length) throw new Error(`Unable to find role \"${role}\"${options.name ? ` named \"${options.name}\"` : ''}`);\n  return found;\n}\nfunction getByRole(root, role, options = {}) {\n  const found = getAllByRole(root, role, options);\n  if (found.length > 1) throw new Error(`Found ${found.length} elements with role \"${role}\"`);\n  return found[0];\n}\nfunction queryByText(root, text) {\n  return [...root.querySelectorAll('*')].find((el) =>\n    !el.closest('[hidden]') &&\n    el.textContent.trim() === text &&\n    ![...el.children].some((child) => child.textContent.trim() === text)) ?? null;\n}\nfunction getByText(root, text) {\n  const el = queryByText(root, text);\n  if (!el) throw new Error(`Unable to find text \"${text}\"`);\n  return el;\n}\n// ────────────────────────────────────────────────────────────────────────────\n\n// ── Stand-ins for waitFor and findBy (poll every 20 ms, give up after 500 ms) ──\nasync function waitFor(callback, { timeout = 500, interval = 20 } = {}) {\n  const start = Date.now();\n  for (;;) {\n    try {\n      return await callback();\n    } catch (error) {\n      if (Date.now() - start > timeout) throw error;\n      await new Promise((resolve) => setTimeout(resolve, interval));\n    }\n  }\n}\nconst findByRole = (root, role, options) => waitFor(() => getByRole(root, role, options));\nconst findByText = (root, text) => waitFor(() => getByText(root, text));\n// ────────────────────────────────────────────────────────────────────────────\n\n// ── Stand-ins for user-event (synchronous here, so no await) ──\nfunction typeInto(el, text) {\n  clickOn(el);\n  for (const key of text) {\n    el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));\n    el.value += key;\n    el.dispatchEvent(new InputEvent('input', { data: key, inputType: 'insertText', bubbles: true }));\n    el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));\n  }\n}\nfunction clickOn(el) {\n  if (el.disabled) return;\n  el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));\n  el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));\n  el.focus();\n  el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));\n  el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));\n  el.click();\n}\n// ────────────────────────────────────────────────────────────────────────────\n\nlet container;\n\nbeforeEach(() => {\n  container = document.createElement('div');\n  document.body.append(container);\n});\n\nafterEach(() => {\n  container.remove();\n});\n\ndescribe('balance panel', () => {\n  test('eventually shows the balances', async () => {\n    const loadBalances = vi.fn().mockResolvedValue({ Ana: 500, Ben: -500 });\n\n    mountBalancePanel(container, loadBalances);\n\n    expect(await findByRole(container, 'list', { name: 'Balances' })).toBeTruthy();\n  });\n\n  // Add: the loading status (and that it goes away),\n  // the error alert, and the \"Try again\" button.\n});\n"},"solution":{"js":"// ── Tiny stand-ins for Testing Library queries (no npm in the playground) ──\nconst ROLE_SELECTORS = {\n  alert: '[role=\"alert\"]',\n  button: 'button, [role=\"button\"]',\n  list: 'ul, ol, [role=\"list\"]',\n  listitem: 'li, [role=\"listitem\"]',\n  status: '[role=\"status\"]',\n  textbox: 'input:not([type]), input[type=\"text\"], textarea, [role=\"textbox\"]',\n};\nfunction accessibleName(el) {\n  if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');\n  if (el.labels && el.labels.length) return el.labels[0].textContent.trim();\n  return el.textContent.trim();\n}\nfunction queryAllByRole(root, role, { name } = {}) {\n  return [...root.querySelectorAll(ROLE_SELECTORS[role])]\n    .filter((el) => !el.closest('[hidden]'))\n    .filter((el) => name === undefined || accessibleName(el) === name);\n}\nfunction queryByRole(root, role, options) {\n  return queryAllByRole(root, role, options)[0] ?? null;\n}\nfunction getAllByRole(root, role, options = {}) {\n  const found = queryAllByRole(root, role, options);\n  if (!found.length) throw new Error(`Unable to find role \"${role}\"${options.name ? ` named \"${options.name}\"` : ''}`);\n  return found;\n}\nfunction getByRole(root, role, options = {}) {\n  const found = getAllByRole(root, role, options);\n  if (found.length > 1) throw new Error(`Found ${found.length} elements with role \"${role}\"`);\n  return found[0];\n}\nfunction queryByText(root, text) {\n  return [...root.querySelectorAll('*')].find((el) =>\n    !el.closest('[hidden]') &&\n    el.textContent.trim() === text &&\n    ![...el.children].some((child) => child.textContent.trim() === text)) ?? null;\n}\nfunction getByText(root, text) {\n  const el = queryByText(root, text);\n  if (!el) throw new Error(`Unable to find text \"${text}\"`);\n  return el;\n}\n// ────────────────────────────────────────────────────────────────────────────\n\n// ── Stand-ins for waitFor and findBy (poll every 20 ms, give up after 500 ms) ──\nasync function waitFor(callback, { timeout = 500, interval = 20 } = {}) {\n  const start = Date.now();\n  for (;;) {\n    try {\n      return await callback();\n    } catch (error) {\n      if (Date.now() - start > timeout) throw error;\n      await new Promise((resolve) => setTimeout(resolve, interval));\n    }\n  }\n}\nconst findByRole = (root, role, options) => waitFor(() => getByRole(root, role, options));\nconst findByText = (root, text) => waitFor(() => getByText(root, text));\n// ────────────────────────────────────────────────────────────────────────────\n\n// ── Stand-ins for user-event (synchronous here, so no await) ──\nfunction typeInto(el, text) {\n  clickOn(el);\n  for (const key of text) {\n    el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));\n    el.value += key;\n    el.dispatchEvent(new InputEvent('input', { data: key, inputType: 'insertText', bubbles: true }));\n    el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));\n  }\n}\nfunction clickOn(el) {\n  if (el.disabled) return;\n  el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));\n  el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));\n  el.focus();\n  el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));\n  el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));\n  el.click();\n}\n// ────────────────────────────────────────────────────────────────────────────\n\nlet container;\n\nbeforeEach(() => {\n  container = document.createElement('div');\n  document.body.append(container);\n});\n\nafterEach(() => {\n  container.remove();\n});\n\ndescribe('balance panel', () => {\n  test('shows a loading status, then the balances', async () => {\n    const loadBalances = vi.fn().mockResolvedValue({ Ana: 500, Ben: -500 });\n\n    mountBalancePanel(container, loadBalances);\n\n    expect(getByRole(container, 'status').textContent).toMatch('Loading balances');\n    expect(await findByRole(container, 'list', { name: 'Balances' })).toBeTruthy();\n    expect(queryByRole(container, 'status')).toBeNull();\n  });\n\n  test('explains a failure and retries on request', async () => {\n    const loadBalances = vi.fn().mockRejectedValue(new Error('Offline'));\n\n    mountBalancePanel(container, loadBalances);\n    expect((await findByRole(container, 'alert')).textContent).toBe('Could not load balances.');\n\n    loadBalances.mockResolvedValue({ Ana: 0 });\n    clickOn(getByRole(container, 'button', { name: 'Try again' }));\n\n    expect(await findByText(container, 'Ana is settled up')).toBeTruthy();\n    expect(loadBalances).toHaveBeenCalledTimes(2);\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-no-loading","suite":"no-loading"},{"id":"catches-stale-loading","suite":"stale-loading"},{"id":"catches-error-hidden","suite":"error-hidden"},{"id":"catches-no-retry","suite":"no-retry"}],"title":"التحميل والخطأ وإعادة المحاولة","prompt":"<p>تعرض <code dir=\"ltr\">mountBalancePanel(container, loadBalances)</code> الرسالة “Loading balances…” في <code dir=\"ltr\">role=&quot;status&quot;</code> ما دام <code dir=\"ltr\">loadBalances()</code> معلّقًا، ثم قائمة <strong>Balances</strong>. وإن رُفض الـ promise، تعرض “Could not load balances.” في <code dir=\"ltr\">role=&quot;alert&quot;</code> مع زر <strong>Try again</strong> يحمّل من جديد.</p>\n<p>يضيف الكود المبدئي <code dir=\"ltr\">waitFor</code> و<code dir=\"ltr\">findByRole</code> و<code dir=\"ltr\">findByText</code> إلى البدائل (وهي تستطلع حتى 500 ms). تحكّم في <code dir=\"ltr\">loadBalances</code> باستخدام <code dir=\"ltr\">vi.fn()</code> وأضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: نسخة <strong>لا تعرض</strong> حالة التحميل أبدًا، ونسخة <strong>تُبقيها</strong> على الشاشة بعد وصول البيانات، ونسخة <strong>لا تعرض شيئًا</strong> عند الفشل، ونسخة لا يفعل زر <strong>Try again</strong> فيها شيئًا.</p>\n","hints":["تُضبط حالة التحميل بشكل متزامن عند تركيب اللوحة، لذا يجدها <code dir=\"ltr\">getByRole(container, 'status')</code> مباشرة بعد <code dir=\"ltr\">mountBalancePanel(...)</code>. وبعد <code dir=\"ltr\">await findByRole(... 'list' ...)</code> يجب أن يكون <code dir=\"ltr\">queryByRole(container, 'status')</code> مساويًا <code dir=\"ltr\">null</code>.","للفشل: <code dir=\"ltr\">vi.fn().mockRejectedValue(new Error('Offline'))</code>، ثم <code dir=\"ltr\">await findByRole(container, 'alert')</code>.","لإعادة المحاولة: بعد ظهور التنبيه، استدعِ <code dir=\"ltr\">loadBalances.mockResolvedValue({ Ana: 0 })</code>، وانقر Try again بـ <code dir=\"ltr\">clickOn</code>، ثم <code dir=\"ltr\">await findByText(container, 'Ana is settled up')</code>."],"explanation":"<p>كل حالة assertion واحد عمّا يراه المستخدم. فحص حالة التحميل مباشرة بعد التركيب يلتقط اللوحة التي لا تعرض رسالة تحميل؛ وفحص اختفائها بعد أن يُحلّ <code dir=\"ltr\">findByRole</code> يلتقط اللوحة التي تتركها خلفها. وينتظر اختبار الفشل التنبيه بـ <code dir=\"ltr\">findByRole</code>، الذي تنتهي مهلته إن ابتُلع الخطأ. وتحويل الـ mock إلى النجاح قبل النقر على Try again يجعل إعادة المحاولة قصة حتمية: إن لم يكن الزر موصولًا، لا يظهر نص التسوية أبدًا ويفشل الاختبار. لا يوجد أي انتظار ثابت في أي مكان، فالمجموعة بسرعة الكود نفسه.</p>\n","checkLabels":{"passes":"كل اختباراتك تنجح على اللوحة الحقيقية","catches-no-loading":"تلتقط النسخة التي لا تعرض حالة التحميل","catches-stale-loading":"تلتقط النسخة التي تُبقي حالة التحميل على الشاشة","catches-error-hidden":"تلتقط النسخة التي لا تعرض شيئًا عند الفشل","catches-no-retry":"تلتقط النسخة التي لا يفعل زر Try again فيها شيئًا"}},"words":556,"readMinutes":3}