Files
zyt/server/tests/LocalAudioTranscriptRecoveryTest.php
T
2026-11-09 10:02:27 +08:00

205 lines
11 KiB
PHP

<?php
declare(strict_types=1);
// Execute the production recovery and realtime finish logic against in-memory IO.
namespace recovery_test {
final class Record extends \ArrayObject
{
public function save(array $values): void
{
if (!\think\facade\Db::$transaction) { throw new \RuntimeException('Write outside transaction'); }
if (\think\facade\Db::$failSave) { throw new \RuntimeException('Synthetic database failure'); }
foreach ($values as $key => $value) { $this[$key] = $value; }
}
}
final class RecordQuery
{
private array $filters = [];
private bool $locked = false;
public function where(string $key, $value): self { $this->filters[$key] = $value; return $this; }
public function lock(bool $locked): self { $this->locked = $locked; return $this; }
public function find(): ?Record
{
if (!$this->locked || !\think\facade\Db::$transaction) { throw new \RuntimeException('Missing transaction row lock'); }
foreach (\app\common\model\tcm\CallRecord::$rows as $row) {
foreach ($this->filters as $key => $value) { if (($row[$key] ?? null) !== $value) { continue 2; } }
return $row;
}
return null;
}
}
final class SegmentQuery
{
private array $filters = [];
public function where(string $key, $value): self { $this->filters[$key] = $value; return $this; }
public function order(string $order): self { return $this; }
public function select(): self { return $this; }
public function toArray(): array
{
return array_values(array_filter(\think\facade\Db::$segments, function (array $row): bool {
foreach ($this->filters as $key => $value) { if ($row[$key] !== $value) { return false; } }
return true;
}));
}
public function insertAll(array $rows): void
{
if (!\think\facade\Db::$transaction) { throw new \RuntimeException('Segment write outside transaction'); }
foreach ($rows as $row) {
foreach (\think\facade\Db::$segments as $existing) {
if ($row['call_record_id'] === $existing['call_record_id']
&& $row['transcription_session_id'] === $existing['transcription_session_id']
&& $row['segment_id'] === $existing['segment_id']) { throw new \RuntimeException('Duplicate segment'); }
}
\think\facade\Db::$segments[] = $row;
}
}
}
}
namespace app\common\model\tcm {
final class CallRecord
{
public static array $rows = [];
public static function where(string $key, $value): \recovery_test\RecordQuery
{
return (new \recovery_test\RecordQuery())->where($key, $value);
}
}
}
namespace think\facade {
final class Db
{
public static bool $transaction = false;
public static bool $failSave = false;
public static array $segments = [];
public static function transaction(callable $callback)
{
$rows = array_map(static fn ($row) => clone $row, \app\common\model\tcm\CallRecord::$rows);
$segments = self::$segments;
self::$transaction = true;
try { return $callback(); }
catch (\Throwable $e) {
\app\common\model\tcm\CallRecord::$rows = $rows;
self::$segments = $segments;
throw $e;
} finally { self::$transaction = false; }
}
public static function name(string $name): \recovery_test\SegmentQuery
{
if ($name !== 'tcm_call_transcript_segment') { throw new \RuntimeException('Unexpected table'); }
return new \recovery_test\SegmentQuery();
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisLogic as Logic;
use app\common\model\tcm\CallRecord;
use think\facade\Db;
$assertions = 0;
function check(bool $condition, string $message): void
{
global $assertions;
if (!$condition) { throw new RuntimeException($message); }
$assertions++;
}
function fixture(array $changes = []): array
{
CallRecord::$rows = [new recovery_test\Record(array_replace([
'id' => 17, 'diagnosis_id' => 23, 'caller_id' => 91, 'caller_type' => 'doctor',
'status' => 2, 'local_audio_status' => 2, 'local_audio_urls' => '["https://audio.invalid/synthetic.webm"]',
'transcription_session_id' => 'original-session', 'transcription_status' => 'partial',
'transcription_segment_count' => 1, 'transcript_text' => '未知说话人:片段',
], $changes))];
Db::$segments = [['call_record_id' => 17, 'transcription_session_id' => 'original-session',
'segment_id' => 'old-1', 'text' => '片段', 'speaker_role' => 'unknown', 'timestamp_ms' => 10]];
Db::$failSave = false;
return ['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
'recovery_id' => 'audio-' . str_repeat('a', 32), 'expected_session_id' => 'original-session',
'expected_status' => 'partial', 'audio_url' => 'https://audio.invalid/synthetic.webm',
'transcript_text' => "完整的合成录音。\n保留结尾及否定:没有服药。"];
}
function rejects(array $params, string $label): void
{
$before = serialize([CallRecord::$rows, Db::$segments]);
check(Logic::saveLocalAudioTranscript($params) === false, $label . ' rejects');
check(serialize([CallRecord::$rows, Db::$segments]) === $before, $label . ' does not mutate');
}
$params = fixture();
$result = Logic::saveLocalAudioTranscript($params);
check($result === ['call_record_id' => 17, 'transcription_status' => 'completed', 'saved' => true], 'recovery saves');
check(CallRecord::$rows[0]['transcription_session_id'] === $params['recovery_id'], 'new session is active');
check(CallRecord::$rows[0]['transcript_text'] === '未知说话人:' . $params['transcript_text'], 'complete text retained without claiming a speaker');
check(count(Db::$segments) === 2 && Db::$segments[0]['text'] === '片段', 'old session retained');
check(Db::$segments[1]['speaker_role'] === 'unknown' && Db::$segments[1]['timestamp_ms'] === 0, 'no invented attribution or timing');
$before = serialize([CallRecord::$rows, Db::$segments]);
check(Logic::saveLocalAudioTranscript($params) === $result, 'lost response retry is idempotent despite stale CAS');
check(serialize([CallRecord::$rows, Db::$segments]) === $before, 'retry writes no duplicate segments');
$late = Logic::upsertCallTranscriptSegments(['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
'transcription_session_id' => 'original-session', 'segments' => [['segment_id' => 'late', 'text' => '迟到分段']]]);
check($late === false, 'old realtime writer cannot overwrite replacement session');
$lateFinish = Logic::finishCallTranscription(['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
'transcription_session_id' => 'original-session', 'status' => 'completed', 'expected_segment_count' => 1]);
check($lateFinish === false && serialize([CallRecord::$rows, Db::$segments]) === $before, 'late finish cannot overwrite replacement');
$params = fixture(['transcription_status' => 'completed']);
$before = serialize([CallRecord::$rows, Db::$segments]);
check(Logic::saveLocalAudioTranscript($params) === ['call_record_id' => 17, 'transcription_status' => 'completed',
'saved' => false, 'already_complete' => true], 'another complete session wins even with stale CAS');
check(serialize([CallRecord::$rows, Db::$segments]) === $before, 'completed transcript never overwritten');
foreach (['', 'pending', 'running', 'partial', 'failed', 'completed'] as $status) {
$params = fixture(['transcription_status' => $status, 'transcript_text' => '']);
$params['expected_status'] = $status;
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'recover empty/nonterminal session ' . $status);
}
$params = fixture(['transcription_session_id' => '', 'transcription_status' => '', 'transcript_text' => '']);
$params['expected_session_id'] = $params['expected_status'] = '';
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'record without prior session can recover');
foreach (['expected_session_id' => 'changed-session', 'expected_status' => 'running',
'audio_url' => 'https://audio.invalid/different.webm', 'admin_id' => 92, 'diagnosis_id' => 24,
'call_record_id' => 18, 'recovery_id' => 'invalid', 'transcript_text' => ' '] as $key => $value) {
$params = fixture(); $params[$key] = $value; rejects($params, 'invalid ' . $key);
}
foreach ([['status' => 1], ['status' => 3], ['caller_type' => 'patient'], ['local_audio_status' => 0],
['local_audio_urls' => 'invalid'], ['local_audio_urls' => '[]']] as $changes) {
rejects(fixture($changes), 'invalid record ' . json_encode($changes));
}
foreach ([null, [], false, 23] as $value) {
$params = fixture(); $params['transcript_text'] = $value; rejects($params, 'non-string text');
}
$params = fixture(); $params['transcript_text'] = str_repeat('字', 100001); rejects($params, 'oversize text');
$params = fixture(); $params['transcript_text'] = str_repeat('字', 100000);
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'Unicode limit counts characters');
$newSegments = array_slice(Db::$segments, 1);
check(count($newSegments) === 25 && implode('', array_column($newSegments, 'text')) === $params['transcript_text'],
'bounded segments preserve all 100000 characters in order');
check(max(array_map(static fn ($row) => mb_strlen($row['text']), $newSegments)) <= 4000, 'segment schema limit respected');
$params = fixture(); Db::$failSave = true;
rejects($params, 'transaction rollback after segment insertion');
check(!str_contains(Logic::getError(), 'Synthetic'), 'internal failure detail is not exposed');
fixture(['transcription_status' => 'running', 'transcript_text' => '', 'transcription_segment_count' => 0]);
Db::$segments = [];
$finish = ['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
'transcription_session_id' => 'original-session', 'status' => 'completed', 'expected_segment_count' => 0];
check(Logic::finishCallTranscription($finish)['status'] === 'failed', 'zero-segment completion is failed');
fixture(['transcription_status' => 'running']);
$finish['expected_segment_count'] = 2;
check(Logic::finishCallTranscription($finish)['status'] === 'partial', 'missing requested segments stays partial');
fixture(['transcription_status' => 'running']);
$finish['expected_segment_count'] = 1;
check(Logic::finishCallTranscription($finish)['status'] === 'completed', 'nonempty complete realtime session still completes');
echo "Local audio transcript recovery: {$assertions} assertions passed (no DB or network)\n";
}