Files
zyt/server/tests/DiagnosisAiAssistantErrorReceiptTest.php
T
2026-10-23 11:17:58 +08:00

73 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\controller\tcm\DiagnosisController;
function errorReceiptExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); }
$format = new ReflectionMethod(DiagnosisAiLogic::class, 'formatAssistantResult');
$sections = AiRequiredSource::manifest([]);
foreach ($sections as &$section) { $section['status'] = 'failed'; $section['label'] = '不得回传的姓名'; }
unset($section);
$result = ['ok' => false, 'error_code' => 'REQUIRED_FILES_REJECTED', 'error' => '合成附件失败',
'required_sections' => $sections, 'source_summary' => ['patient_name' => '不得回传的姓名', 'url' => 'https://private.invalid', 'required_sections' => $sections],
'items' => [['kind' => 'image', 'status' => 'failed', 'label' => '不得回传的姓名', 'url' => 'https://private.invalid/image.png', 'upload_file_id' => 'private-id'],
['kind' => 'video', 'status' => 'submitted', 'label' => 'private-video']],
'attachment_count' => 1, 'attachment_transfer' => 'uploaded_files', 'transfer_manifest_final' => true,
'upstream_http_status' => 400, 'upstream_message' => 'provider private message', 'api_key' => 'private-api-key'];
errorReceiptExpect($format->invoke(null, [], $result) === null, 'real formatter retains failure');
$meta = DiagnosisAiLogic::getAssistantErrorMeta();
errorReceiptExpect(count($meta['required_sections']) === 13 && $meta['items'][0]['status'] === 'failed'
&& count($meta['items']) === 1 && $meta['attachment_transfer'] === 'uploaded_files', 'safe complete final receipt survives failure');
$encoded = json_encode($meta, JSON_UNESCAPED_UNICODE);
foreach (['不得回传的姓名', 'private.invalid', 'private-id', 'private-api-key', 'provider private message', 'private-video'] as $private) {
errorReceiptExpect(!str_contains($encoded, $private), 'error metadata excludes private fields');
}
$protocol = new AssistantSseProtocol(); $protocol->encode('start', []);
$frames = [];
$emit = static function (string $event, array $payload) use ($protocol, &$frames): bool {
$frames[] = $protocol->encode($event, $payload); return true;
};
$controller = (new ReflectionClass(DiagnosisController::class))->newInstanceWithoutConstructor();
$finish = new ReflectionMethod(DiagnosisController::class, 'finishAssistantError');
$finish->invoke($controller, $emit, null, false);
$frame = end($frames);
errorReceiptExpect(str_contains($frame, 'REQUIRED_FILES_REJECTED') && str_contains($frame, '"transfer_manifest_final":true')
&& str_contains($frame, '"status":"failed"'), 'real controller SSE error carries authoritative failure receipt');
$frames = []; $cancelProtocol = new AssistantSseProtocol(); $cancelProtocol->encode('start', []);
$cancelEmit = static function (string $event, array $payload) use ($cancelProtocol, &$frames): bool {
$frames[] = $cancelProtocol->encode($event, $payload); return true;
};
$finish->invoke($controller, $cancelEmit, null, true);
errorReceiptExpect(!str_contains(end($frames), 'required_sections') && str_contains(end($frames), 'AI_ASSISTANT_CANCELLED'),
'cancel terminal does not reuse unrelated failed receipt');
$format->invoke(null, [], ['ok' => true, 'content' => '合成回复']);
errorReceiptExpect(DiagnosisAiLogic::getAssistantErrorMeta() === [], 'success clears stale error metadata');
$skippedResult = $result;
$skippedResult['items'] = [
['kind' => 'image', 'status' => 'skipped', 'reason' => 'not_found', 'url' => 'https://private.invalid/missing'],
['kind' => 'image', 'status' => 'failed', 'reason' => 'private-error-body'],
];
$skippedResult['attachment_count'] = 2;
$skippedResult['submitted_attachment_count'] = 0;
$skippedResult['skipped_attachment_count'] = 1;
$format->invoke(null, [], $skippedResult);
$safeSkipped = DiagnosisAiLogic::getAssistantErrorMeta();
errorReceiptExpect($safeSkipped['items'][0]['status'] === 'skipped' && $safeSkipped['items'][0]['reason'] === 'not_found'
&& $safeSkipped['items'][1]['status'] === 'failed' && !isset($safeSkipped['items'][1]['reason'])
&& $safeSkipped['submitted_attachment_count'] === 0 && $safeSkipped['skipped_attachment_count'] === 1,
'final errors preserve safe missing-source facts and separate counters');
errorReceiptExpect(!str_contains(json_encode($safeSkipped), 'private.invalid')
&& !str_contains(json_encode($safeSkipped), 'private-error-body'), 'skipped error metadata never includes resource URLs or private reasons');
$success = $format->invoke(null, [], ['ok' => true, 'content' => '合成回复', 'transfer_manifest_final' => true,
'required_sections' => AiRequiredSource::manifest([]), 'items' => [['kind' => 'image', 'status' => 'skipped', 'reason' => 'not_found']],
'attachment_count' => 1, 'submitted_attachment_count' => 0, 'skipped_attachment_count' => 1,
'attachment_transfer' => 'uploaded_files']);
errorReceiptExpect($success['answer'] === '合成回复' && $success['items'][0]['status'] === 'skipped'
&& $success['submitted_attachment_count'] === 0 && $success['skipped_attachment_count'] === 1,
'a text answer with all attachments missing keeps its successful final receipt');
echo "Assistant final error receipt / real controller SSE / private metadata filter / cancellation isolation: OK\n";