Files
zyt/server/tests/PrescriptionAiProgressTest.php
2026-09-10 15:19:17 +08:00

90 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) { throw new RuntimeException($message); }
$checks++;
};
$now = 1900000000;
$private = 'private patient text https://secret.example.test key=fixture';
$raw = ['stage' => 'text', 'phase' => 'waiting', 'completed_units' => 2, 'total_units' => 5,
'stage_started_at' => $now - 80, 'updated_at' => $now - 60,
'steps' => [['value' => ['content' => $private]]], 'notice' => $private, 'model_key' => $private,
'source_hash' => $private, 'lock_token' => $private, 'stage_label' => $private, 'usage' => [$private]];
$meta = Progress::sanitize($raw);
$expect(array_keys($meta) === ['stage', 'phase', 'completed_units', 'total_units', 'stage_started_at', 'updated_at'],
'storage accepts only the fixed metadata keys');
$expect(!str_contains(json_encode($meta), 'private') && strlen(json_encode($meta)) < 2048, 'storage has no free text or model cache');
foreach ($meta as $value) { $expect(is_scalar($value) || $value === null, 'metadata is scalar only'); }
$task = ['status' => 'running', 'started_at' => $now - 120, 'updated_at' => $now - 2,
'progress_json' => json_encode($raw), 'attempts' => 1, 'total_attempts' => 4, 'lock_until' => $now - 10];
$result = Progress::task($task, $now);
$expect($result['elapsed_seconds'] === 120 && $result['stage_elapsed_seconds'] === 80
&& $result['completed_units'] === 2 && $result['total_units'] === 5 && $result['attempt'] === 4,
'measured stage and current-attempt timing use trusted clocks');
$expect($result['phase'] === 'waiting' && str_contains($result['notice'], '等待模型返回') && !str_contains(json_encode($result), 'private'),
'API labels and waiting notices are authored locally');
$expect(!array_key_exists('percent', $result) && !array_key_exists('progress_cipher', $result), 'no invented overall percentage or encrypted cache');
$stale = Progress::task($task, $now + 100);
$expect(str_contains($stale['notice'], '暂无新的进度更新') && !str_contains($stale['notice'], '超时')
&& !str_contains($stale['notice'], '失联') && $stale['stage'] === 'text', 'quiet progress never diagnoses a dead worker or timeout');
$terminalExpected = ['success' => ['completed', 'completed'], 'failed' => ['failed', 'failed'], 'cancelled' => ['cancelled', 'failed'],
'queued' => ['queued', 'waiting'], 'retry_wait' => ['retry_wait', 'waiting']];
foreach ($terminalExpected as $status => [$stage, $phase]) {
$result = Progress::task(array_replace($task, ['status' => $status, 'finished_at' => $now - 20, 'next_run_at' => $now + 30]), $now);
$expect($result['stage'] === $stage && $result['phase'] === $phase && $result['completed_units'] === null,
'task state overrides stale counters: ' . $status);
if (in_array($status, ['success', 'failed', 'cancelled'], true)) {
$expect($result['elapsed_seconds'] === 100, 'terminal duration stops advancing: ' . $status);
}
}
$retry = Progress::task(array_replace($task, ['status' => 'retry_wait', 'next_run_at' => $now + 30, 'error_code' => 'BUDGET_PAUSED']), $now);
$expect($retry['wait_remaining_seconds'] === 30 && str_contains($retry['notice'], '额度'), 'retry waiting explains scheduled budget pause');
$expect(str_contains($retry['notice'], '上次进度:整理文字资料') && str_contains($retry['notice'], '本次尝试'),
'waiting retries retain a trusted stage description and identify attempt timing');
$finishedFailure = array_replace($task, ['status' => 'failed', 'finished_at' => $now - 20, 'updated_at' => $now - 1]);
$expect(Progress::task($finishedFailure, $now + 1000)['elapsed_seconds'] === 100,
'historical terminal duration uses finish time despite later metadata updates or polling');
$finishedRetry = array_replace($finishedFailure, ['status' => 'retry_wait', 'next_run_at' => $now + 30]);
$expect(Progress::task($finishedRetry, $now + 1000)['elapsed_seconds'] === 100,
'scheduled retry backoff does not inflate the previous model attempt duration');
$expect(Progress::task(array_replace($task, ['status' => 'retry_wait', 'next_run_at' => $now - 30]), $now)['wait_remaining_seconds'] === 0,
'elapsed retry deadlines do not become negative');
foreach ([null, '{}', str_repeat('x', 3000), '{invalid', json_encode(['stage' => 'completed', 'phase' => 'completed'])] as $old) {
$result = Progress::task(array_replace($task, ['progress_json' => $old]), $now);
$expect($result['stage'] === 'unknown' && $result['phase'] === 'running' && $result['stage_elapsed_seconds'] === null,
'old/missing/invalid progress cannot claim current task completion');
}
$bad = Progress::sanitize(['stage' => $private, 'phase' => $private, 'completed_units' => $private, 'total_units' => [],
'updated_at' => [], 'stage_started_at' => -2]);
$expect($bad['stage'] === 'unknown' && $bad['phase'] === 'running' && $bad['completed_units'] === null && $bad['updated_at'] === 0,
'malformed metadata is harmless');
$expect(Progress::sanitize(['stage' => 'text', 'completed_units' => 9, 'total_units' => 2])['completed_units'] === 2,
'bounded counters cannot exceed their stage total');
$fresh = Progress::advance($meta, 'text', 'running', 0, 5, $now, true);
$expect($fresh['stage_started_at'] === $now, 'new attempt/round explicitly resets timing even for the same stage');
$expect(Progress::advance($fresh, 'text', 'waiting', 0, 5, $now + 5)['stage_started_at'] === $now,
'waiting and parsing transitions retain measured stage start');
$future = Progress::task(array_replace($task, ['started_at' => $now + 50,
'progress_json' => json_encode(Progress::advance([], 'files', 'waiting', 0, 2, $now + 60))]), $now);
$expect($future['elapsed_seconds'] === 0 && $future['stage_elapsed_seconds'] === 0 && $future['updated_at'] === $now,
'clock skew cannot produce negative elapsed time or future update labels');
$batch = ['status' => 'waiting_sources', 'wait_until' => $now + 15, 'created_at' => $now - 90, 'updated_at' => $now - 3];
$waiting = Progress::batch($batch, $now);
$expect($waiting['stage'] === 'waiting_sources' && $waiting['wait_remaining_seconds'] === 15
&& $waiting['stage_elapsed_seconds'] === null && str_contains($waiting['notice'], '自动'), 'waiting source deadline is visible without inventing a polling stage start');
$expired = Progress::batch($batch, $now + 16);
$expect($expired['wait_remaining_seconds'] === 0 && str_contains($expired['notice'], '期限已到')
&& $expired['stage'] === 'waiting_sources', 'expired source deadline stays waiting until coordinator really advances');
$historyBatch = Progress::batch(['status' => 'success', 'validity' => 'source_updated', 'created_at' => $now - 200, 'updated_at' => $now - 1], $now,
['qwen' => ['progress' => ['phase' => 'completed', 'updated_at' => $now - 50]],
'openai' => ['progress' => ['phase' => 'completed', 'updated_at' => $now - 60]]]);
$expect($historyBatch['elapsed_seconds'] === 150, 'later source validity updates cannot inflate historical batch completion duration');
echo "Prescription AI progress: {$checks} checks passed\n";