originalRuntimePath = App::getRuntimePath(); } protected function tearDown(): void { // 还原 runtimePath(appendLine 用例会改它) App::setRuntimePath($this->originalRuntimePath); // 清理临时目录 $base = sys_get_temp_dir() . '/' . self::TEMP_BASE; if (is_dir($base)) { self::rrmdir($base); } } /** * 递归删除目录. */ private static function rrmdir(string $dir): void { $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $path = $dir . DIRECTORY_SEPARATOR . $item; is_dir($path) ? self::rrmdir($path) : unlink($path); } rmdir($dir); } /** * 用例 1:buildRow 字段齐全(8 键、键序与 FIELDS 一致)+ CLI 上下文分支. * * PHPUnit 进程 PHP_SAPI='cli',App::runningInConsole()=true: * app_name 必须='cli',且不得触碰 app('http')/request()(DebugMysql L89 反面示范)。 */ public function test_buildRow_cli_context_fields_complete(): void { $before = time(); $row = DebugLogToolkit::buildRow('info', 'hello 日志'); $after = time(); // 8 键齐全且键序与契约常量一致 self::assertSame(DebugLogToolkit::FIELDS, array_keys($row)); self::assertCount(8, $row); // 基础字段 self::assertSame('info', $row['level']); self::assertSame('hello 日志', $row['content']); self::assertGreaterThanOrEqual($before, $row['create_time']); self::assertLessThanOrEqual($after, $row['create_time']); self::assertSame(date('Y-m-d H:i:s', $row['create_time']), $row['create_time_title']); // CLI 分支:app_name='cli',controller/action 空串 self::assertSame('cli', $row['app_name']); self::assertSame('', $row['controller_name']); self::assertSame('', $row['action_name']); // FIELDS 契约自检:首列 level、8 字段(T5 Reader 侧依赖) self::assertSame('level', DebugLogToolkit::FIELDS[0]); self::assertCount(8, DebugLogToolkit::FIELDS); } /** * 用例 2:resolveUid 兜底路径——常量未定义时 uid 为非空字符串(uniqid). * * PHPUnit 进程未定义 REUQEST_UID/REQUEST_UID(bootstrap 不 define, * 常量定义后不可撤销、会跨测试文件污染),只测兜底分支。 */ public function test_buildRow_uid_fallback_uniqid(): void { $row = DebugLogToolkit::buildRow('info', 'x'); self::assertIsString($row['uid']); self::assertNotSame('', $row['uid']); } /** * 用例 3:encodeRow 对含换行/引号/中文/制表符的 content 输出单物理行. */ public function test_encodeRow_single_physical_line(): void { $content = "第一行\nsecond \"line\" with 'quotes'\ttab结尾"; $json = DebugLogToolkit::encodeRow(DebugLogToolkit::buildRow('debug', $content)); // JSONL 铁律:一个逻辑行 = 一个物理行(换行符被转义为 \n 字面量) self::assertSame(0, substr_count($json, "\n")); self::assertSame(0, substr_count($json, "\r")); // roundtrip:内容无损还原 $decoded = json_decode($json, true); self::assertIsArray($decoded); self::assertSame($content, $decoded['content']); self::assertSame('debug', $decoded['level']); } /** * 用例 4:非法 UTF-8("\xB1\x31")不抛异常且返回非空字符串. * * 清洗(mb_convert_encoding UTF-8→UTF-8,非法字节替换 '?')后必须可被 * json_decode 解析——降级占位行 '[encoding-error]' 也满足"非空可解析"。 */ public function test_encodeRow_invalid_utf8_never_throws_or_empty(): void { $bad = "pre\xB1\x31post"; // \xB1 为非法 UTF-8 起始字节 $json = DebugLogToolkit::encodeRow(DebugLogToolkit::buildRow('error', $bad)); self::assertIsString($json); self::assertNotSame('', $json); $decoded = json_decode($json, true); self::assertIsArray($decoded, '非法 UTF-8 行也必须可被 json_decode 解析'); } /** * 用例 5:decodeLine——encodeRow 输出可解回;坏 JSON/非数组/缺键返回 null. */ public function test_decodeLine_roundtrip_and_rejects(): void { $line = DebugLogToolkit::encodeRow(DebugLogToolkit::buildRow('info', 'roundtrip 内容')); $decoded = DebugLogToolkit::decodeLine($line); self::assertIsArray($decoded); self::assertSame(DebugLogToolkit::FIELDS, array_keys($decoded)); self::assertSame('roundtrip 内容', $decoded['content']); // 坏 JSON / 非对象 / 缺键 / 空行 self::assertNull(DebugLogToolkit::decodeLine('not json at all')); self::assertNull(DebugLogToolkit::decodeLine('{truncated')); self::assertNull(DebugLogToolkit::decodeLine('')); self::assertNull(DebugLogToolkit::decodeLine('[1,2,3]')); // 非关联数组 self::assertNull(DebugLogToolkit::decodeLine('"string"')); // 标量 self::assertNull(DebugLogToolkit::decodeLine('{"level":"info"}')); // FIELDS 键不全 self::assertNull(DebugLogToolkit::decodeLine((string) json_encode([ 'level' => 'info', 'content' => 'x', 'create_time' => 1, 'create_time_title' => 't', 'uid' => 'u', 'app_name' => 'cli', 'controller_name' => '', // 缺 action_name ]))); } /** * 用例 6:appendLine 在系统临时目录写入后,文件内容逐物理行完整. * * runtimePath 改指 sys_get_temp_dir()/debug-log-toolkit-test/, * 目标文件必须落在 {runtimePath}log/{Ymd}.jsonl;写前清旧目录,tearDown 统一清理。 */ public function test_appendLine_writes_complete_lines(): void { $base = sys_get_temp_dir() . '/' . self::TEMP_BASE; // 写前清理旧目录 if (is_dir($base)) { self::rrmdir($base); } // 把容器 runtimePath 改到临时目录(tearDown 还原) App::setRuntimePath($base . '/'); $lines = []; for ($i = 1; $i <= 3; ++$i) { $line = DebugLogToolkit::encodeRow(DebugLogToolkit::buildRow('info', "行 {$i} 内容\n带换行")); $lines[] = $line; self::assertTrue(DebugLogToolkit::appendLine($line), "第 {$i} 行 appendLine 应成功"); } $file = $base . '/log/' . date('Ymd') . '.jsonl'; self::assertFileExists($file); $content = (string) file_get_contents($file); $physical = explode("\n", trim($content)); self::assertCount(3, $physical, '3 行写入应有 3 个物理行'); foreach ($lines as $i => $expected) { self::assertSame($expected, $physical[$i], "物理行 {$i} 与写入内容不一致"); // 每行可被 decodeLine 解回(JSONL 完整性) self::assertIsArray(DebugLogToolkit::decodeLine($physical[$i])); } // 追加写语义:再写一行变 4 行 self::assertTrue(DebugLogToolkit::appendLine($lines[0])); self::assertCount(4, explode("\n", trim((string) file_get_contents($file)))); } /** * 用例 7:isCsvHeader——真表头 true,首列/列数不符 false. */ public function test_isCsv_header_detection(): void { $header = [ 'level', 'content', 'create_time', 'create_time_title', 'uid', 'app_name', 'controller_name', 'action_name', ]; self::assertTrue(DebugLogToolkit::isCsvHeader($header)); self::assertTrue(DebugLogToolkit::isCsvHeader(DebugLogToolkit::FIELDS)); self::assertFalse(DebugLogToolkit::isCsvHeader(['level', 'content'])); // 列数不足 self::assertFalse(DebugLogToolkit::isCsvHeader(array_merge($header, ['extra']))); // 列数超 8 self::assertFalse(DebugLogToolkit::isCsvHeader(array_merge(['not_level'], array_slice($header, 1)))); // 首列非 level self::assertFalse(DebugLogToolkit::isCsvHeader([])); // 空行 } /** * 用例 8:防回归——glob 模式语义不吞 .log 文件. * * Reader(T5)用 glob 找 {Ymd}.jsonl 与 *.csv,必须验证: * - glob 精确名 date('Ymd').'.jsonl' 不匹配 .log 文件 * - glob '*.csv' 不匹配 .log 文件 * - 正向对照:真 .jsonl/.csv 能被各自模式匹配(排除"glob 失效导致不匹配"的假绿) */ public function test_glob_semantics_not_matching_log_suffix(): void { $dir = sys_get_temp_dir() . '/' . self::TEMP_BASE . '/glob'; if (is_dir($dir)) { self::rrmdir($dir); } mkdir($dir, 0777, true); // 造干扰文件 + 正向对照文件 $logFile = $dir . '/' . date('Ymd') . '.log'; file_put_contents($logFile, 'legacy log'); $jsonlFile = $dir . '/' . date('Ymd') . '.jsonl'; file_put_contents($jsonlFile, '{}'); $csvFile = $dir . '/sample.csv'; file_put_contents($csvFile, 'level,content'); // glob 精确名(含 .jsonl 后缀)不匹配 .log $exact = glob($dir . '/' . date('Ymd') . '.jsonl'); self::assertIsArray($exact); self::assertSame([$jsonlFile], $exact, '精确名 glob 只命中 .jsonl,不吞同日期 .log'); self::assertNotContains($logFile, $exact); // glob *.csv 不匹配 .log $csvs = glob($dir . '/*.csv'); self::assertIsArray($csvs); self::assertContains($csvFile, $csvs); self::assertNotContains($logFile, $csvs, '*.csv 不得匹配 .log 文件'); // 正向对照:若 jsonl 文件不存在,精确 glob 返回空数组(而非误吞 .log) unlink($jsonlFile); $empty = glob($dir . '/' . date('Ymd') . '.jsonl'); self::assertSame([], $empty); } }