42, 'slug' => '20250101-TEST1234', 'author' => 'Test Author', ]); echo " ✓ log() completed without exception\n"; } catch (Throwable $e) { throw new RuntimeException('FAIL: log() threw: ' . $e->getMessage()); } echo "J2: log with null values in extra\n"; ErrorHandler::log('test_null', new PDOException('test'), ['id' => null, 'name' => 'foo']); echo " ✓ log() handles null values\n"; echo "J3: log with empty extra array\n"; ErrorHandler::log('test_empty', new RuntimeException('bare')); echo " ✓ log() handles empty extra\n"; echo "\n"; // ========================================================================= // SECTION K: Keyword normalization logic (tag normalization) // ========================================================================= echo "K: Keyword normalization logic\n"; // Test the normalization regex used in controllers and JS: // strtolower(trim(preg_replace('/\s+/', ' ', $t))) $normalize = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); echo "K1: basic trimming and casing\n"; ehAssertEq('hello', $normalize('Hello'), 'uppercase → lowercase'); ehAssertEq('hello', $normalize(' hello '), 'trimmed'); ehAssertEq('hello world', $normalize('Hello World'), 'two words lowercased'); echo "K2: multiple spaces collapsed\n"; ehAssertEq('a b c', $normalize('a b c'), 'double spaces → single'); ehAssertEq('x y', $normalize(' x y '), 'leading/trailing + multiple → clean'); echo "K3: tabs and newlines collapsed to space\n"; ehAssertEq('word1 word2', $normalize("word1\tword2"), 'tab → space'); ehAssertEq('line1 line2', $normalize("line1\nline2"), 'newline → space'); ehAssertEq('mixed spaces', $normalize("mixed \t \n spaces"), 'mixed whitespace → single space'); echo "K4: French accents preserved\n"; ehAssertEq('très précis', $normalize('Très Précis'), 'accents preserved in lowercase'); echo "K5: empty string\n"; ehAssertEq('', $normalize(''), 'empty stays empty'); ehAssertEq('', $normalize(' '), 'whitespace-only becomes empty'); echo "K6: special characters not mangled\n"; ehAssertEq('c++', $normalize('C++'), 'symbols preserved'); ehAssertEq('c#', $normalize('C#'), 'hash preserved'); echo "\n"; // ========================================================================= // SECTION L: Deduplication on normalize (case-insensitive) // ========================================================================= echo "L: Deduplication after normalization\n"; $dedup = function (array $tags): array { return array_values(array_unique(array_map( fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))), $tags ))); }; echo "L1: case-insensitive dedup\n"; ehAssertEq(['hello'], $dedup(['Hello', 'hello', 'HELLO']), 'case variations → one entry'); echo "L2: whitespace-insensitive dedup\n"; ehAssertEq(['hello world'], $dedup(['hello world', 'Hello World', 'hello world']), 'whitespace + case → one entry'); echo "L3: empty strings filtered\n"; $filtered = array_values(array_filter($dedup(['', ' ', 'valid']), fn ($t) => $t !== '')); ehAssertEq(['valid'], $filtered, 'empty/whitespace-only removed'); echo "L4: mixed valid and empty\n"; $result = array_values(array_filter($dedup(['Alpha', '', ' ', 'BETA', 'alpha']), fn ($t) => $t !== '')); ehAssertEq(['alpha', 'beta'], $result, 'deduplicated and empties filtered'); echo "\n"; // ========================================================================= // SECTION M: Minimum/maximum tag count enforcement\n // ========================================================================= echo "M: Tag count constraints\n"; echo "M1: 3 tags is valid\n"; $valid = ['one', 'two', 'three']; ehAssert(count($valid) >= 3, '3 tags ≥ minimum 3'); ehAssert(count($valid) <= 10, '3 tags ≤ maximum 10'); echo "M2: < 3 tags triggers error\n"; $tooFew = ['one']; ehAssert(count($tooFew) < 3, '1 tag < minimum 3'); echo "M3: > 10 tags triggers error\n"; $tooMany = ['a','b','c','d','e','f','g','h','i','j','k']; ehAssert(count($tooMany) > 10, '11 tags > maximum 10'); echo "M4: empty array\n"; ehAssert(count([]) < 3, 'empty array < minimum 3'); echo "\n"; // ========================================================================= // SECTION N: Real SQLite FK error message formats // ========================================================================= echo "N: Real-world SQLite FK error message patterns\n"; // These are actual error messages observed in the wild. echo "N1: typical INSERT INTO with VALUES\n"; $msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats ("thesis_id", "format_id") VALUES (?, ?)'); $user = ErrorHandler::userMessage($msg); ehAssertContains('Format(s)', $user, 'quoted column names handled'); echo "N2: UPDATE statement\n"; $msg = makeFkException('FOREIGN KEY constraint failed UPDATE theses SET orientation_id = ? WHERE id = ?'); $user = ErrorHandler::userMessage($msg); ehAssertContains('Orientation', $user, 'UPDATE statement parsed'); echo "N3: long FK message with multiple table references\n"; // Only the first match should be used (the INSERT target table) $msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?) REFERENCES format_types'); $user = ErrorHandler::userMessage($msg); ehAssertContains('Format(s)', $user, 'first table match used'); echo "\n"; echo "✅ All ErrorHandler tests passed!\n"; $result = true; } catch (Exception $e) { echo '❌ FAIL: ' . $e->getMessage() . "\n"; $result = false; } return $result ?? false;