feat(log): 新增 DebugLogToolkit 行构建与 JSONL 追加写工具

This commit is contained in:
augushong
2026-08-16 07:27:41 +08:00
parent 8cbe62a402
commit 31ca29af1b
2 changed files with 531 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace tests\Unit;
use PHPUnit\Framework\TestCase;
use think\facade\App;
use think\log\DebugLogToolkit;
/**
* DebugLogToolkit 单元测试(行构建 + JSONL 追加写,不触 DB.
*
* 依赖 tests/bootstrap.php 引导的容器facade App 需容器内有 'app' 绑定),
* 但不连数据库——appendLine 用例通过 setRuntimePath 把写入目标改到系统临时目录,
* 写前清目录、测后清理并还原 runtimePath不污染项目 runtime 与其它测试。
*
* REUQEST_UID/REQUEST_UID 常量在 PHPUnit 进程内不可安全 define不可撤销、
* 会污染同进程后续测试文件),故 uid 用例只测 uniqid 兜底路径。
*/
class DebugLogToolkitTest extends TestCase
{
/** appendLine/glob 用例的临时目录基路径(系统临时目录下,不碰项目 runtime */
private const TEMP_BASE = 'debug-log-toolkit-test';
/** @var string 容器原始 runtimePathtearDown 还原,避免污染同进程其它测试 */
private string $originalRuntimePath = '';
protected function setUp(): void
{
$this->originalRuntimePath = App::getRuntimePath();
}
protected function tearDown(): void
{
// 还原 runtimePathappendLine 用例会改它)
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);
}
/**
* 用例 1buildRow 字段齐全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);
}
/**
* 用例 2resolveUid 兜底路径——常量未定义时 uid 为非空字符串uniqid.
*
* PHPUnit 进程未定义 REUQEST_UID/REQUEST_UIDbootstrap 不 define
* 常量定义后不可撤销、会跨测试文件污染),只测兜底分支。
*/
public function test_buildRow_uid_fallback_uniqid(): void
{
$row = DebugLogToolkit::buildRow('info', 'x');
self::assertIsString($row['uid']);
self::assertNotSame('', $row['uid']);
}
/**
* 用例 3encodeRow 对含换行/引号/中文/制表符的 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 解析');
}
/**
* 用例 5decodeLine——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
])));
}
/**
* 用例 6appendLine 在系统临时目录写入后,文件内容逐物理行完整.
*
* 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))));
}
/**
* 用例 7isCsvHeader——真表头 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 文件.
*
* ReaderT5用 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);
}
}