test: 更新分析功能和迁移测试用例

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scottxjw
2026-08-25 11:30:16 +08:00
parent ee8ea29265
commit 7a8a1e9a87
9 changed files with 602 additions and 122 deletions

View File

@@ -1,6 +1,7 @@
using Gpulse.WCT.DataAnalyzer.Commands; using Gpulse.WCT.DataAnalyzer.Commands;
using Gpulse.WCT.DataAnalyzer.Core.Application; using Gpulse.WCT.DataAnalyzer.Core.Application;
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.CommandLine; using System.CommandLine;
@@ -19,7 +20,8 @@ public class CliStructureTests : CommandTestBase
root.AddCommand(new CleanCommand(Host.Services.GetRequiredService<CleanService>())); root.AddCommand(new CleanCommand(Host.Services.GetRequiredService<CleanService>()));
root.AddCommand(new AnalyzeCommand( root.AddCommand(new AnalyzeCommand(
Host.Services.GetRequiredService<PlossAnalysisService>(), Host.Services.GetRequiredService<PlossAnalysisService>(),
Host.Services.GetRequiredService<QfodCalibrationService>())); Host.Services.GetRequiredService<QfodCalibrationService>(),
Host.Services.GetRequiredService<AnalyzeExcelExporter>()));
return root; return root;
} }

View File

@@ -78,15 +78,15 @@ public sealed class CliTestHost : IDisposable
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();
// 初始化表结构与种子数据(与 Program.Main 一致) // 初始化表结构与种子数据(与 Program.Main 一致,经 EF 迁移建表
using (var scope = provider.CreateScope()) using (var scope = provider.CreateScope())
{ {
var local = scope.ServiceProvider.GetRequiredService<WctMinerDbContext>(); var local = scope.ServiceProvider.GetRequiredService<WctMinerDbContext>();
await local.Database.EnsureCreatedAsync(); await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(local);
SeedData.Initialize(local); SeedData.Initialize(local);
var release = scope.ServiceProvider.GetRequiredService<ReleaseDbContext>(); var release = scope.ServiceProvider.GetRequiredService<ReleaseDbContext>();
await release.Database.EnsureCreatedAsync(); await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(release);
} }
var hostScope = provider.CreateScope(); var hostScope = provider.CreateScope();

View File

@@ -1,6 +1,7 @@
using Gpulse.WCT.DataAnalyzer.Commands; using Gpulse.WCT.DataAnalyzer.Commands;
using Gpulse.WCT.DataAnalyzer.Core.Application; using Gpulse.WCT.DataAnalyzer.Core.Application;
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Gpulse.WCT.DataAnalyzer.Tests; namespace Gpulse.WCT.DataAnalyzer.Tests;
@@ -13,10 +14,31 @@ public abstract class CommandTestBase
protected CliTestHost Host { get; private set; } = null!; protected CliTestHost Host { get; private set; } = null!;
[SetUp] [SetUp]
public async Task SetUp() => Host = await CliTestHost.CreateAsync(); public async Task SetUp()
{
// 清理 Datas 和 Output 文件夹,避免测试间干扰
CleanupDatasAndOutput();
Host = await CliTestHost.CreateAsync();
}
[TearDown] [TearDown]
public void TearDown() => Host.Dispose(); public void TearDown()
{
Host.Dispose();
// 测试结束后再次清理
CleanupDatasAndOutput();
}
/// <summary>
/// 清理 Datas 和 Output 文件夹
/// </summary>
protected static void CleanupDatasAndOutput()
{
var datasDir = Path.Combine(AppContext.BaseDirectory, "Datas");
var outputDir = Path.Combine(AppContext.BaseDirectory, "Output");
if (Directory.Exists(datasDir)) Directory.Delete(datasDir, true);
if (Directory.Exists(outputDir)) Directory.Delete(outputDir, true);
}
// 命令构造方式与 Program.Main 一致:从 DI 解析服务再构造命令 // 命令构造方式与 Program.Main 一致:从 DI 解析服务再构造命令
protected ParseCommand Parse() => new(Host.Services.GetRequiredService<ParseService>()); protected ParseCommand Parse() => new(Host.Services.GetRequiredService<ParseService>());
@@ -29,7 +51,8 @@ public abstract class CommandTestBase
protected AnalyzeCommand Analyze() => new( protected AnalyzeCommand Analyze() => new(
Host.Services.GetRequiredService<PlossAnalysisService>(), Host.Services.GetRequiredService<PlossAnalysisService>(),
Host.Services.GetRequiredService<QfodCalibrationService>()); Host.Services.GetRequiredService<QfodCalibrationService>(),
Host.Services.GetRequiredService<AnalyzeExcelExporter>());
/// <summary>在测试临时目录下新建一个子目录。</summary> /// <summary>在测试临时目录下新建一个子目录。</summary>
protected string NewDir() => protected string NewDir() =>

View File

@@ -38,9 +38,9 @@ public class AggregateCommandTests : CommandTestBase
var record = await Host.ReleaseDb.ChargingParameters.SingleAsync(); var record = await Host.ReleaseDb.ChargingParameters.SingleAsync();
Assert.Multiple(() => Assert.Multiple(() =>
{ {
// 维度来自文件名:singleMold(TxPanel) / v1.0(TxHardware) / hex2_1(TxSoftware) / iPhone15(RxType) // 维度来自文件名:奇瑞(车厂) / E5(车型) / iPhone15(RxType)
Assert.That(record.CarFactory, Is.EqualTo("singleMold")); Assert.That(record.CarFactory, Is.EqualTo("奇瑞"));
Assert.That(record.CarModel, Is.EqualTo("v1.0")); Assert.That(record.CarModel, Is.EqualTo("E5"));
Assert.That(record.PhoneModel, Is.EqualTo("iPhone15")); Assert.That(record.PhoneModel, Is.EqualTo("iPhone15"));
// Field9=350 的两行 Field7 取平均 = (100+110)/2 // Field9=350 的两行 Field7 取平均 = (100+110)/2
Assert.That(record.Power350mW, Is.EqualTo(105)); Assert.That(record.Power350mW, Is.EqualTo(105));

View File

@@ -1,3 +1,5 @@
using ClosedXML.Excel;
namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; namespace Gpulse.WCT.DataAnalyzer.Tests.Commands;
[TestFixture] [TestFixture]
@@ -9,24 +11,23 @@ public class AnalyzeCommandTests : CommandTestBase
/// 直接指向仓库根的 Resources 目录gitignored但本地存在CI 无此文件则跳过。 /// 直接指向仓库根的 Resources 目录gitignored但本地存在CI 无此文件则跳过。
/// </summary> /// </summary>
[Test] [Test]
public async Task Analyze_Ploss_RealVivoFile_ReportsSummary() public async Task Analyze_Ploss_RealVivoFile_GeneratesExcel()
{ {
var file = ResolveResourcesFile( var file = ResolveResourcesFile(
"C车-单充快速成型件-A03-V55-vivo iQOD12 Pro-20250519-01.DAT"); "C车-单充快速成型件-A03-V55-vivo iQOD12 Pro-20250519-01.DAT");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file); var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync(
"--type", "ploss", "--file", file, "--output", outputPath);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(code, Is.EqualTo(0)); Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("=== Ploss Analysis:")); Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(stdout, Does.Contain("Total: 35 (TwoLine: 35, Legacy: 0), FOD: 1, Errors: 0")); Assert.That(stdout, Does.Contain("Total: 35"));
Assert.That(stdout, Does.Contain("Normal margin>2000: 34/34 (100.0%)")); Assert.That(File.Exists(outputPath), Is.True);
Assert.That(stdout, Does.Contain("FOD margin>2000: 1/1 (100.0%)"));
Assert.That(stdout, Does.Contain("Identity (delta_p = Tx - Rx - pow_loss - ploss): 35/35"));
// FOD 行(第 92 行反解delta_p'=18801+2001+(-18447)-1750=605
Assert.That(stdout, Does.Contain("delta_p'=605"));
Assert.That(stdout, Does.Contain("rewrite: pow_loss = 3394, delta_p = 605"));
}); });
} }
@@ -40,28 +41,22 @@ public class AnalyzeCommandTests : CommandTestBase
/// 阈值 = (pureMax + foreignMin) / 2 = (5 + -49) / 2 = -22 /// 阈值 = (pureMax + foreignMin) / 2 = (5 + -49) / 2 = -22
/// </summary> /// </summary>
[Test] [Test]
public async Task Analyze_Qfod_RealXiaomiFiles_ReportsCalibration() public async Task Analyze_Qfod_RealXiaomiFiles_GeneratesExcel()
{ {
var pure = ResolveResourcesFile("rapidMold-v1.5-hex2_5-Xiaomi17-qfod_pure-20250817-1.log"); var pure = ResolveResourcesFile("rapidMold-v1.5-hex2_5-Xiaomi17-qfod_pure-20250817-1.log");
var foreign = ResolveResourcesFile("rapidMold-v1.5-hex2_5-Xiaomi17-qfod_foreign-20250817-1.log"); var foreign = ResolveResourcesFile("rapidMold-v1.5-hex2_5-Xiaomi17-qfod_foreign-20250817-1.log");
var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "qfod_result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync( var (code, stdout, _) = await Analyze().RunCaptureAsync(
"--type", "qfod", "--pure", pure, "--foreign", foreign); "--type", "qfod", "--pure", pure, "--foreign", foreign, "--output", outputPath);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(code, Is.EqualTo(0)); Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("=== Qfod Calibration ===")); Assert.That(stdout, Does.Contain("[OK]"));
// Pure: 21 条min=3 max=5 avg=4.0 Assert.That(File.Exists(outputPath), Is.True);
Assert.That(stdout, Does.Contain("Pure phone"));
Assert.That(stdout, Does.Contain("count=21 min=3 max=5 avg=4.0"));
// Foreign: 7 条min=-49 max=-5 avg=-27.7
Assert.That(stdout, Does.Contain("Foreign object"));
Assert.That(stdout, Does.Contain("count=7 min=-49 max=-5 avg=-27.7"));
// Threshold = (5 + -49) / 2 = -22
Assert.That(stdout, Does.Contain("Threshold = (pureMax + foreignMin) / 2 = (5 + -49) / 2 = -22"));
Assert.That(stdout, Does.Contain("pureMax - threshold = 27"));
Assert.That(stdout, Does.Contain("foreignMin - threshold = -27"));
}); });
} }
@@ -78,9 +73,14 @@ public class AnalyzeCommandTests : CommandTestBase
} }
[Test] [Test]
public async Task Analyze_UnknownType_PrintsMessage() public async Task Analyze_UnknownType_WithFile_PrintsMessage()
{ {
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "foo"); // 确保没有 Datas 文件夹干扰
CleanupDatasAndOutput();
var dir = NewDir();
var file = TestFixtures.WritePlossLog(dir, "test.dat", new TestFixtures.PlossTwoLineRow(Field10: 350));
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "foo", "--file", file);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -90,87 +90,55 @@ public class AnalyzeCommandTests : CommandTestBase
} }
[Test] [Test]
public async Task Analyze_Ploss_MissingFile_PrintsPrompt() public async Task Analyze_Ploss_MissingFile_TriggersAutoScan()
{ {
// 不提供文件路径时触发自动扫描
CleanupDatasAndOutput();
var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss"); var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss");
Assert.That(stdout, Does.Contain("Please specify --file for Ploss analysis")); // 没有 Datas 文件夹时报错
Assert.That(stdout, Does.Contain("未找到 Datas 文件夹"));
} }
[Test] [Test]
public async Task Analyze_Ploss_ValidTwoLineFile_ReportsSummary() public async Task Analyze_Ploss_ValidTwoLineFile_GeneratesExcel()
{ {
var dir = NewDir(); var dir = NewDir();
var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName,
// 正常两行:margin = 3000-500 = 2500 > 2000恒等式成立 // 正常两行:Field10=350功率段理论Δp = 6000-800-2200-500=2500
new TestFixtures.PlossTwoLineRow(), new TestFixtures.PlossTwoLineRow(Field10: 350),
// FOD 两行:ploss=2500 threshold=3000 margin=500恒等式成立delta_p'=2500+2001+2500-3000=4001 // FOD 两行:Field10=500(功率段)
new TestFixtures.PlossTwoLineRow(Field5: 8000, Field9: 2500, Field10: 3000, Field12: 1)); new TestFixtures.PlossTwoLineRow(Field5: 8000, Field9: 2500, Field10: 500, Field12: 1));
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file); var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync(
"--type", "ploss", "--file", file, "--output", outputPath);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(code, Is.EqualTo(0)); Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("=== Ploss Analysis:")); Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(stdout, Does.Contain("Total: 2 (TwoLine: 2, Legacy: 0), FOD: 1, Errors: 0")); Assert.That(File.Exists(outputPath), Is.True);
Assert.That(stdout, Does.Contain("Normal margin>2000: 1/1 (100.0%)"));
Assert.That(stdout, Does.Contain("FOD margin>2000: 0/1 (0.0%)"));
Assert.That(stdout, Does.Contain("Identity (delta_p = Tx - Rx - pow_loss - ploss): 2/2"));
Assert.That(stdout, Does.Contain("delta_p'=4001"));
Assert.That(stdout, Does.Contain("rewrite: pow_loss = 2200, delta_p = 4001"));
}); });
} }
[Test] [Test]
public async Task Analyze_Ploss_LegacyFile_CountsLegacyRows() public async Task Analyze_Qfod_MissingArgs_TriggersAutoScan()
{ {
var dir = NewDir(); // 不提供文件路径时触发自动扫描
var file = TestFixtures.WriteLegacyPlossLog( CleanupDatasAndOutput();
dir, "singleMold-v1.0-hex2_1-iPhone15-ploss_legacy-20260710-1.log",
new (int Ploss, int Threshold, int Fod)[] { (500, 3000, 0), (2500, 3000, 1) });
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("Total: 2 (TwoLine: 0, Legacy: 2), FOD: 1, Errors: 0"));
Assert.That(stdout, Does.Contain("Normal margin>2000: 1/1 (100.0%)"));
Assert.That(stdout, Does.Contain("FOD margin>2000: 0/1 (0.0%)"));
});
}
[Test]
public async Task Analyze_Qfod_MissingArgs_PrintsPrompt()
{
var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod"); var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod");
Assert.That(stdout, Does.Contain("Please specify --pure and --foreign for Qfod calibration")); // 没有 Datas 文件夹时报错
Assert.That(stdout, Does.Contain("未找到 Datas 文件夹"));
} }
[Test] [Test]
public async Task Analyze_Ploss_LegacyFile_HexField1_DecodedCorrectly() public async Task Analyze_Qfod_ValidFiles_GeneratesExcel()
{
var dir = NewDir();
// 旧单行格式 Field1=0C (十六进制) 应被解析为十进制 12
var file = TestFixtures.WriteLegacyPlossLog(
dir,
"singleMold-v1.0-hex2_1-iPhone15-ploss_legacy-20260710-1.log",
new (int, int, int)[] { (500, 3000, 0) },
field1Hex: "0C");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("Total: 1 (TwoLine: 0, Legacy: 1), FOD: 0, Errors: 0"));
});
}
[Test]
public async Task Analyze_Qfod_ValidFiles_ReportsThreshold()
{ {
var dir = NewDir(); var dir = NewDir();
var pure = TestFixtures.WriteQfodLog( var pure = TestFixtures.WriteQfodLog(
@@ -178,19 +146,17 @@ public class AnalyzeCommandTests : CommandTestBase
var foreign = TestFixtures.WriteQfodLog( var foreign = TestFixtures.WriteQfodLog(
dir, "singleMold-v1.0-hex2_1-iPhone15-qfod_foreign-20260710-1.log", 42, 40, 45); dir, "singleMold-v1.0-hex2_1-iPhone15-qfod_foreign-20260710-1.log", 42, 40, 45);
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod", "--pure", pure, "--foreign", foreign); var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "qfod_result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync(
"--type", "qfod", "--pure", pure, "--foreign", foreign, "--output", outputPath);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(code, Is.EqualTo(0)); Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("=== Qfod Calibration ===")); Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(stdout, Does.Contain("Pure phone")); Assert.That(File.Exists(outputPath), Is.True);
Assert.That(stdout, Does.Contain("Foreign object"));
Assert.That(stdout, Does.Contain("count=3 min=20 max=25 avg=22.0"));
Assert.That(stdout, Does.Contain("count=3 min=40 max=45 avg=42.3"));
Assert.That(stdout, Does.Contain("Threshold = (pureMax + foreignMin) / 2 = (25 + 40) / 2 = 32"));
Assert.That(stdout, Does.Contain("pureMax - threshold = -7"));
Assert.That(stdout, Does.Contain("foreignMin - threshold = 8"));
}); });
} }
@@ -237,4 +203,309 @@ public class AnalyzeCommandTests : CommandTestBase
Assert.Ignore($"真实数据文件不存在(已检索 {candidates.Count} 个候选路径):{fileName}"); Assert.Ignore($"真实数据文件不存在(已检索 {candidates.Count} 个候选路径):{fileName}");
return null!; // 不会执行 return null!; // 不会执行
} }
#region --auto
/// <summary>
/// 测试 --auto 自动扫描 Datas 文件夹并执行 Ploss 分析
/// </summary>
[Test]
public async Task Analyze_Auto_Ploss_ScansDatasFolder()
{
// 创建 Datas 文件夹和测试文件
var datasDir = Path.Combine(AppContext.BaseDirectory, "Datas");
Directory.CreateDirectory(datasDir);
TestFixtures.WritePlossLog(datasDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
try
{
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--auto");
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("扫描 Datas 文件夹"));
Assert.That(stdout, Does.Contain("[完成] 已分析"));
});
}
finally
{
if (Directory.Exists(datasDir)) Directory.Delete(datasDir, true);
}
}
/// <summary>
/// 测试 --auto 自动分类纯手机和异物日志
/// </summary>
[Test]
public async Task Analyze_Auto_Qfod_ClassifiesPureAndForeign()
{
var datasDir = Path.Combine(AppContext.BaseDirectory, "Datas");
Directory.CreateDirectory(datasDir);
// 纯手机日志:异物类型 = 无
TestFixtures.WriteQfodLog(datasDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-qfod_pure-2026081701.log", 20, 25, 21);
// 异物日志:异物类型 = 硬币
TestFixtures.WriteQfodLog(datasDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-硬币-qfod_foreign-2026081701.log", -5, -10, -15);
try
{
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod", "--auto");
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
// 验证能识别纯手机和异物
Assert.That(stdout, Does.Contain("纯手机:"));
Assert.That(stdout, Does.Contain("异物:"));
});
}
finally
{
if (Directory.Exists(datasDir)) Directory.Delete(datasDir, true);
}
}
/// <summary>
/// 测试 --auto 时 Datas 文件夹不存在
/// </summary>
[Test]
public async Task Analyze_Auto_NoDatasFolder_ReportsError()
{
CleanupDatasAndOutput();
var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--auto");
Assert.That(stdout, Does.Contain("未找到 Datas 文件夹"));
}
#endregion
#region
/// <summary>
/// 测试输出目录结构Output/{车厂}/{车型}/{手机名}/{文件名}.xlsx
/// </summary>
[Test]
public async Task Analyze_Ploss_CreatesOutputDirectoryStructure()
{
CleanupDatasAndOutput();
var dir = NewDir();
var file = TestFixtures.WritePlossLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
// 验证输出路径包含车厂/车型/手机名
Assert.That(stdout, Does.Contain("Output"));
Assert.That(stdout, Does.Contain("奇瑞"));
Assert.That(stdout, Does.Contain("E5"));
Assert.That(stdout, Does.Contain("iPhone15"));
});
}
#endregion
#region Qfod ΔQ
/// <summary>
/// 测试 Qfod 统计使用绝对值,阈值用有符号 ΔQ见使用指南 Threshold=-22 样例)。
/// 本用例 pure ΔQ=-20,-25,-21foreign ΔQ=30,35,40 → pureMax=-20, foreignMin=30
/// 阈值 = (-20 + 30) / 2 = 5有符号。断言行7标定Q值单元格 == 5。
/// </summary>
[Test]
public async Task Analyze_Qfod_WithNegativeValues_GeneratesExcel()
{
CleanupDatasAndOutput();
var dir = NewDir();
// 包含负值 ΔQ
var pure = TestFixtures.WriteQfodLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-qfod_pure-2026081701.log", -20, -25, -21);
var foreign = TestFixtures.WriteQfodLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-硬币-qfod_foreign-2026081701.log", 30, 35, 40);
var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod", "--pure", pure, "--foreign", foreign, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(File.Exists(outputPath), Is.True);
});
// 读回 Excel行6 标定Q值Q0 列 col=2应为有符号阈值 5
using var workbook = new XLWorkbook(outputPath);
var ws = workbook.Worksheet("Sheet1");
Assert.Multiple(() =>
{
Assert.That(ws.Cell(6, 1).GetString(), Is.EqualTo("标定Q值")); // 行6 是标定Q值行
// Q0 在行6合并了三列值在合并后的第一个单元格
Assert.That((int)ws.Cell(6, 2).GetDouble(), Is.EqualTo(5));
});
}
#endregion
#region Ploss
/// <summary>
/// 测试 Ploss 标定值计算标定Δp = 理论Δp最大值 + a
/// 功率段 350 的 a 值为 3000。理论Δp = 6000-(800+2200+500) = 2500 → 标定 = 5500。
/// 读回 Excel 校验行7标定Δp单元格350 段 col=2== 5500。
/// </summary>
[Test]
public async Task Analyze_Ploss_CalculatesCalibrationValue()
{
CleanupDatasAndOutput();
var dir = NewDir();
// 创建功率段 350 的数据
// 理论Δp = Field5 - (Field4 + PowLoss + Field9) = 6000 - (800 + 2200 + 500) = 2500
var file = TestFixtures.WritePlossLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(File.Exists(outputPath), Is.True);
Assert.That(stdout, Does.Contain("Total: 1"));
Assert.That(stdout, Does.Contain("Dropped: 0"));
});
// 读回 Excel行1 列2 含功率段 350行6 标定Δp值col=2= 2500 + 3000 = 5500
using var workbook = new XLWorkbook(outputPath);
var ws = workbook.Worksheet("Sheet1");
Assert.Multiple(() =>
{
Assert.That((int)ws.Cell(1, 2).GetDouble(), Is.EqualTo(350)); // 行1 是功率段标题行
Assert.That(ws.Cell(6, 1).GetString(), Is.EqualTo("标定Δp值")); // 行6 是标定行
Assert.That((long)ws.Cell(6, 2).GetDouble(), Is.EqualTo(5500L));
});
}
/// <summary>
/// RV-004Field10 非标准功率段的记录应计入 DroppedCount 并在 stdout 暴露,
/// 而非静默丢弃。
/// </summary>
[Test]
public async Task Analyze_Ploss_NonStandardPowerSegment_IsDropped()
{
CleanupDatasAndOutput();
var dir = NewDir();
// Field10=850 不在 9 个标准功率段内 → 被丢弃
var file = TestFixtures.WritePlossLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 850));
var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(File.Exists(outputPath), Is.True);
Assert.That(stdout, Does.Contain("Total: 1"));
Assert.That(stdout, Does.Contain("Dropped: 1"));
});
}
/// <summary>
/// RV-002pure/foreign 日志存在但未提取到 Qfod 行 → 空记录守卫应返回失败而非从 0 起算成功。
/// </summary>
[Test]
public async Task Analyze_Qfod_EmptyRecords_ReportsFailure()
{
CleanupDatasAndOutput();
var dir = NewDir();
// 写一个只有空白行的"空"日志(文件存在但无 Qfod 行)
var pure = Path.Combine(dir, "pure_empty.log");
var foreign = Path.Combine(dir, "foreign_empty.log");
await File.WriteAllTextAsync(pure, "\n\n \n");
await File.WriteAllTextAsync(foreign, "\n\n \n");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod", "--pure", pure, "--foreign", foreign);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("FAILED"));
Assert.That(stdout, Does.Contain("未提取到 Qfod"));
});
}
/// <summary>
/// RV-006--output 指向不存在的嵌套目录时应自动创建而非崩溃。
/// </summary>
[Test]
public async Task Analyze_Ploss_OutputToMissingDir_CreatesDir()
{
CleanupDatasAndOutput();
var dir = NewDir();
var file = TestFixtures.WritePlossLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
var outputRoot = NewDir();
var outputPath = Path.Combine(outputRoot, "nested", "deeper", "result.xlsx");
Assume.That(Directory.Exists(Path.GetDirectoryName(outputPath)), Is.False);
var (code, _, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(File.Exists(outputPath), Is.True);
});
}
/// <summary>
/// RV-005--auto 多文件其中一文件损坏(无法解析)不应中止整批;其余文件仍产出 [OK]
/// 且 stdout 含 [FAILED] 与最终 [完成] 汇总。
/// </summary>
[Test]
public async Task Analyze_Auto_Ploss_OneBadFile_DoesNotAbortBatch()
{
// 准备 Datas 文件夹:一个好文件 + 一个坏文件(只有 header 无 FOD 行)
var dataDir = Path.Combine(AppContext.BaseDirectory, "Datas");
try
{
Directory.CreateDirectory(dataDir);
TestFixtures.WritePlossLog(dataDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
// 坏文件:仅 header 行,无配对 FOD 行 → 解析错误计数
var badPath = Path.Combine(dataDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-badfile-2026081702.dat");
await File.WriteAllTextAsync(badPath, "pow_loss = 2200, delta_p = 2500\n");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--auto");
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(stdout, Does.Contain("[完成]"));
});
}
finally
{
if (Directory.Exists(dataDir)) Directory.Delete(dataDir, true);
}
}
#endregion
} }

View File

@@ -0,0 +1,120 @@
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
using Microsoft.EntityFrameworkCore;
namespace Gpulse.WCT.DataAnalyzer.Tests.Commands;
/// <summary>
/// RV-001EF 迁移替代 EnsureCreated。验证两类场景
/// 1. 全新库经迁移建全部表CliTestHost 已覆盖,此处断言关键新表存在)。
/// 2. 既有 EnsureCreated 库(无迁移历史但有表)经 MigrateOrUpgradeAsync 升级后补齐
/// 本变更新增的 3 张维度表 + test_scenario 的 3 列 FK。
/// </summary>
[TestFixture]
public class DatabaseMigrationInitializerTests
{
/// <summary>全新库经迁移建表后3 张新维度表存在。</summary>
[Test]
public async Task MigrateOrUpgrade_FreshDb_CreatesNewDimensionTables()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"wct_fresh_{Guid.NewGuid():N}.db");
try
{
await using var context = CreateContext(dbPath);
await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(context);
Assert.Multiple(async () =>
{
Assert.That(await TableExistsAsync(context, "manufacturer"), Is.True);
Assert.That(await TableExistsAsync(context, "car_model"), Is.True);
Assert.That(await TableExistsAsync(context, "foreign_object"), Is.True);
});
}
finally
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}
/// <summary>既有 EnsureCreated 库(旧 schema无新表/FK 列)经升级补齐。</summary>
[Test]
public async Task MigrateOrUpgrade_LegacyEnsureCreatedDb_AddsNewTablesAndFkColumns()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"wct_legacy_{Guid.NewGuid():N}.db");
try
{
// 1. 构造一个"旧 schema"库test_scenario 无 manufacturer_id/car_model_id/foreign_object_id
// 无 __EFMigrationsHistory模拟 EnsureCreated 既有库)
CreateLegacyEnsureCreatedDatabase(dbPath);
// 2. 运行升级
await using var context = CreateContext(dbPath);
await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(context);
// 3. 断言新维度表与 test_scenario 的新 FK 列已补齐
Assert.Multiple(async () =>
{
Assert.That(await TableExistsAsync(context, "manufacturer"), Is.True);
Assert.That(await TableExistsAsync(context, "car_model"), Is.True);
Assert.That(await TableExistsAsync(context, "foreign_object"), Is.True);
Assert.That(await ColumnExistsAsync(context, "test_scenario", "manufacturer_id"), Is.True);
Assert.That(await ColumnExistsAsync(context, "test_scenario", "car_model_id"), Is.True);
Assert.That(await ColumnExistsAsync(context, "test_scenario", "foreign_object_id"), Is.True);
});
}
finally
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}
private static WctMinerDbContext CreateContext(string dbPath)
{
var dir = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var options = new DbContextOptionsBuilder<WctMinerDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options;
return new WctMinerDbContext(options);
}
private static void CreateLegacyEnsureCreatedDatabase(string dbPath)
{
using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={dbPath};Pooling=False");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE test_scenario (
id TEXT NOT NULL PRIMARY KEY,
tx_panel_id TEXT,
tx_hardware_id TEXT,
tx_software_id TEXT,
rx_type_id TEXT,
test_purpose TEXT,
test_date TEXT,
test_sequence INTEGER,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE tx_panel (id TEXT NOT NULL PRIMARY KEY, name TEXT, is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')));
""";
cmd.ExecuteNonQuery();
}
private static async Task<bool> TableExistsAsync(WctMinerDbContext context, string tableName)
{
var count = await context.Database.SqlQueryRaw<long>(
$"SELECT count(*) AS Value FROM sqlite_master WHERE type='table' AND name = '{tableName}'").FirstOrDefaultAsync();
return count > 0;
}
private static async Task<bool> ColumnExistsAsync(WctMinerDbContext context, string table, string column)
{
var count = await context.Database.SqlQueryRaw<long>(
$"SELECT count(*) AS Value FROM pragma_table_info('{table}') WHERE name = '{column}'").FirstOrDefaultAsync();
return count > 0;
}
}

View File

@@ -0,0 +1,76 @@
namespace Gpulse.WCT.DataAnalyzer.Tests.Commands;
/// <summary>
/// PlossParser 直接单测:覆盖 commit eba1d20 引入的 Field1 十六进制解析
/// 0x 前缀与无前缀两种写法),该路径此前在 legacy 删除后零覆盖。
/// </summary>
[TestFixture]
public class PlossParserTests : CommandTestBase
{
private Core.Application.Parsing.PlossParser _parser = default!;
// NUnit 会先运行基类的 [SetUp](创建 Host再运行本方法
[SetUp]
public async Task InitParser()
{
await Task.CompletedTask;
_parser = Host.Services.GetRequiredService<Core.Application.Parsing.PlossParser>();
}
[Test]
public void Parse_HexField1_WithZeroPrefix_DecodesCorrectly()
{
// Field1 = "0C" → 十进制 12
var dir = NewDir();
var path = TestFixtures.WritePlossLog(dir, "hex-0C.dat",
new TestFixtures.PlossTwoLineRow(Field1AsHex: "0C"));
var lines = File.ReadAllLines(path);
var result = _parser.ParseMultiLine(lines, Guid.Empty);
Assert.Multiple(() =>
{
Assert.That(result, Is.Not.Null);
Assert.That(result!.IsSuccess, Is.True);
Assert.That(result.Record!.Field1, Is.EqualTo(12));
});
}
[Test]
public void Parse_HexField1_WithZeroXPrefix_DecodesCorrectly()
{
// Field1 = "0xFF" → 十进制 255
var dir = NewDir();
var path = TestFixtures.WritePlossLog(dir, "hex-0xFF.dat",
new TestFixtures.PlossTwoLineRow(Field1AsHex: "0xFF"));
var lines = File.ReadAllLines(path);
var result = _parser.ParseMultiLine(lines, Guid.Empty);
Assert.Multiple(() =>
{
Assert.That(result, Is.Not.Null);
Assert.That(result!.IsSuccess, Is.True);
Assert.That(result.Record!.Field1, Is.EqualTo(255));
});
}
[Test]
public void Parse_DecimalField1_DecodesCorrectly()
{
// Field1 普通十进制 → 原值
var dir = NewDir();
var path = TestFixtures.WritePlossLog(dir, "dec-42.dat",
new TestFixtures.PlossTwoLineRow(Field1: 42));
var lines = File.ReadAllLines(path);
var result = _parser.ParseMultiLine(lines, Guid.Empty);
Assert.Multiple(() =>
{
Assert.That(result, Is.Not.Null);
Assert.That(result!.IsSuccess, Is.True);
Assert.That(result.Record!.Field1, Is.EqualTo(42));
});
}
}

View File

@@ -1 +1,2 @@
global using NUnit.Framework; global using NUnit.Framework;
global using Microsoft.Extensions.DependencyInjection;

View File

@@ -2,16 +2,21 @@ namespace Gpulse.WCT.DataAnalyzer.Tests;
/// <summary> /// <summary>
/// 构造 CLI 测试用的日志 fixture 文件。文件名符合 FileNameParser 约定的 /// 构造 CLI 测试用的日志 fixture 文件。文件名符合 FileNameParser 约定的
/// `TX面板-TX硬件-TX软件-RX类型-测试目的-日期-序号` 格式。 /// `车厂-车型-TX面板类型-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数` 格式。
/// </summary> /// </summary>
public static class TestFixtures public static class TestFixtures
{ {
public const string PlossFileName = "singleMold-v1.0-hex2_1-iPhone15-ploss_test-20260710-1.log"; /// <summary>
public const string QfodFileName = "singleMold-v1.0-hex2_1-iPhone15-qfod_test-20260710-1.log"; /// 新格式文件名示例:
/// 车厂-车型-TX面板类型-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数
/// </summary>
public const string PlossFileName = "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026071001.dat";
public const string QfodFileName = "奇瑞-E5-singleMold-A00-V001-iPhone15-无-qfod_test-2026071001.dat";
/// <summary> /// <summary>
/// 两行 Ploss 记录header + FOD 16 字段)。 /// 两行 Ploss 记录header + FOD 16 字段)。
/// 默认值让恒等式成立Field5 - Field4 - PowLoss - ploss == DeltaP且余量 > 2000。 /// 默认值让恒等式成立Field5 - Field4 - PowLoss - ploss == DeltaP且余量 > 2000。
/// Field10 为功率段,默认 350。
/// </summary> /// </summary>
/// <param name="Field1AsHex"> /// <param name="Field1AsHex">
/// 当不为 null 时,覆写 Field1按十六进制字面量写入日志如 "0C" = 十进制 12 /// 当不为 null 时,覆写 Field1按十六进制字面量写入日志如 "0C" = 十进制 12
@@ -30,7 +35,7 @@ public static class TestFixtures
int Field7 = 0, int Field7 = 0,
int Field8 = 0, int Field8 = 0,
int Field9 = 500, // ploss int Field9 = 500, // ploss
int Field10 = 3000, // threshold int Field10 = 350, // threshold/power segment (350/500/750/1000/1250/1500/1750/2000/2250)
int Field11 = 0, int Field11 = 0,
int Field12 = 0, // FOD 标志 int Field12 = 0, // FOD 标志
int Field13 = 0, int Field13 = 0,
@@ -56,24 +61,6 @@ public static class TestFixtures
return path; return path;
} }
/// <summary>写旧单行格式 Ploss 日志14 字段),仅指定 ploss/threshold/FOD 三个关键字段。</summary>
/// <param name="rows">每行 FOD 记录关键字段ploss/threshold/FOD</param>
/// <param name="field1Hex">若非 null 则覆盖首字段为十六进制字面量(否则固定为 "1")。</param>
public static string WriteLegacyPlossLog(
string dir,
string fileName,
(int Ploss, int Threshold, int Fod)[] rows,
string? field1Hex = null)
{
var path = Path.Combine(dir, fileName);
var head = field1Hex ?? "1";
var lines = rows
.Select(r => $"FOD-> {head} 0 0 0 0 0 0 0 {r.Ploss} {r.Threshold} 0 {r.Fod} 0 0")
.ToArray();
File.WriteAllLines(path, lines);
return path;
}
/// <summary>写 Qfod 日志文件,每行 DeltaQ 一个。</summary> /// <summary>写 Qfod 日志文件,每行 DeltaQ 一个。</summary>
public static string WriteQfodLog(string dir, string fileName, params int[] deltaQs) public static string WriteQfodLog(string dir, string fileName, params int[] deltaQs)
{ {