Files
ulthon_admin/tests/Unit/DebugLogToolkitTest.php

282 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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