feat(followup-audio): normalize extraction to record field contracts

This commit is contained in:
2026-10-12 14:34:31 +08:00
parent 9e3aa9fc61
commit 86041042b9
9 changed files with 449 additions and 21 deletions
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require __DIR__ . '/fixtures/followup_audio/database.php';
if ($source = getenv('FOLLOWUP_EXERCISE_APPLY_SOURCE')) { require $source; }
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\adminapi\logic\tcm\ExerciseRecordLogic;
use app\common\model\tcm\ExerciseRecord;
use think\facade\Db;
$f = followupAudioTestDatabase();
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$make = static function (array $values, string $date, ?int $target = null) use ($f, $expect): array {
$data = str_repeat(pack('v', 1000), 16000) . random_bytes(2);
$path = $f['private'] . '/exercise.wav';
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($data)) . $data);
chmod($path, 0600);
$upload = followupAudioTestUpload($f, $path, 'synthetic-exercise.wav');
$task = Store::create($upload, '2026-09-29 20:00:00', 'qwen', 1, $f['actor']);
$claim = Store::claim(); $expect($claim['id'] === $task['id'], 'isolated task claimed');
$text = $date . '散步,运动信息以人工核对的合成测试输入为准。';
$expect(Store::complete($task['id'], $claim['lease_token'], [
'summary' => '合成运动测试', 'transcript' => $text, 'uncertainties' => [], 'items' => [[
'kind' => 'exercise', 'values' => $values, 'record_date' => $date, 'record_time' => null,
'date_text' => $date, 'time_text' => '', 'time_period' => null, 'time_estimated' => true,
'evidence' => [['text' => $text]], 'needs_review' => true,
]],
]), 'real encrypted extraction/review complete');
$detail = Store::detail($task['id']);
$expect(count($detail['items']) === 1, 'one exercise item normalized');
$items = $detail['items']; $items[0]['target_id'] = $target;
$items[0]['selected'] = false; $items[0]['needs_review'] = true;
$draft = Store::saveDraft($task['id'], $detail['version'], $items, 1);
$items = $draft['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$draft = Store::saveDraft($task['id'], $draft['version'], $items, 1);
$result = Apply::apply($task['id'], $draft['version'], $draft['items'], 1, $f['actor']);
$expect($result['status'] === 'applied', 'real Apply transaction completed');
$id = $result['applied_items'][0]['record_id'];
return ExerciseRecordLogic::detail(['id' => $id]);
};
try {
// Mirror verified deployed nullable numeric columns AND their fact-fabricating defaults.
$f['pdo']->exec('ALTER TABLE zyt_patient_exercise_record MODIFY duration INT NULL DEFAULT 0, MODIFY intensity TINYINT NULL DEFAULT 1, ADD images TEXT NULL');
$row = $make(['exercise_type' => '散步'], '2026-09-29');
echo 'EXERCISE_INSERT_RELOAD ' . json_encode(['duration' => $row['duration'], 'intensity' => $row['intensity']], JSON_UNESCAPED_UNICODE) . PHP_EOL;
$expect($row['duration'] === null && $row['intensity'] === null, 'missing duration/intensity must reload NULL, never database defaults 0/1');
$expect(ExerciseRecord::find($row['id'])->append(['intensity_text'])->toArray()['intensity_text'] === '未知', 'real read label is unknown, not low intensity');
$expect(ExerciseRecordLogic::edit(['id' => $row['id'], 'record_date' => '2026-09-29', 'note' => '仅更改备注', 'duration' => null, 'intensity' => null]), 'DailyMatrix null edit accepted');
$reload = ExerciseRecordLogic::detail(['id' => $row['id']]);
$expect($reload['duration'] === null && $reload['intensity'] === null && $reload['note'] === '仅更改备注', 'manual edit/reload retains nulls');
// Exercise editor uses the public controller and app Request with the global trim filter.
$request = (new app\Request())->withServer(['REQUEST_METHOD' => 'POST'])->withPost([
'id' => $row['id'], 'record_date' => '2026-09-29', 'duration' => null, 'intensity' => null,
'note' => ' 只改备注 ',
]);
$controller = (new ReflectionClass(app\adminapi\controller\tcm\ExerciseRecordController::class))->newInstanceWithoutConstructor();
$property = new ReflectionProperty($controller, 'request');
$property->setAccessible(true); $property->setValue($controller, $request);
$response = $controller->edit()->getData();
echo 'EXERCISE_CONTROLLER_NULL_EDIT code=' . $response['code'] . PHP_EOL;
$expect($response['code'] === 1, 'real editor controller must accept JSON null through global trim filter');
$reload = ExerciseRecordLogic::detail(['id' => $row['id']]);
$expect($reload['duration'] === null && $reload['intensity'] === null, 'controller edit/reload preserves null scalars');
$expect($reload['note'] === '只改备注', 'existing text trimming remains unchanged');
$known = $make(['exercise_type' => '散步', 'duration' => 30, 'intensity' => 2], '2026-09-28');
$expect((int) $known['duration'] === 30 && (int) $known['intensity'] === 2, 'explicit values retained');
$updated = $make(['note' => '只补充备注'], '2026-09-28', (int) $known['id']);
$expect((int) $updated['duration'] === 30 && (int) $updated['intensity'] === 2, 'audio update omitted fields retain known values');
$durationOnly = $make(['exercise_type' => '散步', 'duration' => 20], '2026-09-27');
$expect((int) $durationOnly['duration'] === 20 && $durationOnly['intensity'] === null, 'duration only leaves intensity unknown');
$intensityOnly = $make(['exercise_type' => '散步', 'intensity' => 1], '2026-09-26');
$expect($intensityOnly['duration'] === null && (int) $intensityOnly['intensity'] === 1, 'intensity only leaves duration unknown');
$expect(ExerciseRecordLogic::edit(['id' => $row['id'], 'duration' => 0]), 'manual historical zero accepted');
$zero = ExerciseRecordLogic::detail(['id' => $row['id']]);
$expect((int) $zero['duration'] === 0 && $zero['duration'] !== null, 'manual zero differs from missing; audio validator unchanged');
echo 'FOLLOWUP_AUDIO_MISSING_EXERCISE assertions=' . $checks . ' PASS real_mysql=1 actual_apply_reload=1 manual_edit_reload=1 update_omission_preserved=1' . PHP_EOL;
} finally { followupAudioTestDatabaseCleanup($f); }