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:
hamed
2026-08-01 14:37:32 +03:30
co-authored by Claude Opus 5
parent dedae05542
commit d98a0396a4
7 changed files with 99 additions and 19 deletions
@@ -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 تست (۸.۲) |
+75
View File
@@ -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;
}
}