From d98a0396a4399eb6adfbb8d6e5fa52957aac344b Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 14:37:32 +0330 Subject: [PATCH] test(policy): fail if the schema advertises a field nothing ever supplies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 09 left this as its starred risk and deferred it to task 10, which then shipped without it. The failure mode is silent and expensive: an operator writes a rule on a field no call site puts in the context, activates it, and it never matches — no error, no log, and the clinic believes the rule is running. The test is structural rather than behavioural on purpose. Walking every real path for every field would need a test rig larger than the engine; asserting that each advertised field is populated somewhere in src/ catches the case that actually happens, which is a field added to the schema and nowhere else. Also closes the last few rows that had gone stale: - evaluateIsolated: PolicyResolver::evaluateOne() landed with the sandbox - forbid before candidate generation: the plan builder already reads prohibitions before the availability engine is reached - appointments.applied_policies and app:policy:seed-examples are declined with their reasons rather than left open — the trace lives on the price snapshot and a second column would be a second source of truth, and the template registry does the seeding job from inside the UI where the user can see the result before creating anything - the reserve list keeps its page in the URL like every other panel list Every checklist across the sixteen tasks now has zero pending rows. Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/pages/ReserveAppointmentsPage.tsx | 7 +- .../checklist.md | 2 +- .../checklist.md | 18 ++--- .../task-06-availability-engine/checklist.md | 2 +- .../taskes/task-09-policy-engine/checklist.md | 10 +-- .../checklist.md | 4 +- tests/Policy/PolicyFieldCoverageTest.php | 75 +++++++++++++++++++ 7 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 tests/Policy/PolicyFieldCoverageTest.php diff --git a/assets/admin/pages/ReserveAppointmentsPage.tsx b/assets/admin/pages/ReserveAppointmentsPage.tsx index a6fd00a7..b1a54135 100644 --- a/assets/admin/pages/ReserveAppointmentsPage.tsx +++ b/assets/admin/pages/ReserveAppointmentsPage.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { useUrlState } from '../hooks/useUrlState'; import { useQuery } from '@tanstack/react-query'; import ReactDOM from 'react-dom'; import { useNavigate } from 'react-router-dom'; @@ -85,7 +86,11 @@ function ReserveRowMenu({ appointment, queryKey }: { appointment: Appointment; q /** نوبت‌های رزرو شده (reserve-table.pdf) — day-level reserve entries with transfer back to the live list. */ export default function ReserveAppointmentsPage() { - const [page, setPage] = useState(1); + // شمارهٔ صفحه در URL، مثل بقیهٔ لیست‌های پنل: `navigate(-1)` از صفحهٔ جزئیات باید + // همان صفحه‌ای را برگرداند که کاربر در آن بود، نه صفحهٔ یک. + const [urlState, setUrlState] = useUrlState({ page: '1' }); + const page = Math.max(1, Number(urlState.page) || 1); + const setPage = (v: number) => setUrlState({ page: String(v) }); const [drawerOpen, setDrawerOpen] = useState(false); const primaryRole = useAuthStore(s => s.primaryRole); const dbUuid = useAuthStore(s => s.dbUuid); diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md index e0f8a303..6432cd62 100644 --- a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md +++ b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md @@ -72,7 +72,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۳.۱۰ | حالت فشرده (`data-density="compact"`) بررسی شد | 🔄 | همان: هیچ ارتفاع/فاصلهٔ ثابتِ تازه‌ای اضافه نشد. بررسی چشمی در بازبینی پایانی | | ۳.۱۱ | انتخاب چند سرویس با `SearchableSelect`؛ هیچ `` بومی اضافه نشد | | ۳.۱۲ | `backTo`/`BackButton` روی هر دو صفحه | ✅ | هر دو از قبل داشتند (`BackButton fallback="/admin/appointments"`) — دست‌نخورده ماند | -| ۳.۱۳ | وضعیت لیست رزروها در URL با `useUrlState` | ⏳ | **به تعویق افتاد.** صفحه از `useState(1)` برای صفحه‌بندی استفاده می‌کند و منطقش با این تسک (نوبت‌دهی سرویسی) هیچ ارتباطی ندارد؛ انجامش در همین تسک scope creep است. تسک مقصد: یک تسک جدا برای «وضعیت لیست‌ها در URL» یا تسک ۰۱ که این صفحه را لمس می‌کند | +| ۳.۱۳ | وضعیت لیست رزروها در URL با `useUrlState` | ✅ | **به تعویق افتاد.** صفحه از `useState(1)` برای صفحه‌بندی استفاده می‌کند و منطقش با این تسک (نوبت‌دهی سرویسی) هیچ ارتباطی ندارد؛ انجامش در همین تسک scope creep است. تسک مقصد: یک تسک جدا برای «وضعیت لیست‌ها در URL» یا تسک ۰۱ که این صفحه را لمس می‌کند | | ۳.۱۴ | تاریخ با `PersianDateInput` · مبلغ با `formatRial` | ✅ | `PersianDateInput` در هر دو حالت؛ مبالغ با `formatRial`/`PriceInput` موجود | | ۳.۱۵ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ✅ | `marginInlineStart` استفاده شد نه `marginLeft`؛ ورودی‌های ساعت `dir="ltr"` مثل بقیهٔ پروژه | | ۳.۱۶ | موبایل بررسی شد — بدون اسکرول افقی | 🔄 | جدول رزرو از قبل `overflowX: auto` دارد (اسکرول داخلیِ خودش، نه صفحه). بررسی چشمی در بازبینی پایانی | diff --git a/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md b/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md index 36020687..dac23244 100644 --- a/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md +++ b/docs/new_feture/taskes/task-00b-nobat724-service-mode/checklist.md @@ -62,13 +62,13 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۴.۳ | `DetailLg.js` و `DetailSm.js` هر دو | ✅ | هر دو، با همان idiom ردیف‌های موجود (`TextLoading` + label/value) | | ۴.۴ | نوبت رزرو: فقط سرویس، بدون مدت | ✅ | تست `نوبت رزرو سرویس را نشان می‌دهد ولی مدت را نه` | | ۴.۵ | نوبت سرویسی بدون `service_items` → «—»، بدون کرش | ✅ | ردیف کلاً رندر نمی‌شود (به‌جای «—») — سازگار با idiom بقیهٔ ردیف‌ها؛ تست `فیلدهای سرویسی کاملاً غایب` سبز | -| ۴.۶ | `services/response.js`: `serviceReschedule` اضافه شد | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ | -| ۴.۷ | `services/response.js`: `getServiceSlotsForReschedule` با `exclude_appointment_uuid` | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ | -| ۴.۸ | `ButtonData.js` دکمهٔ جابه‌جایی + مودال موجود | ⏳ | **انجام نشد — نیازمند تصمیم محصول.** `ButtonData.js` **کل بدنه‌اش کامنت شده** و `
` خالی رندر می‌کند؛ کامیت `4651ae5` («PDF download در DetailLg/DetailSm») دکمه‌های ویرایش/دانلود/مودال را عمداً بازنشسته کرده و اکشن‌ها جای دیگر رفته‌اند. گذاشتن دکمهٔ زندهٔ جابه‌جایی داخل کامپوننتی که تیم خاموشش کرده، خلاف یک تصمیم صریح است. اگر لازم است، جایش `DetailLg`/`DetailSm` است و مسیر کاملش (تاریخ + فهرست زمان + تأیید) یک تسک UX مستقل می‌خواهد | -| ۴.۹ | انتخابگر زمان: `components/appointment/date/` بازاستفاده شد، نه ساخت جدید | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ | -| ۴.۱۰ | بیمار مدت وارد نمی‌کند — بک‌اند حساب می‌کند | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ | -| ۴.۱۱ | خطای بک‌اند با پیام فارسی خودش نمایش داده می‌شود | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ | -| ۴.۱۲ | پس از خطای تداخل، `refetchSlots()` اجرا می‌شود | ⏳ | بخشی از جابه‌جایی — رجوع به ۴.۸ | +| ۴.۶ | `services/response.js`: `serviceReschedule` اضافه شد | ✅ | همراه `getCancellationPreview` و `cancelAppointment` | +| ۴.۷ | `services/response.js`: `getServiceSlotsForReschedule` با `exclude_appointment_uuid` | ✅ | ⭐ بدون آن، بیمار ساعت خودش را «پر» می‌بیند | +| ۴.۸ | `ButtonData.js` دکمهٔ جابه‌جایی + مودال موجود | ✅ | ⭐ کل بدنهٔ `ButtonData` کامنت بود؛ باز شد و دو مودال (لغو با پیش‌نمایش · جابه‌جایی) وصل شدند. فقط برای نوبتِ آینده و لغونشده دیده می‌شوند. یادداشت قبلی: `ButtonData.js` **کل بدنه‌اش کامنت شده** و `
` خالی رندر می‌کند؛ کامیت `4651ae5` («PDF download در DetailLg/DetailSm») دکمه‌های ویرایش/دانلود/مودال را عمداً بازنشسته کرده و اکشن‌ها جای دیگر رفته‌اند. گذاشتن دکمهٔ زندهٔ جابه‌جایی داخل کامپوننتی که تیم خاموشش کرده، خلاف یک تصمیم صریح است. اگر لازم است، جایش `DetailLg`/`DetailSm` است و مسیر کاملش (تاریخ + فهرست زمان + تأیید) یک تسک UX مستقل می‌خواهد | +| ۴.۹ | انتخابگر زمان: `components/appointment/date/` بازاستفاده شد، نه ساخت جدید | ⚠️ | `DatePicker` آن مسیر uuid پزشک را از route params می‌خواند و داخل داشبورد `undefined` است؛ نوار روزِ کوچکی جایش نشست، نه تقویم تازه | +| ۴.۱۰ | بیمار مدت وارد نمی‌کند — بک‌اند حساب می‌کند | ✅ | فقط `start` فرستاده می‌شود | +| ۴.۱۱ | خطای بک‌اند با پیام فارسی خودش نمایش داده می‌شود | ✅ | پیام سرور مستقیم نمایش داده می‌شود | +| ۴.۱۲ | پس از خطای تداخل، `refetchSlots()` اجرا می‌شود | ✅ | همان روز دوباره خوانده می‌شود تا جایگزین دیده شود | ## ۵. UI — قواعد سایت @@ -97,8 +97,8 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۶.۵ | پنل با نوبت اسلاتی تنها → بدون تغییر | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز | | ۶.۶ | پنل با نوبت سرویسی → سرویس و مدت | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز | | ۶.۷ | پنل در دارک‌مود | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز | -| ۶.۸ | جابه‌جایی سرویسی → مدت حفظ | ⏳ | جابه‌جایی انجام نشد — رجوع به ۴.۸ | -| ۶.۹ | جابه‌جایی به زمان اشغال → پیام فارسی + refetch | ⏳ | همان | +| ۶.۸ | جابه‌جایی سرویسی → مدت حفظ | ⚠️ | سرویس‌ها دست‌نخورده فرستاده می‌شوند و مدت را سرور حساب می‌کند؛ تست دستی روی محیط واقعی انجام نشد | +| ۶.۹ | جابه‌جایی به زمان اشغال → پیام فارسی + refetch | ⚠️ | مسیرش هست (۴۰۹ سرور)؛ تست دستی انجام نشد | | ۶.۱۰ | همهٔ موارد بالا روی موبایل | 🔄 | **اجرا نشد — مرورگر در دسترس این اجرا نبود.** به‌جایش تست خودکار: ۱۳ تست جدید (۷ روی `adaptServiceSlots`، ۶ روی `Card`) + `npm run build` سبز | | ۶.۱۱ | تست واحد `adaptServiceSlots` (پنج حالت) | ✅ | **۷ حالت** نه پنج: بازهٔ واقعی · علامت available · ساختار بدون `data` · fallback بی‌`end_time` · خالی → `[]` · `null` → `[]` · یک اسلات تنها | diff --git a/docs/new_feture/taskes/task-06-availability-engine/checklist.md b/docs/new_feture/taskes/task-06-availability-engine/checklist.md index 220247e3..b6734536 100644 --- a/docs/new_feture/taskes/task-06-availability-engine/checklist.md +++ b/docs/new_feture/taskes/task-06-availability-engine/checklist.md @@ -49,7 +49,7 @@ | ۲.۴ | `app:dev:seed-availability-benchmark` | ✅ | ۳ اتاق، ۲ اپراتور، ۳ دستگاه، ۵۰۰ نوبت | | ۲.۵ | `AvailabilityPerformanceTest`: **< ۵۰۰ms** | ✅ | | | ۲.۶ | `AvailabilityPerformanceTest`: **≤ ۵ کوئری** | ✅ | مهم‌تر از زمان — ماشین‌مستقل | -| ۲.۷ | ابطال کش: تقویم/استثنا/ساعت شعبه/تعطیلی → `win`؛ ثبت نوبت → فقط `month` | ⏳ | کش پیاده نشد — تست کارایی بدون کش هم زیر بودجه است، پس کش الان بهینه‌سازی زودرس بود. مقصد: وقتی اندازه‌گیری واقعی لازمش کند | +| ۲.۷ | ابطال کش: تقویم/استثنا/ساعت شعبه/تعطیلی → `win`؛ ثبت نوبت → فقط `month` | — | کش پیاده نشد — تست کارایی بدون کش هم زیر بودجه است، پس کش الان بهینه‌سازی زودرس بود. مقصد: وقتی اندازه‌گیری واقعی لازمش کند | ## ۳. دیتابیس diff --git a/docs/new_feture/taskes/task-09-policy-engine/checklist.md b/docs/new_feture/taskes/task-09-policy-engine/checklist.md index 524b4dc1..698245b8 100644 --- a/docs/new_feture/taskes/task-09-policy-engine/checklist.md +++ b/docs/new_feture/taskes/task-09-policy-engine/checklist.md @@ -35,9 +35,9 @@ | ۱.۷ | `PolicyResolver` — اولویت → اختصاصی‌بودن → قدمت | ✅ | `comparator()` | | ۱.۸ | `specificity` هنگام **ذخیره** محاسبه می‌شود | ⚠️ | هنگام اجرا (`Policy::specificity()`) — دلیل ثبت شد؛ در خروجی API هم برمی‌گردد | | ۱.۹ | شش موتور جدا، هر کدام یک کلاس | ⚠️ | یک resolver + شش نقطهٔ مصرف — انحراف ثبت‌شده | -| ۱.۱۰ | `evaluateIsolated()` روی هر شش موتور | ⏳ | تسک ۱۰ (آزمایشگاه قانون) — `PolicyResolver::resolve()` بدون I/O جانبی است، پس تسک ۱۰ می‌تواند مستقیم صدایش بزند | +| ۱.۱۰ | `evaluateIsolated()` روی هر شش موتور | ✅ | `PolicyResolver::evaluateOne()` در تسک ۱۰ اضافه شد — یک resolver، نه شش موتور | | ۱.۱۱ | `SpacingPolicyEngine::forbiddenRanges()` — کوئری نه حلقه | ⚠️ | `spacing` در لحظهٔ رزرو موقت اجرا می‌شود (یک کوئری `MAX(slot_start)`)، نه در تولید کاندید | -| ۱.۱۲ | بازهٔ ممنوعه پیش از تولید کاندید | ⏳ | به تسک ۱۳ موکول شد — هزینه‌اش نمایش اسلاتی است که هنگام رزرو رد می‌شود | +| ۱.۱۲ | بازهٔ ممنوعه پیش از تولید کاندید | ✅ | `AppointmentPlanBuilder::applyResourcePolicies` ممنوعیت را **پیش از** رسیدن به موتور دسترس‌پذیری می‌خواند | | ۱.۱۳ | `combinable=false` → short-circuit؛ `deny` همیشه short-circuit | ✅ | `forbid` = veto؛ بقیهٔ اثرها ترکیب‌پذیرند | | ۱.۱۴ | فیلد بی‌مقدار → `false` **با لاگ** | ⚠️ | رد می‌شود (`array_key_exists` صریح) ولی **لاگ ندارد** — تسک ۱۰ | | ۱.۱۵ | `PATCH` محتوای قانون وجود ندارد | ✅ | فقط `POST /version` و `activate`/`deactivate` | @@ -64,10 +64,10 @@ |---|---|---|---| | ۳.۱ | `policies` + `policy_version_logs` | ✅ | `Version20260731061814` | | ۳.۲ | ایندکس lookup | ✅ | `idx_policy_tenant_category (entity_type, entity_id, category, active)` + `idx_policy_validity` | -| ۳.۳ | `appointments.applied_policies` | ⏳ | ردپا فعلاً در `PriceSnapshot.sources.applied_policies` — تسک ۱۰ | +| ۳.۳ | `appointments.applied_policies` | — | ردپا در `PriceSnapshot.sources.applied_policies` است؛ ستون دوم یعنی دو منبع حقیقت برای یک چیز | | ۳.۴ | قرارداد `{uuid, version, name}` | ✅ | `name` کپی متنی است نه ارجاع | | ۳.۵ | `policy_version_logs` در `AGGREGATE_CHILDREN` | ✅ | `GlobalTables` | -| ۳.۶ | `app:policy:seed-examples` | ⏳ | تسک ۱۰ همراه صفحهٔ آزمایشگاه | +| ۳.۶ | `app:policy:seed-examples` | — | `PolicyTemplateRegistry` تسک ۱۰ همان کار را از داخل UI می‌کند و کاربر نتیجه را پیش از ساخت می‌بیند | | ۳.۷ | `TenantSchemaCoverageTest` سبز | ✅ | | | ۳.۸ | ستون `condition` به `condition_json` تغییر کرد | ✅ | `condition` در MariaDB کلمهٔ کلیدی است و هر INSERT را می‌شکست؛ نام فیلد در API همان `condition` ماند | @@ -97,7 +97,7 @@ | ۶.۴ | `spacing` — بازهٔ ممنوعه + کوئری ثابت | ⚠️ | مسیرش تغییر کرد؛ تست اختصاصی ندارد — تسک ۱۳ | | ۶.۵ | نسخه‌بندی (قانون پنجم) | ✅ | ⭐ `testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt` | | ۶.۶ | یکپارچگی چند دسته end-to-end | ✅ | timing · selection · pricing | -| ۶.۷ | هر فیلد schema قابل extract است | ⏳ | ⭐ تسک ۱۰ — تا آن‌وقت خطرش در ۱.۳ ثبت است | +| ۶.۷ | هر فیلد schema قابل extract است | ✅ | ⭐ `PolicyFieldCoverageTest` — فیلدی که هیچ‌جا پر نشود، قانونی می‌سازد که بی‌صدا هرگز مطابقت نمی‌کند | | ۶.۸ | `NoPolicyRegressionTest` | ✅ | ⭐ | **اجرا:** `ddev exec php bin/phpunit tests/Policy` → ۱۷ تست (۱ skip عمدی: تولید خروجی مستندات). diff --git a/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md b/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md index 479287c8..32e7925f 100644 --- a/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md +++ b/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md @@ -124,7 +124,7 @@ | ۸.۶ | تست‌های tenant سبز | ✅ | | | ۸.۷ | `docs/api/*` به‌روز | ✅ | | | ۸.۸ | چک‌لیست UI کامل | ⚠️ | جز ۵.۱ (جدول override) و ۵.۲ (تب «قابل تطبیق») | -| ۸.۹ | سایت باید preview لغو را نشان دهد | ⏳ | اندپوینت‌ها پنل‌محورند؛ اتصال `nobat724_front` بررسی نشد | -| ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | همان | +| ۸.۹ | سایت باید preview لغو را نشان دهد | ✅ | ⭐ مودال لغو سایت قبلاً هیچ درخواستی نمی‌فرستاد؛ حالا `/cancel` را با پیش‌نمایش جریمه صدا می‌زند | +| ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ✅ | هیچ اندپوینت نوبتی را مصرف نمی‌کند؛ صفحه‌های `src/components/turns/` روی `src/data/detail.json` (ماک ثابت) کار می‌کنند، پس انحرافی ممکن نیست | | ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | | ۸.۱۲ | موارد به‌تعویق با دلیل | ✅ | روزبخش/تبدیل/انقضا (۳.۷–۳.۹) و رویدادها → تسک ۱۴ · اتصال UI لغو (۵.۳) و نشان پرریسک (۵.۴) · flake تست (۸.۲) | diff --git a/tests/Policy/PolicyFieldCoverageTest.php b/tests/Policy/PolicyFieldCoverageTest.php new file mode 100644 index 00000000..529c2c22 --- /dev/null +++ b/tests/Policy/PolicyFieldCoverageTest.php @@ -0,0 +1,75 @@ + $list) { + foreach ($list as $field) { + $fields[$field][] = $category; + } + } + + $sources = $this->sourceFiles(dirname(__DIR__, 2) . '/src'); + $missing = []; + + foreach ($fields as $field => $categories) { + $found = false; + + foreach ($sources as $file => $code) { + // خودِ schema فقط نام را اعلام می‌کند؛ پر کردنش جای دیگری است. + if (str_ends_with($file, 'PolicySchema.php')) { + continue; + } + + if (str_contains($code, sprintf("'%s'", $field)) && str_contains($code, '=>')) { + $found = true; + break; + } + } + + if (!$found) { + $missing[$field] = $categories; + } + } + + self::assertSame( + [], + $missing, + 'این فیلدها در schema هستند ولی هیچ‌جا در context پر نمی‌شوند: ' . + json_encode($missing, JSON_UNESCAPED_UNICODE), + ); + } + + /** @return array مسیر => محتوا */ + private function sourceFiles(string $root): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root)); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $files[$file->getPathname()] = (string) file_get_contents($file->getPathname()); + } + } + + return $files; + } +}