*/ public static array $failures = []; public static int $passed = 0; public static int $assertions = 0; public static string $group = ''; } final class AssertionFailed extends RuntimeException {} function group(string $name): void { TestRun::$group = $name; echo "\n\033[1m" . $name . "\033[0m\n"; } function test(string $name, callable $fn): void { try { $fn(); TestRun::$passed++; echo " \033[32m✓\033[0m " . $name . "\n"; } catch (AssertionFailed $e) { TestRun::$failures[] = ['name' => TestRun::$group . ' › ' . $name, 'error' => $e->getMessage()]; echo " \033[31m✗ " . $name . "\033[0m\n " . str_replace("\n", "\n ", $e->getMessage()) . "\n"; } catch (Throwable $e) { $where = basename($e->getFile()) . ':' . $e->getLine(); TestRun::$failures[] = ['name' => TestRun::$group . ' › ' . $name, 'error' => get_class($e) . ': ' . $e->getMessage() . ' @ ' . $where]; echo " \033[31m✗ " . $name . "\033[0m\n " . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $where . "\n"; } } function assert_same($expected, $actual, string $message = ''): void { TestRun::$assertions++; if ($expected !== $actual) { throw new AssertionFailed( ($message !== '' ? $message . "\n" : '') . 'expected: ' . compact_export($expected) . "\n" . 'actual: ' . compact_export($actual) ); } } function assert_true($actual, string $message = ''): void { assert_same(true, $actual, $message !== '' ? $message : 'expected true'); } function assert_null($actual, string $message = ''): void { assert_same(null, $actual, $message !== '' ? $message : 'expected null'); } function assert_contains(string $needle, string $haystack, string $message = ''): void { TestRun::$assertions++; if (strpos($haystack, $needle) === false) { throw new AssertionFailed( ($message !== '' ? $message . "\n" : '') . 'expected to find: ' . $needle . "\n" . 'in: ' . truncate($haystack, 600) ); } } function assert_not_contains(string $needle, string $haystack, string $message = ''): void { TestRun::$assertions++; if (strpos($haystack, $needle) !== false) { throw new AssertionFailed( ($message !== '' ? $message . "\n" : '') . 'did NOT expect to find: ' . $needle . "\n" . 'in: ' . truncate($haystack, 600) ); } } function compact_export($value): string { if (is_string($value)) return "'" . $value . "'"; if ($value === null) return 'null'; if (is_bool($value)) return $value ? 'true' : 'false'; if (is_array($value)) return truncate(json_encode($value, JSON_UNESCAPED_SLASHES), 400); return (string) $value; } function truncate(string $s, int $max): string { return strlen($s) <= $max ? $s : substr($s, 0, $max) . '… (' . strlen($s) . ' bytes)'; } function report_and_exit(): void { $failed = count(TestRun::$failures); echo "\n" . str_repeat('─', 60) . "\n"; if ($failed === 0) { echo "\033[32mPASS\033[0m — " . TestRun::$passed . ' tests, ' . TestRun::$assertions . " assertions\n"; exit(0); } echo "\033[31mFAIL\033[0m — " . $failed . ' failed, ' . TestRun::$passed . " passed\n\n"; foreach (TestRun::$failures as $f) { echo " • " . $f['name'] . "\n " . str_replace("\n", "\n ", $f['error']) . "\n\n"; } exit(1); }