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); }