122 lines
10 KiB
PHP
122 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Offline contract test: a disposable dictionary only, no application initialize(), network or clinical writes. */
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
|
if (getenv('FOLLOWUP_AUDIO_FIELDS_SOURCE')) { require (string) getenv('FOLLOWUP_AUDIO_FIELDS_SOURCE'); }
|
|
|
|
use app\common\service\followupaudio\FollowupAudioFields as Fields;
|
|
|
|
new think\App();
|
|
$directory = sys_get_temp_dir() . '/fa-field-contract-' . bin2hex(random_bytes(6));
|
|
mkdir($directory, 0700);
|
|
$database = $directory . '/dictionary.sqlite';
|
|
$pdo = new PDO('sqlite:' . $database);
|
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
$pdo->exec('CREATE TABLE zyt_dict_data(id INTEGER PRIMARY KEY,type_value TEXT,status INTEGER,sort INTEGER,name TEXT,value TEXT)');
|
|
$pdo->exec("INSERT INTO zyt_dict_data VALUES(1,'appetite',1,2,'口干','configured_dry'),(2,'appetite',1,1,'口苦','configured_bitter'),(3,'appetite',0,0,'已停用','disabled')");
|
|
$manager = new think\DbManager();
|
|
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $database, 'prefix' => 'zyt_']]]);
|
|
think\Container::getInstance()->instance('think\DbManager', $manager);
|
|
$checks = 0;
|
|
$expect = static function (bool $ok, string $message) use (&$checks): void {
|
|
if (!$ok) { throw new RuntimeException($message); }
|
|
$checks++;
|
|
};
|
|
$reject = static function (callable $call, string $code) use ($expect): void {
|
|
try { $call(); } catch (DomainException $error) {
|
|
$expect($error->getMessage() === $code, 'Expected ' . $code . ', got ' . $error->getMessage()); return;
|
|
}
|
|
throw new RuntimeException('Expected ' . $code);
|
|
};
|
|
try {
|
|
$catalog = Fields::catalog();
|
|
$byKind = [];
|
|
foreach ($catalog as $kind => $fields) { $byKind[$kind] = array_column($fields, null, 'key'); }
|
|
$expect(array_map('count', $catalog) === ['diagnosis' => 40, 'blood' => 8, 'diet' => 4, 'exercise' => 4, 'tracking_note' => 1], 'Existing 57 fields retained');
|
|
$expect($byKind['diagnosis']['appetite']['options'] === [
|
|
['label' => '口苦', 'value' => 'configured_bitter'], ['label' => '口干', 'value' => 'configured_dry'],
|
|
], 'Configured active dictionary values ordered by sort, never hardcoded seed values');
|
|
$expect($byKind['diagnosis']['water_intake']['options'] === [], 'Empty dictionary remains empty');
|
|
$expect(array_column($byKind['diagnosis']['local_hospital_diagnosis']['options'], 'value') === ['糖尿病', '消渴病', '糖尿病前期'], 'Local diagnosis uses its actual fixed stored strings');
|
|
$expect(array_column($byKind['diagnosis']['gender']['options'], 'value') === [0, 1], 'Integer enum type preserved');
|
|
$expect(Fields::validateValues('diagnosis', ['appetite' => ['configured_dry']]) === ['appetite' => ['configured_dry']], 'Real configured enum accepted');
|
|
$reject(static fn () => Fields::validateValues('diagnosis', ['appetite' => ['dry']]), 'FOLLOWUP_AUDIO_OPTION_INVALID');
|
|
$reject(static fn () => Fields::validateValues('diagnosis', ['appetite' => []]), 'FOLLOWUP_AUDIO_OPTION_INVALID');
|
|
$reject(static fn () => Fields::validateValues('diagnosis', ['water_intake' => 'normal']), 'FOLLOWUP_AUDIO_OPTION_INVALID');
|
|
$reject(static fn () => Fields::validateValues('exercise', ['intensity' => null]), 'FOLLOWUP_AUDIO_FIELD_INVALID');
|
|
$reject(static fn () => Fields::validateValues('exercise', ['intensity' => false]), 'FOLLOWUP_AUDIO_FIELD_INVALID');
|
|
$expect(Fields::validateValues('exercise', ['exercise_type' => '散步', 'duration' => 30]) === ['exercise_type' => '散步', 'duration' => 30], 'Missing intensity stays absent');
|
|
$expect(Fields::validateValues('blood', ['fasting_blood_sugar' => 999.99, 'systolic_pressure' => 999]) === ['fasting_blood_sugar' => 999.99, 'systolic_pressure' => 999], 'Existing upper bounds unchanged');
|
|
$expect(Fields::validateValues('exercise', ['duration' => 1440]) === ['duration' => 1440], 'Existing duration maximum unchanged');
|
|
$reject(static fn () => Fields::validateValues('exercise', ['duration' => 0]), 'FOLLOWUP_AUDIO_NUMBER_INVALID');
|
|
$reject(static fn () => Fields::validateValues('exercise', ['duration' => 1.5]), 'FOLLOWUP_AUDIO_NUMBER_INVALID');
|
|
$reject(static fn () => Fields::validateValues('blood', ['fasting_blood_sugar' => 6.123]), 'FOLLOWUP_AUDIO_NUMBER_INVALID');
|
|
$reject(static fn () => Fields::validateValues('blood', ['fasting_blood_sugar' => 1000]), 'FOLLOWUP_AUDIO_NUMBER_INVALID');
|
|
$expect(Fields::validateValues('diagnosis', ['diabetes_discovery_year' => '自述发现糖尿病约2年。'])['diabetes_discovery_year'] === '自述发现糖尿病约2年。', 'Disease duration remains text');
|
|
$expect(Fields::toDatabase('diet', ['breakfast' => '小米粥1碗']) === ['breakfast_foods' => '小米粥1碗'], 'Meal storage alias unchanged');
|
|
$expect(Fields::toDatabase('diagnosis', ['appetite' => ['configured_dry', 'configured_bitter']]) === ['appetite' => 'configured_dry,configured_bitter'], 'Multiselect storage unchanged');
|
|
$pdo->exec("UPDATE zyt_dict_data SET value='updated_dry' WHERE id=1");
|
|
$fresh = array_column(Fields::catalog()['diagnosis'], null, 'key');
|
|
$expect($fresh['appetite']['options'][1]['value'] === 'updated_dry', 'Dictionary is reloaded for each catalog');
|
|
$reject(static fn () => Fields::validateValues('diagnosis', ['appetite' => ['configured_dry']]), 'FOLLOWUP_AUDIO_OPTION_INVALID');
|
|
$expect(Fields::validateValues('diagnosis', ['appetite' => ['updated_dry']]) === ['appetite' => ['updated_dry']], 'Validator consumes same fresh dictionary');
|
|
|
|
if (!in_array('--validation-only', $argv, true)) {
|
|
foreach ([['diet', 'breakfast', 500], ['diet', 'lunch', 500], ['diet', 'dinner', 500],
|
|
['diet', 'note', 255], ['exercise', 'note', 255]] as [$kind, $key, $limit]) {
|
|
$expect($byKind[$kind][$key]['max'] === $limit, "$kind.$key matches deployed VARCHAR size");
|
|
$value = str_repeat('合', $limit);
|
|
$expect(Fields::validateValues($kind, [$key => $value]) === [$key => $value], "$kind.$key accepts exact Unicode length");
|
|
$reject(static fn () => Fields::validateValues($kind, [$key => $value . '成']), 'FOLLOWUP_AUDIO_TEXT_INVALID');
|
|
}
|
|
$definitions = (new ReflectionMethod(Fields::class, 'definitions'))->invoke(null);
|
|
foreach ($catalog as $kind => $fields) {
|
|
foreach ($fields as $field) {
|
|
$key = $field['key'];
|
|
foreach (['type', 'min', 'max', 'integer', 'dictionary'] as $constraint) {
|
|
if (array_key_exists($constraint, $definitions[$kind][$key])) {
|
|
$expect(($field[$constraint] ?? null) === $definitions[$kind][$key][$constraint], "$kind.$key exposes shared $constraint");
|
|
}
|
|
}
|
|
foreach (['semantic', 'temporal_scope', 'missing_policy', 'source_policy', 'recording_rules'] as $metadata) {
|
|
$expect(!empty($field[$metadata]), "$kind.$key exposes $metadata");
|
|
}
|
|
$expect(str_contains($field['missing_policy'], '不填表单默认值'), "$kind.$key never fills defaults");
|
|
if (isset($field['options'])) {
|
|
$expect(str_contains($field['enum_no_match_policy'], '无匹配或字典为空则省略'), "$kind.$key no-match is explicit");
|
|
}
|
|
}
|
|
}
|
|
$assertRule = static function (string $kind, string $key, string $fragment) use ($byKind, $expect): void {
|
|
$expect(str_contains(json_encode($byKind[$kind][$key], JSON_UNESCAPED_UNICODE), $fragment), "$kind.$key contains semantic rule: $fragment");
|
|
};
|
|
foreach ([['diagnosis', 'appetite', '不是食欲'], ['diagnosis', 'diabetes_discovery_year', '不是发现年份'],
|
|
['diagnosis', 'current_medications', '历史停药与当前恢复分开'], ['diagnosis', 'diagnosis_date', '不从病程推算'],
|
|
['diagnosis', 'local_hospital_diagnosis', '不从血糖或症状推断诊断'], ['diagnosis', 'diagnosis_type', '初诊、复诊或会诊'], ['diagnosis', 'diagnosis_type', '推断初复诊'],
|
|
['diagnosis', 'fatty_liver_degree', '不依据症状或体重推断分级'], ['diagnosis', 'allergy_history', '整体有无'],
|
|
['diagnosis', 'weight_change', '近一个月'], ['diagnosis', 'water_intake', '不推毫升数'],
|
|
['blood', 'postprandial_blood_sugar', '仅明确餐后2小时'], ['blood', 'western_medicine', '某日西药实际服用事件'],
|
|
['blood', 'insulin', '不将一次注射升级为长期方案'], ['exercise', 'intensity', '不继承UI默认1'],
|
|
['exercise', 'duration', '未运动不生成duration=0'], ['diet', 'note', '加餐'],
|
|
['diet', 'breakfast', '不输出breakfast_foods'], ['exercise', 'note', '不假装具备时刻级存储'],
|
|
['tracking_note', 'content', '按事件日期追加而非覆盖']] as $case) { $assertRule(...$case); }
|
|
foreach ([['diagnosis', 'patient_name'], ['diagnosis', 'phone'], ['diagnosis', 'id_card'],
|
|
['diagnosis', 'region'], ['diagnosis', 'local_hospital_name'], ['exercise', 'exercise_type']] as [$kind, $key]) {
|
|
$assertRule($kind, $key, '标量文本只输出');
|
|
$expect(!str_contains(implode(' ', $byKind[$kind][$key]['recording_rules']), '整理为简洁记录句'), "$kind.$key is not narrative");
|
|
}
|
|
$expect($byKind['blood']['postprandial_blood_sugar']['unit'] === 'mmol/L', 'Postprandial unit exposed');
|
|
$expect($byKind['exercise']['duration']['decimal_places'] === 0, 'Integer precision exposed');
|
|
$expect($byKind['blood']['fasting_blood_sugar']['decimal_places'] === 2, 'Decimal precision exposed');
|
|
$expect($byKind['blood']['postprandial_blood_sugar']['label'] === '餐后2小时血糖(mmol/L)', 'Catalog label agrees with existing UI meaning');
|
|
}
|
|
echo 'FOLLOWUP_AUDIO_FIELD_CONTRACT assertions=' . $checks . ' PASS mode=' . (in_array('--validation-only', $argv, true) ? 'validation' : 'full') . " fields=57 network_calls=0 clinical_writes=0\n";
|
|
} finally {
|
|
$pdo = null;
|
|
if (is_file($database)) { unlink($database); }
|
|
rmdir($directory);
|
|
}
|