{"id":"l-tj-3-2","courseId":"testing-javascript","lang":"ar","kind":"lesson","summary":"تقود النماذج والأزرار في الاختبارات كما يفعل الإنسان، وتختار user-event بدل fireEvent، وتؤكّد ما يراه المستخدم وما يبلّغ عنه الكود.","html":"<p>نموذج المصروفات هو المكان الذي يلتقي فيه Splitwise-lite بالناس الحقيقيين، والناس الحقيقيون يفعلون أشياء فوضوية: يتنقّلون بين الحقول بمفتاح Tab، ويلصقون &quot; 45.50 &quot;، وينقرون “Add” وحقل المبلغ ما زال فارغًا، وينقرون نقرًا مزدوجًا. لا يستطيع unit test لـ <code dir=\"ltr\">parseAmount</code> أن يخبرك إن كان النموذج يربط كل ذلك بشكل صحيح. أما الاختبار الذي يستخدم النموذج كما يستخدمه إنسان فيستطيع.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">fireEvent مقابل user-event</h2>\n<p>تأتي Testing Library بطريقتين للتفاعل.</p>\n<p><strong><code dir=\"ltr\">fireEvent</code></strong> (من <code dir=\"ltr\">@testing-library/dom</code>) يُطلق حدث DOM واحدًا بالضبط. <code dir=\"ltr\">fireEvent.click(button)</code> يرسل <code dir=\"ltr\">click</code>. و<code dir=\"ltr\">fireEvent.change(input, { target: { value: '45.50' } })</code> يضبط القيمة ويرسل <code dir=\"ltr\">change</code>. إنه سريع ودقيق، لكنه ليس ما يحدث حين ينقر إنسان أو يكتب.</p>\n<p><strong><code dir=\"ltr\">user-event</code></strong> (من <code dir=\"ltr\">@testing-library/user-event</code>) يحاكي التفاعل نفسه. <code dir=\"ltr\">user.click(button)</code> يُنتج أحداث المؤشّر والفأرة، وينقل التركيز، ثم يُطلق <code dir=\"ltr\">click</code>، ويرفض النقر على زر معطّل أو مخفيّ تحت <code dir=\"ltr\">pointer-events: none</code>، تمامًا كالمتصفّح الحقيقي. و<code dir=\"ltr\">user.type(input, '45.50')</code> ينقر الحقل، ثم يرسل لكل حرف <code dir=\"ltr\">keydown</code> و<code dir=\"ltr\">keypress</code> و<code dir=\"ltr\">input</code> و<code dir=\"ltr\">keyup</code>.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 210\" role=\"img\" aria-labelledby=\"l-tj-3-2-t1\"><defs><marker id=\"arrow-l-tj-3-2-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-2-t1\">يرسل fireEvent.click حدث click فقط. أما user.click فيرسل pointerdown وmousedown وfocus وpointerup وmouseup ثم click.</title>\n  <text class=\"d-code\" x=\"20\" y=\"48\">fireEvent.click</text>\n  <rect class=\"d-box-warn\" x=\"200\" y=\"26\" width=\"90\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"245\" y=\"48\" text-anchor=\"middle\">click</text>\n  <text class=\"d-code\" x=\"20\" y=\"138\">user.click</text>\n  <rect class=\"d-box-accent\" x=\"140\" y=\"116\" width=\"100\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"190\" y=\"138\" text-anchor=\"middle\">pointerdown</text>\n  <rect class=\"d-box-accent\" x=\"250\" y=\"116\" width=\"90\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"295\" y=\"138\" text-anchor=\"middle\">mousedown</text>\n  <rect class=\"d-box-primary\" x=\"350\" y=\"116\" width=\"60\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"380\" y=\"138\" text-anchor=\"middle\">focus</text>\n  <rect class=\"d-box-accent\" x=\"420\" y=\"116\" width=\"80\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"460\" y=\"138\" text-anchor=\"middle\">pointerup</text>\n  <rect class=\"d-box-accent\" x=\"510\" y=\"116\" width=\"74\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"547\" y=\"138\" text-anchor=\"middle\">mouseup</text>\n  <rect class=\"d-box-success\" x=\"594\" y=\"116\" width=\"70\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"629\" y=\"138\" text-anchor=\"middle\">click</text>\n  <text class=\"d-label-muted\" x=\"340\" y=\"190\" text-anchor=\"middle\">تعمل معالِجات أيّ من هذه الأحداث، كما في المتصفّح</text>\n</svg><figcaption>حدث واحد مقابل التسلسل الذي يُحدثه إنسان</figcaption></figure>\n<p>اجعل user-event خيارك الافتراضي. ولا تلجأ إلى <code dir=\"ltr\">fireEvent</code> إلا مع أحداث لا يحاكيها user-event، مثل حدث مخصّص أو <code dir=\"ltr\">scroll</code>.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">اختبار نموذج المصروفات</h2>\n<p>في النموذج حقلان لهما تسميتان، وزر “Add expense”، ومنطقة للتنبيه. إليك المسار السعيد، مكتوبًا بالطريقة التي ستصفه بها لزميل:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/expense-form.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:#1F2328;--shiki-dark:#E6EDF3\"> userEvent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/user-event'</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\"> { mountExpenseForm } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './expense-form.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\">'adds a valid expense in cents and clears the form'</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\"> user</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> userEvent.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setup</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\"> onAdd</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>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountExpenseForm</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, onAdd);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> user.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">type</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\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Description'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Dinner'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> user.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">type</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\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'45.50'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><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\">'Add expense'</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\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(onAdd).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalledWith</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ description: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Dinner'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, amountCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">4550</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\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }).value).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</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></code></pre></div>\n<p>لاحظ ثلاثة أشياء. يأتي <code dir=\"ltr\">userEvent.setup()</code> أولًا ويعطيك جلسة <code dir=\"ltr\">user</code> تتتبّع حالة لوحة المفاتيح والمؤشّر عبر الاستدعاءات. و<strong>كل تفاعل يُنتظَر بـ await</strong>: فدوال user-event غير متزامنة، وبدون <code dir=\"ltr\">await</code> تعمل الـ assertions قبل أن تنتهي الكتابة. والـ assertions تتعلّق <strong>بالنتائج</strong>: ما استلمته <code dir=\"ltr\">onAdd</code> (العقد مع بقية التطبيق) وما يراه المستخدم الآن.</p>\n<p>ومستخدمو لوحة المفاتيح مهمّون أيضًا. ينقل <code dir=\"ltr\">await user.tab()</code> التركيز كما يفعل مفتاح Tab، ويضغط <code dir=\"ltr\">await user.keyboard('{Enter}')</code> مفتاح Enter، فتستطيع اختبار أن النموذج يعمل دون فأرة.</p>\n<h3 id=\"sec-3\" tabindex=\"-1\">ما لن يفعله user-event من أجلك</h3>\n<p>يحاكي user-event السلوك الافتراضي للمتصفّح في التفاعل، لكنه لا يرى تخطيط الـ CSS لديك. لن يلاحظ أن نافذة منبثقة تغطّي الزر بصريًا، أو أن زر “Add expense” خارج الشاشة على الهاتف. ليس في jsdom محرّك تخطيط، فكل الأحجام والمواقع أصفار. هذه مهام اختبارات الـ end-to-end في متصفّح حقيقي، وهذا ما يتّجه إليه القسم 4. أما في اختبارات الوحدات والمكوّنات، فيعطيك user-event <em>تسلسل الأحداث</em> الصحيح و<em>سلوك التركيز</em> الصحيح، وهذا يغطّي الغالبية العظمى من أخطاء النماذج.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">المسار غير السعيد هو معظم العمل</h2>\n<p>تعيش معظم أخطاء النماذج في معالجة الأخطاء، فأعطها على الأقل عدد الاختبارات نفسه. كتابة <code dir=\"ltr\">abc</code> كمبلغ ثم النقر على “Add expense” يجب أن:</p>\n<ol>\n<li>يعرض السبب في عنصر له <code dir=\"ltr\">role=&quot;alert&quot;</code>، وهو ما تعلنه قارئات الشاشة؛</li>\n<li><strong>لا</strong> يستدعي <code dir=\"ltr\">onAdd</code>؛</li>\n<li><strong>يُبقي</strong> ما كتبه المستخدم، كي يصلح حرفًا واحدًا بدل إعادة الكتابة.</li>\n</ol>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"1\"><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\">'explains an invalid amount and keeps the input'</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\"> user</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> userEvent.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setup</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\"> onAdd</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>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountExpenseForm</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, onAdd);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> user.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">type</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\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Description'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Taxi'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> user.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">type</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\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'abc'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><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\">'Add expense'</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\">  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\">'alert'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).textContent).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toMatch</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Not a valid amount'</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\">(onAdd).not.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalled</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\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }).value).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'abc'</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>كل assertion يحمي من خطأ مختلف. احذف فحص التنبيه، فينجح الفشل الصامت. احذف <code dir=\"ltr\">not.toHaveBeenCalled</code>، فينجح نموذج يحفظ قيمًا عبثية. احذف الأخير، فينجح نموذج يمسح ما كتبه المستخدم.</p>\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>تأكيد التفاصيل الداخلية للمكوّن بعد التفاعل</span></p>\n<p>بعد النقر، يغريك أن تفحص متغيّرًا خاصًّا، أو صنف CSS مثل <code dir=\"ltr\">.has-error</code>، أو عدد مرات تشغيل دالة <code dir=\"ltr\">validate</code> داخلية. لا شيء من ذلك يدركه المستخدم، وكلها تتغيّر أثناء إعادة الهيكلة. أكّد الرسالة الظاهرة، وقيم الحقول، والدالة الراجعة؛ هذا هو العقد.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">في ساحة التجربة</h2>\n<p>لا توجد حزمة user-event في البيئة المعزولة، لذا يتضمّن الكود المبدئي للتمرين دالتين مساعدتين صغيرتين: <code dir=\"ltr\">typeInto(element, text)</code>، التي تضع التركيز على الحقل وترسل أحداث المفاتيح والإدخال لكل حرف، و<code dir=\"ltr\">clickOn(element)</code>، التي ترسل تسلسل المؤشّر والفأرة، وتضع التركيز وتنقر (ولا تفعل شيئًا مع زر معطّل). كلتاهما متزامنة، فلا تضع قبلهما <code dir=\"ltr\">await</code>. وتمنع البيئة المعزولة أيضًا الإرسال الحقيقي للنماذج، لذا يستمع العنصر إلى نقر الزر بدل حدث <code dir=\"ltr\">submit</code>؛ أما في تطبيقك، فـ <code dir=\"ltr\">&lt;form&gt;</code> حقيقي مع معالِج submit أفضل، لأنه يعطيك الإرسال بمفتاح Enter دون جهد.</p>\n<p>بعد ذلك، أصعب جزء في اختبار الواجهة: الأشياء التي تحدث لاحقًا.</p>\n","toc":[{"id":"sec-1","text":"fireEvent مقابل user-event","level":2},{"id":"sec-2","text":"اختبار نموذج المصروفات","level":2},{"id":"sec-3","text":"ما لن يفعله user-event من أجلك","level":3},{"id":"sec-4","text":"المسار غير السعيد هو معظم العمل","level":2},{"id":"sec-5","text":"في ساحة التجربة","level":2}],"takeaways":["يُطلق <code dir=\"ltr\">fireEvent</code> حدث DOM واحدًا؛ أما <code dir=\"ltr\">user-event</code> فيؤدّي التسلسل الكامل الذي يُحدثه إنسان حقيقي (المؤشّر، والتركيز، وkeydown، وinput، وkeyup، والنقر).","أنشئ جلسة بـ <code dir=\"ltr\">userEvent.setup()</code> وضع <code dir=\"ltr\">await</code> قبل كل تفاعل، لأن دوال user-event غير متزامنة.","اختبر النموذج من خلال نتائجه: معاملات الدالة الراجعة (callback)، والخطأ الذي سيقرؤه المستخدم، وحالة الحقول بعد ذلك.","اختبر المسار غير الصالح بالعناية نفسها التي تختبر بها الصالح؛ “لم يحدث شيء” سلوك يجب تأكيده."],"further":[{"title":"Testing Library — user-event introduction","url":"https://testing-library.com/docs/user-event/intro"},{"title":"Testing Library — Firing events","url":"https://testing-library.com/docs/dom-testing-library/api-events"},{"title":"Testing Library — user-event setup","url":"https://testing-library.com/docs/user-event/setup"}],"quiz":[{"q":"<p>ينسّق حقل المبلغ المدخلات عند كل <code dir=\"ltr\">keydown</code>. يستخدم اختبار <code dir=\"ltr\">fireEvent.change(input, { target: { value: '45.50' } })</code>، فلا يعمل كود التنسيق أبدًا. لماذا؟</p>","options":[{"text":"يضبط <code dir=\"ltr\">fireEvent.change</code> القيمة ويُطلق حدث <code dir=\"ltr\">change</code> واحدًا، دون أي أحداث مفاتيح.","why":"صحيح. لم يضغط شيء أي مفتاح، فلا تعمل معالِجات <code dir=\"ltr\">keydown</code> أبدًا. أما <code dir=\"ltr\">await user.type(input, '45.50')</code> فيُنتج أحداث مفاتيح لكل حرف."},{"text":"<code dir=\"ltr\">fireEvent</code> لا يعمل إلا مع مكوّنات React.","why":"يأتي <code dir=\"ltr\">fireEvent</code> من <code dir=\"ltr\">@testing-library/dom</code> ويعمل مع أي DOM؛ المشكلة في نوع الأحداث التي يرسلها."},{"text":"أحداث change لا تنتشر صعودًا (bubble) في jsdom.","why":"الانتشار ليس المشكلة؛ فمعالِج <code dir=\"ltr\">keydown</code> لا ينتظر حدث change أصلًا."}],"answer":0},{"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:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'adds the expense'</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\"> user</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> userEvent.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setup</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountExpenseForm</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, onAdd);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  user.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">type</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\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'12'</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\">'Add expense'</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\">(onAdd).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalled</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\">userEvent.setup()</code> داخل <code dir=\"ltr\">beforeEach</code>.","why":"استدعاء <code dir=\"ltr\">setup()</code> في بداية كل اختبار هو النمط الموصى به؛ ولا حاجة إلى hook."},{"text":"لا يستطيع <code dir=\"ltr\">getByRole</code> إيجاد حقول الإدخال؛ يجب استخدام <code dir=\"ltr\">getByTestId</code>.","why":"لحقول الإدخال الدور <code dir=\"ltr\">textbox</code>، ومع وجود تسمية يصبح لها اسم قابل للوصول، فـ <code dir=\"ltr\">getByRole</code> هو الـ query الصحيح."},{"text":"التفاعلات بلا await، فيعمل الـ assertion قبل انتهاء الكتابة والنقر.","why":"صحيح. دوال user-event تعيد promises؛ اجعل الاختبار <code dir=\"ltr\">async</code> وضع <code dir=\"ltr\">await</code> قبل كل استدعاء."}],"answer":2},{"q":"<p>كتابة <code dir=\"ltr\">'abc'</code> في حقل المبلغ ثم النقر على “Add expense” يجب أن يُظهر خطأً. أيّ مجموعة assertions تختبر هذا المسار على أفضل وجه؟</p>","options":[{"text":"التنبيه ظاهر.","why":"بداية جيدة، لكن النسخة التي تُظهر التنبيه <em>وتستدعي</em> <code dir=\"ltr\">onAdd</code> بقيمة عبثية ستنجح أيضًا."},{"text":"التنبيه يعرض الرسالة، و<code dir=\"ltr\">onAdd</code> لم تُستدعَ، والنص المكتوب ما زال في الحقل.","why":"صحيح. كل assertion يحمي من خطأ مختلف: الفشل الصامت، وحفظ مصروف سيئ، ومسح ما كتبه المستخدم."},{"text":"<code dir=\"ltr\">onAdd</code> لم تُستدعَ.","why":"النسخة التي تتجاهل النقر بصمت تنجح في هذا أيضًا؛ وسيبقى المستخدم يخمّن ما حدث."}],"answer":1}],"exercise":{"kind":"web","mode":"test","subject":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      alert.hidden = false;\n      return;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents });\n    description.value = '';\n    amount.value = '';\n  });\n}\n","mutants":[{"id":"adds-invalid","code":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      alert.hidden = false;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents });\n    description.value = '';\n    amount.value = '';\n  });\n}\n"},{"id":"keeps-values","code":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      alert.hidden = false;\n      return;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents });\n\n  });\n}\n"},{"id":"dollars-not-cents","code":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      alert.hidden = false;\n      return;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents: Number(amount.value) });\n    description.value = '';\n    amount.value = '';\n  });\n}\n"},{"id":"silent-error","code":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      return;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents });\n    description.value = '';\n    amount.value = '';\n  });\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 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('expense form', () => {\n  test('has the fields and the button', () => {\n    mountExpenseForm(container, vi.fn());\n\n    expect(getByRole(container, 'textbox', { name: 'Description' })).toBeTruthy();\n    expect(getByRole(container, 'textbox', { name: 'Amount' })).toBeTruthy();\n    expect(getByRole(container, 'button', { name: 'Add expense' })).toBeTruthy();\n  });\n\n  // Add: the happy path (what onAdd receives, the fields afterwards)\n  // and the invalid-amount path (alert, onAdd, the typed text).\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 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('expense form', () => {\n  const description = () => getByRole(container, 'textbox', { name: 'Description' });\n  const amount = () => getByRole(container, 'textbox', { name: 'Amount' });\n  const addButton = () => getByRole(container, 'button', { name: 'Add expense' });\n\n  test('has the fields and the button', () => {\n    mountExpenseForm(container, vi.fn());\n\n    expect(description()).toBeTruthy();\n    expect(amount()).toBeTruthy();\n    expect(addButton()).toBeTruthy();\n  });\n\n  test('adds a valid expense in cents and clears the form', () => {\n    const onAdd = vi.fn();\n    mountExpenseForm(container, onAdd);\n\n    typeInto(description(), 'Dinner');\n    typeInto(amount(), '45.50');\n    clickOn(addButton());\n\n    expect(onAdd).toHaveBeenCalledWith({ description: 'Dinner', amountCents: 4550 });\n    expect(description().value).toBe('');\n    expect(amount().value).toBe('');\n  });\n\n  test('explains an invalid amount and keeps the input', () => {\n    const onAdd = vi.fn();\n    mountExpenseForm(container, onAdd);\n\n    typeInto(description(), 'Taxi');\n    typeInto(amount(), 'abc');\n    clickOn(addButton());\n\n    expect(getByRole(container, 'alert').textContent).toMatch('Not a valid amount');\n    expect(onAdd).not.toHaveBeenCalled();\n    expect(amount().value).toBe('abc');\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-adds-invalid","suite":"adds-invalid"},{"id":"catches-keeps-values","suite":"keeps-values"},{"id":"catches-dollars-not-cents","suite":"dollars-not-cents"},{"id":"catches-silent-error","suite":"silent-error"}],"title":"استخدم نموذج المصروفات كما يستخدمه إنسان","prompt":"<p>تعرض <code dir=\"ltr\">mountExpenseForm(container, onAdd)</code> حقلين تسميتاهما <strong>Description</strong> و<strong>Amount</strong>، وزر <strong>Add expense</strong>. النقرة الصالحة تستدعي <code dir=\"ltr\">onAdd({ description, amountCents })</code> وتمسح الحقول؛ وغير الصالحة تعرض السبب في <code dir=\"ltr\">role=&quot;alert&quot;</code>، ولا تستدعي <code dir=\"ltr\">onAdd</code>، وتُبقي النص المكتوب.</p>\n<p>يتضمّن الكود المبدئي بدائل الاستعلامات من التمرين السابق، إضافة إلى <code dir=\"ltr\">typeInto(el, text)</code> و<code dir=\"ltr\">clickOn(el)</code> اللتين تحاكيان user-event (وهما متزامنتان: بلا <code dir=\"ltr\">await</code>). أضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: نسخة تستدعي <code dir=\"ltr\">onAdd</code> حتى حين يكون المبلغ <strong>غير صالح</strong>، ونسخة <strong>لا تمسح</strong> الحقول، ونسخة تمرّر المبلغ <strong>بالدولار</strong> بدل السنتات، ونسخة <strong>لا تعرض</strong> التنبيه أبدًا.</p>\n","hints":["المسار السعيد: اكتب ‘Dinner’ و’45.50’، وانقر الزر، ثم <code dir=\"ltr\">expect(onAdd).toHaveBeenCalledWith({ description: 'Dinner', amountCents: 4550 })</code>.","بعد إضافة ناجحة، اقرأ الحقلين مرة أخرى وتحقّق من أن <code dir=\"ltr\">.value</code> تساوي <code dir=\"ltr\">''</code> في كليهما.","المسار غير الصالح: اكتب ‘abc’ كمبلغ، وانقر، ثم افحص <code dir=\"ltr\">getByRole(container, 'alert').textContent</code>، و<code dir=\"ltr\">expect(onAdd).not.toHaveBeenCalled()</code>، وأن حقل المبلغ ما زال يحتوي ‘abc’. بدائل الاستعلامات تتخطّى العناصر المخفيّة، تمامًا مثل <code dir=\"ltr\">getByRole</code> الحقيقي."],"explanation":"<p>اختباران، وستة assertions، وأربعة أخطاء. يثبّت <code dir=\"ltr\">toHaveBeenCalledWith({ description: 'Dinner', amountCents: 4550 })</code> العقد مع بقية التطبيق، فيُفشله خطأ الدولار بدل السنتات (لأنه يرسل 45.5). وفحص الحقلين بعد الإضافة يلتقط النموذج الذي ينسى المسح. وفي المسار غير الصالح، يفشل <code dir=\"ltr\">getByRole(container, 'alert')</code> حين يبقى التنبيه مخفيًّا، لأن العناصر المخفيّة ليست في شجرة إمكانية الوصول، ويلتقط <code dir=\"ltr\">not.toHaveBeenCalled()</code> النموذج الذي يحفظ القيم العبثية رغم ذلك. كل assertion يتعلّق بشيء يراه المستخدم أو يستلمه التطبيق؛ ولا يذكر أيّ منها متغيّرًا داخل العنصر.</p>\n","checkLabels":{"passes":"كل اختباراتك تنجح على النموذج الحقيقي","catches-adds-invalid":"تلتقط النسخة التي تستدعي onAdd بمبلغ غير صالح","catches-keeps-values":"تلتقط النسخة التي لا تمسح الحقول","catches-dollars-not-cents":"تلتقط النسخة التي ترسل الدولارات بدل السنتات","catches-silent-error":"تلتقط النسخة التي لا تعرض التنبيه أبدًا"}},"words":577,"readMinutes":3}