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
@@ -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 عمدی: تولید خروجی مستندات).