Files
zyt/server/tests/AssistantServiceAutomationTest.php
T
2026-11-12 09:07:07 +08:00

308 lines
23 KiB
PHP

<?php
declare(strict_types=1);
// 真实 ORM 与权限/业务代码,临时 SQLite;不初始化应用、不读 .env、不联网。
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\tcm\AssistantServiceLogic as Logic;
use app\adminapi\validate\tcm\AssistantServiceValidate as Validate;
use app\common\service\assistantservice\AssistantServiceAccess as Access;
use app\common\service\assistantservice\AssistantServiceContext as Context;
use app\common\service\assistantservice\AssistantServiceAutomation as Automation;
use think\Container;
use think\facade\Db;
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
throw new RuntimeException('Run: php -d extension=php_pdo_sqlite.dll tests/AssistantServiceApiTest.php');
}
new think\App();
set_exception_handler(static function (Throwable $e): void {
fwrite(STDERR, $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1);
});
$manager = new think\DbManager();
$manager->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
'type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'test_', 'fields_strict' => true,
]]]);
Container::getInstance()->instance('think\DbManager', $manager);
think\Model::setDb($manager);
$config = new think\Config();
$config->set(['enabled' => true, 'profile' => 'qwen', 'timeout' => 120, 'daily_job_limit' => 100,
'max_source_rows' => 200, 'max_source_chars' => 30000, 'max_manual_chars' => 10000, 'max_knowledge_rows' => 40], 'assistant_service');
$config->set(['enable' => true, 'base_url' => 'https://example.invalid', 'models' => [
'qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-no-network']]], 'prescription_ai');
$config->set(['data_scope' => ['enabled' => true, 'no_dept_fallback_self' => true]], 'project');
Container::getInstance()->instance('config', $config);
$tables = [
'admin' => 'id INTEGER PRIMARY KEY,name TEXT,root INTEGER DEFAULT 0,disable INTEGER DEFAULT 0,delete_time INTEGER NULL',
'admin_role' => 'admin_id INTEGER,role_id INTEGER',
'admin_dept' => 'admin_id INTEGER,dept_id INTEGER',
'dept' => 'id INTEGER PRIMARY KEY,pid INTEGER,status INTEGER DEFAULT 1,delete_time INTEGER NULL',
'system_role' => 'id INTEGER PRIMARY KEY,name TEXT,data_scope INTEGER,delete_time INTEGER NULL',
'system_role_menu' => 'role_id INTEGER,menu_id INTEGER',
'system_menu' => 'id INTEGER PRIMARY KEY,perms TEXT,is_disable INTEGER DEFAULT 0',
'doctor_appointment' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,doctor_id INTEGER,status INTEGER',
'tcm_diagnosis' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,assistant_id INTEGER,patient_name TEXT,phone TEXT,status INTEGER DEFAULT 1,delete_time INTEGER NULL',
'tracking_note' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,admin_id INTEGER,note_date TEXT,content TEXT,create_time INTEGER,update_time INTEGER,delete_time INTEGER NULL',
'tcm_im_chat_message' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,diagnosis_id INTEGER,from_account TEXT,to_account TEXT,doctor_peer_account TEXT,msg_type TEXT,text TEXT,msg_time INTEGER',
'tcm_diagnosis_todo' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,content TEXT,remind_time INTEGER,status INTEGER,creator_id INTEGER,creator_name TEXT,create_time INTEGER,update_time INTEGER,notified_at INTEGER DEFAULT 0,cancelled_at INTEGER DEFAULT 0,cancelled_by INTEGER DEFAULT 0,delete_time INTEGER NULL',
'assistant_service_record' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,actor_id INTEGER,content TEXT,record_type TEXT,speaker_confirmed INTEGER,occurred_at INTEGER,created_at INTEGER,delete_time INTEGER NULL',
'assistant_service_job' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,actor_id INTEGER,type TEXT,scenario TEXT,days INTEGER,status TEXT,version INTEGER,dedupe_key TEXT UNIQUE,source_hash TEXT,scope_hash TEXT,payload_json TEXT,result_json TEXT,claim_token TEXT DEFAULT "",lease_until INTEGER DEFAULT 0,error_code TEXT DEFAULT "",error_message TEXT DEFAULT "",upstream_id TEXT DEFAULT "",created_at INTEGER,updated_at INTEGER,finished_at INTEGER DEFAULT 0',
'assistant_service_review' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,job_id INTEGER,item_key TEXT,reviewer_id INTEGER,decision TEXT,note TEXT,created_at INTEGER',
'assistant_service_action' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,job_id INTEGER,action_key TEXT,todo_id INTEGER,assignee_id INTEGER,content TEXT,due_at INTEGER,business_state TEXT,completion_note TEXT DEFAULT "",completed_at INTEGER DEFAULT 0,created_at INTEGER,updated_at INTEGER,UNIQUE(job_id,action_key)',
'assistant_service_knowledge' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,question TEXT,answer TEXT,category TEXT,source_note TEXT,status TEXT,version INTEGER,created_by INTEGER,published_by INTEGER,created_at INTEGER,updated_at INTEGER',
];
foreach ($tables as $name => $schema) { Db::execute('CREATE TABLE test_' . $name . ' (' . $schema . ')'); }
Db::name('admin')->insertAll([
['id' => 1, 'name' => 'root', 'root' => 1], ['id' => 11, 'name' => 'assistant', 'root' => 0],
['id' => 12, 'name' => 'other', 'root' => 0], ['id' => 13, 'name' => 'team', 'root' => 0], ['id' => 14, 'name' => 'doctor', 'root' => 0],
]);
Db::name('system_role')->insertAll([
['id' => 1, 'name' => '医生', 'data_scope' => 1], ['id' => 2, 'name' => '医助', 'data_scope' => 4], ['id' => 3, 'name' => '经理', 'data_scope' => 3],
]);
Db::name('admin_role')->insertAll([['admin_id' => 11, 'role_id' => 2], ['admin_id' => 12, 'role_id' => 2], ['admin_id' => 13, 'role_id' => 3], ['admin_id' => 14, 'role_id' => 1]]);
Db::name('dept')->insertAll([['id' => 10, 'pid' => 0], ['id' => 20, 'pid' => 0]]);
Db::name('admin_dept')->insertAll([['admin_id' => 11, 'dept_id' => 10], ['admin_id' => 12, 'dept_id' => 20], ['admin_id' => 13, 'dept_id' => 10]]);
foreach (Access::PERMISSIONS as $i => $permission) {
Db::name('system_menu')->insert(['id' => $i + 1, 'perms' => 'tcm.assistantService/' . $permission]);
foreach ([1, 2, 3] as $role) { Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $i + 1]); }
}
Db::name('tcm_diagnosis')->insertAll([
['id' => 101, 'patient_id' => 201, 'assistant_id' => 11, 'patient_name' => '合成甲', 'phone' => '13800138000'],
['id' => 102, 'patient_id' => 202, 'assistant_id' => 12, 'patient_name' => '合成乙', 'phone' => '13900139000'],
['id' => 103, 'patient_id' => 0, 'assistant_id' => 11, 'patient_name' => '未映射', 'phone' => ''],
]);
Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 101, 'doctor_id' => 14, 'status' => 1]);
$now = time();
Db::name('tracking_note')->insert(['id' => 1, 'diagnosis_id' => 101, 'admin_id' => 11,
'note_date' => date('Y-m-d'), 'content' => '患者要求周六确认预约', 'create_time' => $now, 'update_time' => $now]);
foreach ([
[1, 201, 'patient_201', 'doctor_11', 'text', '请帮我核对预约,电话13800138000'],
[2, 201, 'doctor_11', 'patient_201', 'text', '我将在明天回复可预约时段'],
[3, 201, 'doctor_12', 'patient_201', 'text', '其他部门私有沟通'],
[4, 201, 'doctor_11', 'patient_999', 'text', '错误患者映射'],
[5, 201, 'doctor_11', 'patient_201', 'image', '不可读图片'],
[6, 201, 'doctor_999', 'patient_201', 'text', '不存在的员工'],
] as [$id, $patient, $from, $to, $type, $text]) {
Db::name('tcm_im_chat_message')->insert(['id' => $id, 'patient_id' => $patient, 'diagnosis_id' => 999,
'from_account' => $from, 'to_account' => $to, 'doctor_peer_account' => 'doctor_11', 'msg_type' => $type, 'text' => $text, 'msg_time' => $now - 100]);
}
$checks = 0;
$ok = static function (bool $condition, string $label) use (&$checks): void {
if (!$condition) { throw new RuntimeException($label); } $checks++;
};
$temporary = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'assistant-auto-test-' . bin2hex(random_bytes(8));
$clock = time();
$automation = new Automation($temporary, static function () use (&$clock): int { return $clock; });
$tick = static function (int $times = 1) use ($automation, &$clock): array {
$result = [];
for ($i = 0; $i < $times; $i++) { $clock += 61; $result = $automation->tick(); }
return $result;
};
$manual = static function (int $diagnosis, int $patient, int $assistant = 11, ?int $at = null) use ($now): int {
return (int) Db::name('assistant_service_record')->insertGetId(['diagnosis_id' => $diagnosis, 'patient_id' => $patient,
'actor_id' => $assistant, 'content' => '合成服务记录 ' . $diagnosis, 'record_type' => 'service_note',
'speaker_confirmed' => 0, 'occurred_at' => $at ?? $now - 1000, 'created_at' => $now - 1000]);
};
$diagnosis = static function (int $id, int $assistant = 11): void {
Db::name('tcm_diagnosis')->insert(['id' => $id, 'patient_id' => $id + 1000, 'assistant_id' => $assistant,
'patient_name' => '合成测试 ' . $id, 'phone' => '']);
};
$jobCount = static fn (): int => (int) Db::name('assistant_service_job')->count();
$clear = static function () use ($temporary): void {
foreach (['assistant_service_job', 'assistant_service_record', 'tracking_note', 'tcm_im_chat_message'] as $table) { Db::name($table)->where('id', '>', 0)->delete(); }
$scan = $temporary . DIRECTORY_SEPARATOR . 'scan.json';
if (is_file($scan)) { unlink($scan); }
};
try {
$ok(Automation::status($temporary)['state'] === 'disabled' && !is_dir($temporary), 'disabled status is read-only and needs no runtime directory');
$ok($automation->tick()['state'] === 'disabled' && !is_dir($temporary), 'default disabled tick never discovers candidates');
$config->set(['auto_enabled' => true, 'auto_actor_id' => 0, 'auto_interval_seconds' => 60,
'auto_quiet_seconds' => 300, 'auto_batch_size' => 10], 'assistant_service');
$ok($tick()['state'] === 'error' && $jobCount() === 0, 'unbound actor does not choose existing root');
$config->set(['auto_actor_id' => 11], 'assistant_service');
$ok($tick()['state'] === 'error', 'analyze-enabled frontline account is not a scheduler manager');
$config->set(['auto_actor_id' => 13], 'assistant_service');
Db::name('admin')->where('id', 13)->update(['disable' => 1]);
$ok($tick()['state'] === 'error', 'disabled configured manager rejected');
Db::name('admin')->where('id', 13)->update(['disable' => 0]);
Db::name('system_role_menu')->where('role_id', 3)->where('menu_id', 2)->delete();
$ok($tick()['state'] === 'error', 'configured manager must retain analyze permission');
Db::name('system_role_menu')->insert(['role_id' => 3, 'menu_id' => 2]);
$config->set(['enabled' => false], 'assistant_service');
$ok($tick()['state'] === 'error', 'model feature switch respected');
$config->set(['enabled' => true], 'assistant_service');
$config->set(['enable' => false], 'prescription_ai');
$ok($tick()['state'] === 'error', 'incomplete model settings do not enqueue');
$config->set(['enable' => true], 'prescription_ai');
$clear();
$ok($tick()['enqueued'] === 0 && $jobCount() === 0, 'assigned empty diagnoses never manufacture tasks');
$ok(str_contains(Automation::status($temporary, $clock)['reason'], '没有新增待分析来源'), 'empty scan explicitly waits for sources');
$ok(Automation::status($temporary, $clock)['state'] === 'waiting', 'scheduler alone is not falsely reported fully running');
Automation::workerHeartbeat('idle', $temporary, $clock);
$ok(Automation::status($temporary, $clock)['state'] === 'running', 'real completed worker polling plus scan heartbeat is running');
$ok(Automation::status($temporary, $clock + 181)['state'] === 'waiting', 'expired scanner heartbeat is waiting');
Automation::workerHeartbeat('stopped', $temporary, $clock);
$ok(Automation::status($temporary, $clock)['state'] === 'waiting', 'stopped worker not reported active');
$ok($automation->tick()['scanned'] === 0, 'persisted interval prevents frequent external ticks');
// 真正第二进程持锁:主进程不能进入扫描,锁释放后恢复。
$pipes = [];
$child = proc_open([PHP_BINARY, '-r', '$h=fopen($argv[1],"c+b");flock($h,LOCK_EX);echo "LOCKED\n";fflush(STDOUT);fgets(STDIN);fclose($h);',
$temporary . DIRECTORY_SEPARATOR . 'scan.lock'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
$ok(is_resource($child) && trim((string) fgets($pipes[1])) === 'LOCKED', 'independent process owns real flock');
$blocked = $tick();
$ok($blocked['scanned'] === 0 && str_contains($blocked['reason'], '另一'), 'second process is excluded from scanning');
fwrite($pipes[0], "release\n"); fclose($pipes[0]); fclose($pipes[1]); fclose($pipes[2]);
$ok(proc_close($child) === 0 && $tick()['state'] === 'running', 'process lock releases without stale lease');
// 来源身份只能由可靠账号对/登记作者决定,不能借当前归属推断历史员工。
$manual(101, 201);
$manual(102, 202, 12);
$manual(103, 999); // 旧患者绑定。
$manual(103, 0, 11, $now - 40 * 86400);
Db::name('tcm_im_chat_message')->insert(['id' => 20, 'patient_id' => 201, 'diagnosis_id' => 101,
'from_account' => 'doctor_11', 'to_account' => 'patient_999', 'msg_type' => 'text', 'text' => '错误映射', 'msg_time' => $now - 1000]);
$first = $tick();
$ok($first['enqueued'] === 1 && $jobCount() === 1, 'first scan covers existing 30-day manual source but excludes cross-scope, old and mismapped');
$stored = Db::name('assistant_service_job')->order('id', 'asc')->find();
$payload = json_decode($stored['payload_json'], true);
$ok((int) $stored['actor_id'] === 13 && $payload['context']['subject_admin_id'] === 11 && $payload['trigger'] === 'automatic', 'actual configured actor and explicit subject are preserved');
$ok(!str_contains(json_encode($payload['context']['sources']), '错误映射'), 'false IM mapping never enters context');
$tick(3);
$ok($jobCount() === 1, 'queued snapshot never repeats across cursor cycles');
Db::name('assistant_service_job')->where('id', $stored['id'])->update(['status' => 'failed', 'error_code' => 'INTERNAL_ERROR']);
$tick(3);
$ok($jobCount() === 1 && Db::name('assistant_service_job')->where('id', $stored['id'])->value('status') === 'failed', 'automatic scan never resets even safely retryable failure');
$manual(101, 201);
$tick(3);
$ok($jobCount() === 2, 'new content may create a new snapshot after definite terminal failure');
$latest = Db::name('assistant_service_job')->order('id', 'desc')->find();
Db::name('assistant_service_job')->where('id', $latest['id'])->update(['status' => 'needs_reconciliation']);
$manual(101, 201);
$tick(3);
$ok($jobCount() === 2, 'unknown earlier result blocks new snapshot even when new content exists');
Db::name('assistant_service_job')->where('id', $latest['id'])->update(['status' => 'ready']);
$tick(3);
$ok($jobCount() === 3, 'resolved terminal allows newly arrived content');
Db::name('assistant_service_job')->where('status', 'queued')->update(['status' => 'ready']);
$oldestRecord = Db::name('assistant_service_record')->where('diagnosis_id', 101)->order('id', 'asc')->value('id');
Db::name('assistant_service_record')->where('id', $oldestRecord)->delete();
$tick(3);
$ok($jobCount() === 3, 'shrinking source set does not regenerate merely because hash changed');
$newestRecord = Db::name('assistant_service_record')->where('diagnosis_id', 101)->order('id', 'desc')->value('id');
Db::name('assistant_service_record')->where('id', $newestRecord)->update(['content' => '合成登记内容已修订']);
$tick(3);
$ok($jobCount() === 4, 'changed actual source text receives a new snapshot');
$clear();
$diagnosis(110);
Db::name('admin')->insert(['id' => 15, 'name' => '无菜单目标医助', 'root' => 0]);
Db::name('system_role')->insert(['id' => 22, 'name' => '医助', 'data_scope' => 4]);
Db::name('admin_role')->insert(['admin_id' => 15, 'role_id' => 22]);
Db::name('admin_dept')->insert(['admin_id' => 15, 'dept_id' => 10]);
Db::name('tcm_diagnosis')->insert(['id' => 112, 'patient_id' => 1110, 'assistant_id' => 11, 'patient_name' => '相同患者新诊单', 'phone' => '']);
Db::name('tcm_im_chat_message')->insert(['id' => 30, 'patient_id' => 1110, 'diagnosis_id' => 999,
'from_account' => 'patient_1110', 'to_account' => 'doctor_15', 'msg_type' => 'text', 'text' => '历史医助可靠收信', 'msg_time' => $now - 1000]);
$ok($tick()['enqueued'] === 1, 'reliable incoming IM discovers historical target without menu or current assignment');
$payload = json_decode(Db::name('assistant_service_job')->value('payload_json'), true);
$ok($payload['context']['subject_admin_id'] === 15 && $payload['context']['sources'][0]['speaker'] === 'patient', 'incoming patient words are never attributed to employee');
$tick(3);
$ok($jobCount() === 1 && (int) Db::name('assistant_service_job')->value('diagnosis_id') === 112, 'same patient IM is queued only for latest authorized active diagnosis');
Db::name('assistant_service_job')->where('status', 'queued')->update(['status' => 'ready']);
Db::name('tcm_diagnosis')->insert(['id' => 113, 'patient_id' => 1110, 'assistant_id' => 11, 'patient_name' => '又一新诊单', 'phone' => '']);
$tick(3);
$ok($jobCount() === 1, 'new diagnosis alone cannot replay already analyzed identical patient conversation');
$clear();
$diagnosis(111);
Db::name('tracking_note')->insert(['id' => 40, 'diagnosis_id' => 111, 'admin_id' => 15,
'note_date' => date('Y-m-d', $now - 86400), 'content' => '历史目标跟踪备注', 'create_time' => $now - 1000, 'update_time' => $now - 1000]);
$ok($tick()['enqueued'] === 1, 'authored tracking alone is discovered for historical target');
$payload = json_decode(Db::name('assistant_service_job')->value('payload_json'), true);
$ok($payload['context']['subject_admin_id'] === 15 && !$payload['context']['sources'][0]['quality_eligible'], 'tracking remains non-attributable quality evidence');
$clear();
$clock = time();
$manualId = $manual(101, 201);
Db::name('assistant_service_record')->where('id', $manualId)->update(['created_at' => $clock]);
$quiet = $tick();
$ok($quiet['quiet'] === 1 && $jobCount() === 0, 'backdated manual record respects real insertion quiet window');
$tick(7);
$ok($jobCount() === 1, 'quiet candidates return after cursor wrap and eventually enqueue');
$clear();
Db::name('tracking_note')->insert(['id' => 41, 'diagnosis_id' => 101, 'admin_id' => 11,
'note_date' => date('Y-m-d', $now - 86400), 'content' => '最近追加到旧日期', 'create_time' => $now - 1000, 'update_time' => $clock]);
$ok($tick()['quiet'] === 1 && $jobCount() === 0, 'tracking update time participates in quiet window');
$tick(7);
$ok($jobCount() === 1, 'tracking candidate resumes after quiet window');
$clear();
$manual(101, 201);
Db::name('admin_dept')->where('admin_id', 11)->update(['dept_id' => 20]);
$ok($tick()['enqueued'] === 0 && $jobCount() === 0, 'scope revocation effective on next scan');
Db::name('admin_dept')->where('admin_id', 11)->update(['dept_id' => 10]);
$ok($tick()['enqueued'] === 1, 'restored authorized sources remain discoverable');
$clear();
$manual(101, 201);
Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => $now]);
$ok($tick()['enqueued'] === 0, 'deleted diagnosis excluded at discovery');
Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => null]);
$clear();
$config->set(['auto_batch_size' => 1], 'assistant_service');
for ($i = 200; $i < 240; $i++) { $diagnosis($i); $manual($i, $i + 1000); }
$first = $tick();
$ok($first['enqueued'] === 1 && $first['scanned'] <= 30, 'one-round queue and source scan are bounded');
for ($i = 0; $i < 60; $i++) { $tick(); }
$ids = array_unique(Db::name('assistant_service_job')->column('diagnosis_id'));
$ok(count($ids) === 40 && $jobCount() === 40, 'persistent keyset cursor reaches every source beyond first page without duplicates');
$replacement = new Automation($temporary, static function () use (&$clock): int { return $clock; });
$clock += 61;
$ok($replacement->tick()['enqueued'] === 0 && $jobCount() === 40, 'restart reuses durable cursor and queue dedupe');
$clear();
$config->set(['auto_batch_size' => 10, 'daily_job_limit' => 1], 'assistant_service');
$manual(200, 1200); $manual(201, 1201);
$limited = $tick();
$ok($limited['enqueued'] === 1 && $limited['state'] === 'waiting' && str_contains($limited['reason'], '上限'), 'daily allowance stops explicitly after accepted work');
$config->set(['daily_job_limit' => 100], 'assistant_service');
$ok($tick()['enqueued'] === 1 && $jobCount() === 2, 'quota-blocked source cursor is preserved for next allowed scan');
$clear();
$config->set(['max_source_rows' => 1], 'assistant_service');
$manual(200, 1200); $manual(200, 1200); $manual(201, 1201);
$over = $tick();
$ok($over['source_limit'] === 1 && $over['enqueued'] === 1 && $jobCount() === 1, 'oversized source set is skipped while later valid candidates continue');
Automation::workerHeartbeat('idle', $temporary, $clock);
$ok(str_contains(Automation::status($temporary, $clock)['reason'], '超出分析上限'), 'persistent skipped source limit is clearly surfaced in health reason');
$tick();
Automation::workerHeartbeat('idle', $temporary, $clock);
$ok(str_contains(Automation::status($temporary, $clock)['reason'], '超出分析上限'), 'empty cursor page does not hide unresolved source limit warning');
$config->set(['max_source_rows' => 200], 'assistant_service');
$clear();
Db::execute('DROP TABLE test_tcm_im_chat_message');
$manual(200, 1200);
$partial = $tick();
$ok($partial['unavailable_channels'] === 1 && $partial['enqueued'] === 1, 'missing optional IM archive still processes verified manual sources');
$ok(str_contains($partial['reason'], '来源暂不可用'), 'unavailable source is reported without fabricated zero compliance');
$encoded = json_encode(Automation::status($temporary, $clock), JSON_UNESCAPED_UNICODE);
$ok(!str_contains($encoded, 'actor_id') && !str_contains($encoded, '合成') && !str_contains($encoded, 'patient_id'), 'public status reveals no actor, patient or source text');
file_put_contents($temporary . DIRECTORY_SEPARATOR . 'scan.json', '{broken');
$ok(Automation::status($temporary, $clock)['state'] === 'error', 'damaged heartbeat is explicit error rather than fictional running');
echo 'ASSISTANT_SERVICE_AUTOMATION assertions=' . $checks . ' PASS sqlite=memory real_access_context_enqueue=1 multiprocess_flock=1 network=0' . PHP_EOL;
} finally {
// 只清理本测试生成的确切文件,不递归删除计算路径。
foreach (['scan.json', 'scan.lock', 'workers.json', 'workers.lock'] as $name) {
$path = $temporary . DIRECTORY_SEPARATOR . $name;
if (is_file($path)) { unlink($path); }
}
if (is_dir($temporary)) { rmdir($temporary); }
}