111 lines
6.4 KiB
PHP
111 lines
6.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||
|
||
use app\adminapi\service\AssistantClinicalRejectDiagnostic;
|
||
use app\adminapi\service\AssistantSseTiming;
|
||
|
||
$checks = 0;
|
||
function rejectDiagnosticExpect(bool $condition, string $message): void
|
||
{
|
||
++$GLOBALS['checks'];
|
||
if (!$condition) throw new RuntimeException($message);
|
||
}
|
||
|
||
$fixtures = [
|
||
['脉象:未记录。', ['pulse_row', 'missing_pulse_statement']],
|
||
['| 脉象 | 未采集 |', ['pulse_row', 'missing_pulse_statement']],
|
||
['**脉象**:弦细。', ['pulse_row', 'pulse_feature']],
|
||
['脉象未提供。', ['missing_pulse_statement']],
|
||
['缺少脉象会影响判断。', ['missing_pulse_statement']],
|
||
['pulse is not recorded.', ['missing_pulse_statement']],
|
||
['本流程不采集脉象。', ['workflow_rule_narration']],
|
||
['本次问诊无需补采脉象。', ['workflow_rule_narration']],
|
||
['脉象不纳入本次评估。', ['workflow_rule_narration']],
|
||
['本流程不采集脉象,请补采脉象。', ['workflow_rule_narration', 'other']],
|
||
['本流程不采集脉象,脉弦细。', ['workflow_rule_narration', 'pulse_feature']],
|
||
['本流程不采集脉象且要求补采脉象。', ['other']],
|
||
['脉象未记录,请补采脉象。', ['missing_pulse_statement', 'other']],
|
||
['脉象弦细。', ['pulse_feature']],
|
||
['脉沉,弦脉。', ['pulse_feature']],
|
||
['请补采脉象。', ['other']],
|
||
['脉象:', ['pulse_row']],
|
||
['脉率78次/分,脉压40mmHg,pulse rate 78/min,pulse pressure 40mmHg。', ['other']],
|
||
['普通合成文本。', ['other']],
|
||
['', ['other']],
|
||
["\xff", ['other']],
|
||
];
|
||
foreach ($fixtures as $index => [$unit, $expected]) {
|
||
rejectDiagnosticExpect(AssistantClinicalRejectDiagnostic::forms($unit) === $expected,
|
||
'Finite lexical classification fixture ' . $index);
|
||
}
|
||
|
||
$timing = new AssistantSseTiming();
|
||
$private = 'SYNTHETIC_PHI_SENTINEL_患者姓名_123456789_任务_TASK_URL_https://private.invalid/s999';
|
||
$issues = [
|
||
['code' => 'PULSE_EXCLUDED_FROM_ASSESSMENT', 'source_id' => $private, 'quote' => $private, 'field' => $private],
|
||
['code' => 'UNKNOWN_TO_NEGATIVE', 'source_id' => 's999'],
|
||
['code' => 'PULSE_EXCLUDED_FROM_ASSESSMENT'],
|
||
['code' => $private], ['code' => 'UNKNOWN_TO_NEGATIVE:s999'], ['code' => "UNKNOWN_TO_NEGATIVE\n" . $private],
|
||
['code' => ['UNKNOWN_TO_NEGATIVE']], ['code' => new stdClass()], $private, 17, null,
|
||
(object) ['code' => 'UNKNOWN_TO_POSITIVE'],
|
||
];
|
||
$event = AssistantClinicalRejectDiagnostic::event($timing, $private . ',脉象未记录。', $issues);
|
||
rejectDiagnosticExpect(array_keys($event) === ['trace_id', 'pulse_forms', 'clinical_issue_codes', 'unit_chars'],
|
||
'The event has exactly four safe fields');
|
||
rejectDiagnosticExpect($event['trace_id'] === $timing->id()
|
||
&& $event['clinical_issue_codes'] === ['PULSE_EXCLUDED_FROM_ASSESSMENT', 'UNKNOWN_TO_NEGATIVE']
|
||
&& $event['pulse_forms'] === ['missing_pulse_statement']
|
||
&& $event['unit_chars'] === mb_strlen($private . ',脉象未记录。', 'UTF-8'),
|
||
'Only generated trace, finite forms, exact codes and character count survive');
|
||
rejectDiagnosticExpect(!str_contains(json_encode($event), $private), 'The event never contains synthetic private text');
|
||
$allowed = (new ReflectionClass(AssistantSseTiming::class))->getReflectionConstant('CLINICAL_ISSUE_CODES')->getValue();
|
||
$bounded = AssistantClinicalRejectDiagnostic::event($timing, str_repeat('字', 100001),
|
||
array_map(static fn (string $code): array => ['code' => $code], array_merge($allowed, $allowed)));
|
||
rejectDiagnosticExpect($bounded['clinical_issue_codes'] === array_slice($allowed, 0, 16)
|
||
&& $bounded['pulse_forms'] === ['other'] && $bounded['unit_chars'] === 100001,
|
||
'Unknown prose stays bounded and issue codes retain the existing sixteen-code limit');
|
||
|
||
// Use only an isolated temporary File log; no application boot, network or database.
|
||
$app = new \think\App(dirname(__DIR__) . '/');
|
||
$directory = sys_get_temp_dir() . '/zyt-reject-diagnostic-' . bin2hex(random_bytes(8));
|
||
$app->config->set(['default' => 'diagnostic', 'channels' => ['diagnostic' => [
|
||
'type' => 'File', 'path' => $directory, 'single' => 'rejection', 'realtime_write' => false,
|
||
]]], 'log');
|
||
$app->make('log');
|
||
$file = $directory . '/rejection.log';
|
||
try {
|
||
AssistantClinicalRejectDiagnostic::log($timing, $private . ',本流程不采集脉象。', $issues);
|
||
rejectDiagnosticExpect(is_file($file), 'The diagnostic reaches the immediate File write before SSE exits');
|
||
$logged = is_file($file) ? (string) file_get_contents($file) : '';
|
||
rejectDiagnosticExpect(str_contains($logged, '"pulse_forms":["workflow_rule_narration"]')
|
||
&& str_contains($logged, '"clinical_issue_codes":["PULSE_EXCLUDED_FROM_ASSESSMENT","UNKNOWN_TO_NEGATIVE"]')
|
||
&& str_contains($logged, $timing->id()), 'Persisted diagnostics retain only bounded correlated metadata');
|
||
foreach ([$private, 'SYNTHETIC_PHI', '123456789', 'private.invalid', 'source_id', 'quote', 'field', 's999', 'TASK_URL'] as $secret) {
|
||
rejectDiagnosticExpect(!str_contains($logged, $secret), 'Persisted output discards arbitrary private fields and issue values');
|
||
}
|
||
|
||
// Execute the exact controller callback with a throwing logger to verify the
|
||
// production observer cannot turn a logging failure into a stream failure.
|
||
$controller = (string) file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
|
||
$pattern = <<<'REGEX'
|
||
~'on_clinical_rejection'\s*=>\s*(static function \(string \$unit, array \$issues\) use \(\$timing\): void \{.*?\n \}),~s
|
||
REGEX;
|
||
rejectDiagnosticExpect(preg_match($pattern, $controller, $matches) === 1, 'Controller installs the rejection observer');
|
||
$callback = eval('use app\\adminapi\\service\\AssistantClinicalRejectDiagnostic; return ' . $matches[1] . ';');
|
||
$throwingLog = new class {
|
||
public int $calls = 0;
|
||
public function write(...$arguments): void { ++$this->calls; throw new RuntimeException('synthetic log failure'); }
|
||
};
|
||
$app->instance('log', $throwingLog);
|
||
$callback($private, $issues);
|
||
rejectDiagnosticExpect($throwingLog->calls === 1, 'Controller swallows diagnostic exceptions after calling the logger');
|
||
} finally {
|
||
if (is_file($file)) unlink($file);
|
||
if (is_dir($directory)) rmdir($directory);
|
||
}
|
||
|
||
echo 'Clinical rejection diagnostic: ' . $checks . " checks passed (synthetic offline fixtures).\n";
|