Files
zyt/server/tests/ClinicalImageTextServiceTest.php
2026-10-23 13:57:11 +08:00

104 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service {
function proc_open($command, $descriptors, &$pipes, $cwd, $env, $options) {
$GLOBALS['ocrCommands'][] = $command;
if (count($command) !== 2 || !str_ends_with($command[1], 'clinical_image_ocr.py') || !$options['bypass_shell']) throw new \RuntimeException('unsafe command');
if (isset($env['PYTHONPATH']) || isset($env['SYNTHETIC_PRIVATE_SECRET'])) throw new \RuntimeException('unfiltered environment');
foreach ($descriptors as $descriptor) {
if (!is_resource($descriptor)) throw new \RuntimeException('must use private file descriptors');
$GLOBALS['ocrTemporaryPaths'][] = stream_get_meta_data($descriptor)['uri'];
if (PHP_OS_FAMILY !== 'Windows' && (fstat($descriptor)['mode'] & 0777) !== 0600) throw new \RuntimeException('private file mode');
}
rewind($descriptors[0]);
$request = json_decode(stream_get_contents($descriptors[0]), true);
$GLOBALS['ocrLastRequest'] = $request;
if (count($request['images']) !== 2 || $request['images'][0]['index'] !== 7 || $request['images'][1]['index'] !== 19) throw new \RuntimeException('one process must cover every global source');
$scenario = $GLOBALS['ocrScenario'];
if ($scenario === 'spawn') return false;
$rows = array_map(static fn (array $source): array => ['index' => $source['index'], 'status' => 'processed',
'lines' => [['text' => 'SYNTHETIC_TEXT', 'confidence' => 0.9, 'box' => [[0, 0], [1, 0], [1, 1], [0, 1]]]]], $request['images']);
$result = ['status' => 'completed', 'images' => $rows];
if ($scenario === 'missing') array_pop($result['images']);
if ($scenario === 'duplicate') $result['images'][1]['index'] = 7;
if ($scenario === 'unknown') $result['images'][1]['index'] = 999;
if ($scenario === 'confidence') $result['images'][0]['lines'][0]['confidence'] = 2;
if ($scenario === 'box') $result['images'][0]['lines'][0]['box'][0][0] = 'bad';
if ($scenario === 'control') $result['images'][0]['lines'][0]['text'] = "bad\0text";
if ($scenario === 'no-lines') $result['images'][0]['lines'] = [];
if ($scenario === 'unavailable') $result['status'] = 'unavailable';
if ($scenario === 'partial') $result['images'][1] = ['index' => 19, 'status' => 'unreadable', 'lines' => []];
fwrite($descriptors[1], $scenario === 'malformed' ? '{' : json_encode($result));
if ($scenario === 'overflow') fwrite($descriptors[1], str_repeat('x', 524289));
fwrite($descriptors[2], 'SYNTHETIC_PRIVATE_STDERR');
$GLOBALS['ocrRunning'] = in_array($scenario, ['timeout', 'cancel'], true);
return fopen('php://temp', 'w+b');
}
function proc_get_status($process): array { return ['running' => $GLOBALS['ocrRunning'], 'exitcode' => $GLOBALS['ocrScenario'] === 'exit' ? 1 : 0]; }
function proc_terminate($process, $signal = 15): bool { $GLOBALS['ocrRunning'] = false; $GLOBALS['ocrTerminated']++; return true; }
function proc_close($process): int {
if ($GLOBALS['ocrRunning']) throw new \RuntimeException('must never close a running process');
$GLOBALS['ocrClosed']++; fclose($process); return 0;
}
}
namespace {
require dirname(__DIR__) . '/app/common/service/ClinicalImageTextService.php';
use app\common\service\ClinicalImageTextService;
function ocrExpect(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); }
$directory = sys_get_temp_dir() . '/clinical-ocr-fixture-' . bin2hex(random_bytes(8));
mkdir($directory, 0700);
$image = $directory . '/image.png';
file_put_contents($image, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='));
$images = [['index' => 7, 'path' => $image], ['index' => 19, 'path' => $image]];
$config = ['enabled' => true, 'python' => PHP_BINARY, 'timeout_seconds' => 1, 'threads' => 1, 'detection_limit' => 640, 'max_text_bytes' => 4096];
$ocrCommands = []; $ocrTemporaryPaths = []; $ocrClosed = 0; $ocrTerminated = 0; $ocrRunning = false;
putenv('SYNTHETIC_PRIVATE_SECRET=not-for-child');
try {
$disabled = ClinicalImageTextService::read($images, $directory, microtime(true) + 1, null, ['enabled' => false]);
ocrExpect($disabled === ['status' => 'disabled', 'images' => []] && $ocrCommands === [], 'disabled feature performs no subprocess work');
foreach (['success', 'partial', 'missing', 'duplicate', 'unknown', 'confidence', 'box', 'control', 'no-lines',
'malformed', 'overflow', 'exit', 'spawn', 'timeout', 'cancel', 'unavailable'] as $ocrScenario) {
$ocrRunning = false;
$before = count($ocrCommands); $checks = 0;
$abort = $ocrScenario === 'cancel' ? static function () use (&$checks): bool { return ++$checks > 1; } : null;
$result = ClinicalImageTextService::read($images, $directory, microtime(true) + ($ocrScenario === 'timeout' ? 0.06 : 1), $abort, $config);
ocrExpect(count($ocrCommands) === $before + 1, 'one subprocess per complete source set');
ocrExpect(count($result['images']) === 2 && array_column($result['images'], 'index') === [7, 19], 'every input index explicitly covered on every outcome');
if ($ocrScenario === 'success') ocrExpect($result['status'] === 'completed', 'valid complete output');
elseif (in_array($ocrScenario, ['partial', 'missing', 'unavailable', 'timeout', 'cancel'], true)) ocrExpect($result['status'] === 'partial', 'checkpoint/missing rows never claim a fully completed set');
else ocrExpect($result['status'] === 'unavailable', 'invalid child output is safe optional unavailability');
ocrExpect(!str_contains(json_encode($result), 'SYNTHETIC_PRIVATE_STDERR'), 'stderr never reaches callers');
foreach ($ocrTemporaryPaths as $path) ocrExpect(!file_exists($path), 'private request descriptors are cleaned after every outcome');
}
foreach (['duplicate', 'outside', 'url', 'unc', 'relative', 'index', 'pixel', 'bytes'] as $invalid) {
$selected = $images;
if ($invalid === 'duplicate') $selected[1]['index'] = 7;
elseif ($invalid === 'index') $selected[0]['index'] = true;
elseif ($invalid === 'outside') $selected[0]['path'] = __FILE__;
elseif ($invalid === 'url') $selected[0]['path'] = 'https://synthetic.invalid/image.png';
elseif ($invalid === 'unc') $selected[0]['path'] = '\\\\synthetic.invalid\\share\\image.png';
elseif ($invalid === 'relative') $selected[0]['path'] = 'image.png';
elseif ($invalid === 'bytes') {
$huge = $directory . '/large.png'; $handle = fopen($huge, 'w+b'); ftruncate($handle, 20971521); fclose($handle); $selected[0]['path'] = $huge;
} else {
$huge = $directory . '/pixels.bmp';
file_put_contents($huge, 'BM' . pack('VvvV', 54, 0, 0, 54) . pack('VllvvVVllVV', 40, 10000, 10000, 1, 24, 0, 0, 0, 0, 0, 0));
$selected[0]['path'] = $huge;
}
$before = count($ocrCommands);
$result = ClinicalImageTextService::read($selected, $directory, microtime(true) + 1, null, $config);
ocrExpect($result['status'] === 'unavailable' && count($ocrCommands) === $before, 'invalid source rejected before process creation: ' . $invalid);
}
ocrExpect($ocrTerminated >= 2 && $ocrClosed >= 2, 'deadline/cancel terminate and reap owned subprocesses');
echo "ClinicalImageTextService offline descriptors/coverage/limits/cancellation: 25 scenarios OK\n";
} finally {
putenv('SYNTHETIC_PRIVATE_SECRET');
foreach (glob($directory . '/*') ?: [] as $path) unlink($path);
rmdir($directory);
}
}