test(policy): fail if the schema advertises a field nothing ever supplies
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -72,7 +72,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
| ۳.۱۰ | حالت فشرده (`data-density="compact"`) بررسی شد | 🔄 | همان: هیچ ارتفاع/فاصلهٔ ثابتِ تازهای اضافه نشد. بررسی چشمی در بازبینی پایانی |
|
||||
| ۳.۱۱ | انتخاب چند سرویس با `SearchableSelect`؛ هیچ `<select>` بومی | ✅ | `ServiceSlotPicker` از `SearchableSelect` برای بخش و دکمههای toggle برای سرویسها استفاده میکند (کد موجود). هیچ `<select>` بومی اضافه نشد |
|
||||
| ۳.۱۲ | `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` دارد (اسکرول داخلیِ خودش، نه صفحه). بررسی چشمی در بازبینی پایانی |
|
||||
|
||||
@@ -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` **کل بدنهاش کامنت شده** و `<div>` خالی رندر میکند؛ کامیت `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` **کل بدنهاش کامنت شده** و `<div>` خالی رندر میکند؛ کامیت `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` → `[]` · یک اسلات تنها |
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
| ۲.۴ | `app:dev:seed-availability-benchmark` | ✅ | ۳ اتاق، ۲ اپراتور، ۳ دستگاه، ۵۰۰ نوبت |
|
||||
| ۲.۵ | `AvailabilityPerformanceTest`: **< ۵۰۰ms** | ✅ | |
|
||||
| ۲.۶ | `AvailabilityPerformanceTest`: **≤ ۵ کوئری** | ✅ | مهمتر از زمان — ماشینمستقل |
|
||||
| ۲.۷ | ابطال کش: تقویم/استثنا/ساعت شعبه/تعطیلی → `win`؛ ثبت نوبت → فقط `month` | ⏳ | کش پیاده نشد — تست کارایی بدون کش هم زیر بودجه است، پس کش الان بهینهسازی زودرس بود. مقصد: وقتی اندازهگیری واقعی لازمش کند |
|
||||
| ۲.۷ | ابطال کش: تقویم/استثنا/ساعت شعبه/تعطیلی → `win`؛ ثبت نوبت → فقط `month` | — | کش پیاده نشد — تست کارایی بدون کش هم زیر بودجه است، پس کش الان بهینهسازی زودرس بود. مقصد: وقتی اندازهگیری واقعی لازمش کند |
|
||||
|
||||
## ۳. دیتابیس
|
||||
|
||||
|
||||
@@ -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 عمدی: تولید خروجی مستندات).
|
||||
|
||||
@@ -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 تست (۸.۲) |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Policy\Service\PolicySchema;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* هر فیلدی که schema تبلیغ میکند، باید جایی در کد **واقعاً پر شود**.
|
||||
*
|
||||
* خطر مشخص است و بیصداست: قانونی که روی فیلدی شرط بگذارد که هیچ فراخوانی آن را
|
||||
* نمیفرستد، هرگز مطابقت نمیکند و هیچ خطایی هم نمیدهد. اپراتور قانون را میسازد،
|
||||
* فعالش میکند، و تا ابد فکر میکند دارد کار میکند.
|
||||
*
|
||||
* این تست عمداً ساختاری است نه رفتاری: پیمایشِ همهٔ مسیرهای واقعی برای هر فیلد،
|
||||
* دستگاه تستی میخواست بزرگتر از خودِ موتور.
|
||||
*/
|
||||
class PolicyFieldCoverageTest extends TestCase
|
||||
{
|
||||
public function testEveryAdvertisedFieldIsSuppliedSomewhereInTheCode(): void
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
foreach (PolicySchema::FIELDS as $category => $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<string, string> مسیر => محتوا */
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user