Files
zyt/server/tests/AssistantClinicalRejectDiagnosticTest.php
2026-10-27 14:03:35 +08:00

111 lines
6.4 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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";