* @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-unicode * * This file is part of tc-lib-unicode software library. */ namespace Test; use Com\Tecnick\Unicode\Bidi; use Com\Tecnick\Unicode\Data\Mirror as UniMirror; use PHPUnit\Framework\TestCase; /** * Runs the official BidiCharacterTest.txt conformance suite of the Unicode Character * Database against the bidirectional algorithm. * * The data file is downloaded by "make ucd" into target/ucd//; the test is * skipped when it is not available. * * @since 2026-08-06 * @category Library * @package Unicode * @author Nicola Asuni * @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-unicode */ class BidiConformanceTest extends TestCase { /** * Paragraph direction of field 1 of the data file. */ private const DIRECTION = [ '0' => 'L', '1' => 'R', '2' => '', ]; /** * Known failures, as ";" of the data file, with the reason. * A case listed here must fail: the test reports the entries that can be removed. * * @var array */ private const KNOWN_FAILURES = []; /** * Maximum number of unexpected failures reported by the assertion message. */ private const MAX_REPORTED = 10; public function testBidiCharacterTest(): void { $path = self::findDataFile(); if ($path === null) { $this->markTestSkipped('BidiCharacterTest.txt not found: run "make ucd" to download it'); } $handle = \fopen($path, 'r'); $this->assertIsResource($handle); $total = 0; $failures = []; $fixed = []; while (($line = \fgets($handle)) !== false) { $line = \trim($line); if ($line === '' || $line[0] === '#') { continue; } $field = \explode(';', $line); if (\count($field) < 5) { continue; } ++$total; $key = $field[0] . ';' . ($field[1] ?? ''); $passed = self::runCase($field); if (isset(self::KNOWN_FAILURES[$key])) { if ($passed) { $fixed[] = $key; } continue; } if (!$passed) { $failures[] = $line; } } \fclose($handle); $this->assertGreaterThan(90_000, $total, 'unexpected number of conformance cases'); $this->assertSame([], $fixed, 'these cases now pass and must be removed from KNOWN_FAILURES'); $this->assertSame( [], \array_slice($failures, 0, self::MAX_REPORTED), \sprintf('%d of %d conformance cases failed', \count($failures), $total), ); } /** * Runs a single data file line: codepoints, paragraph direction, paragraph level, * resolved levels and visual order. * * @param array $field Fields of the data file line */ private static function runCase(array $field): bool { $codes = \trim($field[0] ?? ''); $direction = \trim($field[1] ?? ''); $levelStr = \trim($field[3] ?? ''); $orderStr = \trim($field[4] ?? ''); $ordarr = \array_map(static fn(string $hex): int => (int) \hexdec($hex), \explode(' ', $codes)); $levels = \explode(' ', $levelStr); $order = $orderStr === '' ? [] : \array_map('intval', \explode(' ', $orderStr)); // The data file lists the reordered indexes without applying rule L4, which the // library does apply, so the mirrored form is used for the odd levels. $expected = []; foreach ($order as $idx) { $ord = $ordarr[$idx] ?? 0; $expected[] = ((int) ($levels[$idx] ?? 0) % 2) === 1 ? UniMirror::UNI[$ord] ?? $ord : $ord; } try { $bidi = new Bidi(null, null, $ordarr, self::DIRECTION[$direction] ?? '', false); $result = \array_values($bidi->getOrdArray()); } catch (\Throwable) { return false; } return $result === $expected; } /** * Returns the path of the most recent BidiCharacterTest.txt available, or null. */ private static function findDataFile(): ?string { $found = \glob(__DIR__ . '/../target/ucd/*/BidiCharacterTest.txt'); if ($found === false || $found === []) { return null; } \sort($found); return \end($found); } }