test: cover the paths that were reasoned about but never executed
Fifteen rows across five tasks said the mechanism was there and the test was not. Each of these is a case where being wrong would be silent. - the price rows must add up to the final amount. The chain test checks every number individually, which stays green if a new row is added and left out of the total; this checks the relationship itself. - a fixed deposit beats a percentage one, and neither can exceed the final amount — charging a deposit larger than the bill puts the patient in debt before the visit. - an appointment booked without a service still gets an invoice. Slot mode has no service, and without this the financial report is short a row with nothing to say which. - the four accuracy thresholds, each tested on its own boundary. One step off and either everything is red (so nobody looks) or nothing is (so the report is pointless). Includes a short-running service, since the deviation is measured on its absolute value. - all six policy templates build a policy that survives the normal validation, simulation and activation path. A template is a shortcut, not a second road: if one of them produced something the validator rejects, a user could create a rule in one click that never works. - simulation leaves nothing pending for a later flush in the same request. That is what the finally-rollback-clear is for, and the failure would surface in the next operation rather than in the sandbox. The course controller was reading $this->credits without it being injected — phpstan caught it; the package-shortfall path had no test yet and would have 500'd on the first course that had a package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,6 +140,109 @@ class PolicySimulationTest extends ApiTestCase
|
||||
self::assertSame([['type' => 'discount_percent', 'value' => 15]], $policy['effects']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ هر شش الگو باید قانونِ **معتبر** بسازند.
|
||||
*
|
||||
* الگو میانبُر است، نه مسیر دوم: اگر خروجی یکی از آنها از اعتبارسنجی عادی رد
|
||||
* نشود، کاربر با یک کلیک قانونی میسازد که هیچوقت کار نمیکند.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('templateCases')]
|
||||
public function testEveryTemplateBuildsAValidPolicy(string $key, array $values, string $category): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithBranch();
|
||||
|
||||
// الگوی نقشمحور به یک نوع منبع واقعی نیاز دارد.
|
||||
if (isset($values['role'])) {
|
||||
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'code' => 'surgeon',
|
||||
'name' => 'جراح',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$values['role'] = 'surgeon';
|
||||
}
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'name' => sprintf('الگوی %s', $key),
|
||||
'template' => $key,
|
||||
'values' => $values,
|
||||
]);
|
||||
|
||||
self::assertSame($category, $policy['category']);
|
||||
self::assertNotSame([], $policy['effects'], 'قانونی بدون اثر، قانون نیست');
|
||||
|
||||
// و باید از مسیر عادیِ آزمایش و فعالسازی رد شود.
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public static function templateCases(): array
|
||||
{
|
||||
return [
|
||||
'فاصلهٔ جلسات' => ['min_days_between_sessions', ['days' => 21], 'spacing'],
|
||||
'حداقل مدت' => ['complex_min_duration', ['minutes' => 60], 'timing'],
|
||||
'زمان اضافه' => ['extra_time_for_many_items', ['item_count' => 2, 'minutes' => 15], 'timing'],
|
||||
'نقش لازم' => ['surgery_needs_surgeon', ['role' => 'surgeon'], 'resource'],
|
||||
'رضایت والدین' => ['minor_needs_consent', ['age' => 18], 'eligibility'],
|
||||
'تخفیف وفادار' => ['vip_discount', ['visit_count' => 3, 'percent' => 15], 'pricing'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ آزمایش نباید هیچ نیمهحالتی جا بگذارد که **flushِ بعدیِ همین درخواست** ثبتش کند.
|
||||
*
|
||||
* این دقیقاً همان باگی است که `finally { rollback(); clear(); }` جلویش را میگیرد و
|
||||
* پیدا کردنش روزها میبرد: خطا در صفحهٔ آزمایش ظاهر نمیشود، در عملیاتِ بعدی ظاهر
|
||||
* میشود.
|
||||
*/
|
||||
public function testSimulationLeavesNoPendingStateForALaterFlush(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'قانون آزمایشی',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
$entity = static::getContainer()
|
||||
->get(\App\Policy\Repository\PolicyRepository::class)
|
||||
->findOneBy(['uuid' => $policy['uuid']]);
|
||||
|
||||
// یک entity در انتظار flush — دقیقاً وضعیتی که سناریوی خطرناک با آن شروع میشود.
|
||||
$pending = new \App\Resource\Entity\ResourceType(
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
'pending_type',
|
||||
'نوع در انتظار',
|
||||
);
|
||||
$this->em->persist($pending);
|
||||
|
||||
static::getContainer()->get(\App\Policy\Simulation\PolicySimulator::class)->simulate($entity, 5);
|
||||
|
||||
self::assertSame(
|
||||
0,
|
||||
$this->em->getConnection()->getTransactionNestingLevel(),
|
||||
'تراکنش آزمایش باید بسته شده باشد',
|
||||
);
|
||||
|
||||
// flushِ بعدی نباید چیزی از قبل از آزمایش را ثبت کند.
|
||||
$this->em->flush();
|
||||
|
||||
$written = (int) $this->em->getConnection()->fetchOne(
|
||||
'SELECT COUNT(*) FROM resource_types WHERE code = ?',
|
||||
['pending_type'],
|
||||
);
|
||||
|
||||
self::assertSame(0, $written, 'آزمایش نباید حالتِ در انتظار را به ثبت برساند');
|
||||
}
|
||||
|
||||
public function testTemplateWithAMissingValueIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
@@ -239,6 +239,61 @@ class PricingTest extends ApiTestCase
|
||||
self::assertSame(7_500_000, $body['data']['final_rials']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ جمعِ ردیفها **باید** برابر مبلغ نهایی باشد.
|
||||
*
|
||||
* تست زنجیره عددها را تکتک میسنجد؛ این یکی خودِ رابطه را میسنجد. اگر روزی ردیف
|
||||
* تازهای اضافه شود و در جمع نهایی حساب نشود، آن تست سبز میماند و این قرمز.
|
||||
*/
|
||||
public function testTheRowsAlwaysAddUpToTheFinalAmount(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'لیزر', 9_500_000);
|
||||
$extra = $this->service($c['section'], 'ناحیهٔ اضافه', 3_300_000);
|
||||
|
||||
$d = $this->quote($c['user'], $service, $c['address'], [
|
||||
'item_uuids' => [$extra->getUuid()],
|
||||
'policy' => [
|
||||
'discount_percent' => 7,
|
||||
'insurance_base_percent' => 15,
|
||||
'insurance_supplementary_percent' => 25,
|
||||
'tax_percent' => 9,
|
||||
],
|
||||
])['data'];
|
||||
|
||||
$sum = $d['base_rials']
|
||||
+ $d['items_rials']
|
||||
- $d['discount_rials']
|
||||
- $d['insurance_base_rials']
|
||||
- $d['insurance_supplementary_rials']
|
||||
+ $d['tax_rials'];
|
||||
|
||||
self::assertSame($d['final_rials'], $sum, json_encode($d, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/** بیعانهٔ مبلغی بر درصدی مقدم است و هرگز از مبلغ نهایی بیشتر نمیشود. */
|
||||
public function testAFixedDepositOverridesThePercentageAndIsCapped(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'ویزیت', 2_000_000);
|
||||
|
||||
$fixed = $this->quote($c['user'], $service, $c['address'], [
|
||||
'policy' => ['deposit_percent' => 50, 'deposit_rials' => 300_000],
|
||||
])['data'];
|
||||
|
||||
self::assertSame(300_000, $fixed['deposit_rials'], 'مبلغی برنده است، نه ۵۰٪');
|
||||
|
||||
$capped = $this->quote($c['user'], $service, $c['address'], [
|
||||
'policy' => ['deposit_rials' => 9_000_000],
|
||||
])['data'];
|
||||
|
||||
self::assertSame(
|
||||
$capped['final_rials'],
|
||||
$capped['deposit_rials'],
|
||||
'بیعانهٔ بیشتر از مبلغ نهایی یعنی بدهکار کردن بیمار پیش از ویزیت',
|
||||
);
|
||||
}
|
||||
|
||||
public function testForeignServiceIsNotFound(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
@@ -349,6 +404,80 @@ class PricingTest extends ApiTestCase
|
||||
self::assertSame(4_000_000, $snapshot['data']['base_rials']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ نوبت بدون سرویس هم فاکتور میگیرد.
|
||||
*
|
||||
* حالت اسلاتی سرویس ندارد؛ اگر فاکتورش ساخته نمیشد، گزارش مالی یک ردیف کم داشت و
|
||||
* هیچجا هم معلوم نمیشد کدام ردیف.
|
||||
*/
|
||||
public function testAnAppointmentWithoutAServiceStillGetsAnInvoice(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
$service = $this->service($c['section'], 'ویزیت', 1_500_000);
|
||||
|
||||
$room = new \App\Resource\Entity\ResourceType(
|
||||
$c['address']->tenantEntityType(),
|
||||
$c['address']->tenantEntityId(),
|
||||
'room',
|
||||
'اتاق',
|
||||
);
|
||||
$this->em->persist($room);
|
||||
$this->em->flush();
|
||||
|
||||
$resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [
|
||||
'address_uuid' => $c['address']->getUuid(),
|
||||
'type_uuid' => $room->getUuid(),
|
||||
'name' => 'اتاق ویزیت',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $c['user'], [
|
||||
'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]),
|
||||
]);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $c['user'], [
|
||||
'segments' => [[
|
||||
'sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 15,
|
||||
'requirements' => [['type_uuid' => $room->getUuid()]],
|
||||
]],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر ویزیت');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$start = (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran')))
|
||||
->setTime(11, 0)
|
||||
->getTimestamp();
|
||||
|
||||
$hold = $this->authJson('POST', '/api/v1/appointment-hold', $c['user'], [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $c['address']->getUuid(),
|
||||
'start' => $start,
|
||||
'assignment' => ['room' => [$resource['data']['uuid']]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($hold, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// ثبت **بدون** `service_uuid`: مسیر فاکتور تخت.
|
||||
$confirmed = $this->authJson('POST', '/api/v1/appointment-confirm', $c['user'], [
|
||||
'hold_uuid' => $hold['data']['hold_uuid'],
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($confirmed, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$snapshot = $this->authJson(
|
||||
'GET',
|
||||
"/api/v1/appointment/{$confirmed['data']['appointment_uuid']}/price-snapshot",
|
||||
$c['user'],
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), 'فاکتور باید وجود داشته باشد');
|
||||
self::assertSame(0, $snapshot['data']['items_rials']);
|
||||
self::assertSame($snapshot['data']['base_rials'], $snapshot['data']['final_rials']);
|
||||
}
|
||||
|
||||
public function testDraftListHasNoEffectUntilActivated(): void
|
||||
{
|
||||
$c = $this->clinic();
|
||||
|
||||
@@ -186,6 +186,54 @@ class ReportTest extends ApiTestCase
|
||||
// ── بهرهوری منابع ──────────────────────────────────────────────────────
|
||||
|
||||
/** منبعی بدون تقویم «۰٪ بهرهوری» ندارد — بهرهوریاش تعریفنشده است. */
|
||||
/**
|
||||
* ⭐ چهار آستانه، هر کدام روی مرز خودش.
|
||||
*
|
||||
* آستانهای که یک درجه اشتباه بیفتد، یا همهچیز را قرمز میکند (و کسی دیگر نگاه
|
||||
* نمیکند) یا هیچچیز را (و گزارش بیفایده است).
|
||||
*
|
||||
* @param int $planned مدت برنامه
|
||||
* @param int $actual مدت واقعی
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('severityCases')]
|
||||
public function testEachSeverityThresholdIsHitExactly(int $planned, int $actual, string $expected): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinic();
|
||||
$service = $this->service($section, 'خدمت آستانه', $planned);
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), $planned, $actual, $i);
|
||||
}
|
||||
|
||||
$body = $this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
|
||||
$user,
|
||||
);
|
||||
|
||||
$row = $body['data']['rows'][0];
|
||||
|
||||
self::assertSame($expected, $row['severity'], sprintf(
|
||||
'انحراف %d%%',
|
||||
$row['deviation_percent'],
|
||||
));
|
||||
}
|
||||
|
||||
/** مرزها: ۳۰ · ۱۵ · ۵ درصد، روی قدر مطلق. */
|
||||
public static function severityCases(): array
|
||||
{
|
||||
return [
|
||||
'دقیقاً روی مرز high' => [100, 130, 'high'],
|
||||
'یک قدم زیر high' => [100, 129, 'medium'],
|
||||
'دقیقاً روی مرز medium' => [100, 115, 'medium'],
|
||||
'یک قدم زیر medium' => [100, 114, 'low'],
|
||||
'دقیقاً روی مرز low' => [100, 105, 'low'],
|
||||
'یک قدم زیر low' => [100, 104, 'none'],
|
||||
'کوتاهتر هم شمرده میشود' => [100, 70, 'high'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testAResourceWithoutACalendarHasNullUtilization(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinic();
|
||||
|
||||
Reference in New Issue
Block a user