From 7a8a1e9a879f7714fefc0268c9c7a153747e921f Mon Sep 17 00:00:00 2001 From: Scottxjw <13374147+scottxjw@user.noreply.gitee.com> Date: Tue, 25 Aug 2026 11:30:16 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=9B=B4=E6=96=B0=E5=88=86=E6=9E=90?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=92=8C=E8=BF=81=E7=A7=BB=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CliStructureTests.cs | 4 +- .../CliTestHost.cs | 6 +- .../CommandTestBase.cs | 29 +- .../Commands/AggregateCommandTests.cs | 6 +- .../Commands/AnalyzeCommandTests.cs | 451 ++++++++++++++---- .../DatabaseMigrationInitializerTests.cs | 120 +++++ .../Commands/PlossParserTests.cs | 76 +++ .../GlobalUsings.cs | 1 + .../TestFixtures.cs | 31 +- 9 files changed, 602 insertions(+), 122 deletions(-) create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/DatabaseMigrationInitializerTests.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/PlossParserTests.cs diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/CliStructureTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliStructureTests.cs index f34b42b..a4cb1ce 100644 --- a/tests/Gpulse.WCT.DataAnalyzer.Tests/CliStructureTests.cs +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliStructureTests.cs @@ -1,6 +1,7 @@ using Gpulse.WCT.DataAnalyzer.Commands; using Gpulse.WCT.DataAnalyzer.Core.Application; using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; +using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting; using Microsoft.Extensions.DependencyInjection; using System.CommandLine; @@ -19,7 +20,8 @@ public class CliStructureTests : CommandTestBase root.AddCommand(new CleanCommand(Host.Services.GetRequiredService())); root.AddCommand(new AnalyzeCommand( Host.Services.GetRequiredService(), - Host.Services.GetRequiredService())); + Host.Services.GetRequiredService(), + Host.Services.GetRequiredService())); return root; } diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestHost.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestHost.cs index c97ce4d..9f6a182 100644 --- a/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestHost.cs +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestHost.cs @@ -78,15 +78,15 @@ public sealed class CliTestHost : IDisposable var provider = services.BuildServiceProvider(); - // 初始化表结构与种子数据(与 Program.Main 一致) + // 初始化表结构与种子数据(与 Program.Main 一致,经 EF 迁移建表) using (var scope = provider.CreateScope()) { var local = scope.ServiceProvider.GetRequiredService(); - await local.Database.EnsureCreatedAsync(); + await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(local); SeedData.Initialize(local); var release = scope.ServiceProvider.GetRequiredService(); - await release.Database.EnsureCreatedAsync(); + await DatabaseMigrationInitializer.MigrateOrUpgradeAsync(release); } var hostScope = provider.CreateScope(); diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/CommandTestBase.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/CommandTestBase.cs index f1de6ee..dda0dbe 100644 --- a/tests/Gpulse.WCT.DataAnalyzer.Tests/CommandTestBase.cs +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/CommandTestBase.cs @@ -1,6 +1,7 @@ using Gpulse.WCT.DataAnalyzer.Commands; using Gpulse.WCT.DataAnalyzer.Core.Application; using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; +using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting; using Microsoft.Extensions.DependencyInjection; namespace Gpulse.WCT.DataAnalyzer.Tests; @@ -13,10 +14,31 @@ public abstract class CommandTestBase protected CliTestHost Host { get; private set; } = null!; [SetUp] - public async Task SetUp() => Host = await CliTestHost.CreateAsync(); + public async Task SetUp() + { + // 清理 Datas 和 Output 文件夹,避免测试间干扰 + CleanupDatasAndOutput(); + Host = await CliTestHost.CreateAsync(); + } [TearDown] - public void TearDown() => Host.Dispose(); + public void TearDown() + { + Host.Dispose(); + // 测试结束后再次清理 + CleanupDatasAndOutput(); + } + + /// + /// 清理 Datas 和 Output 文件夹 + /// + 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 解析服务再构造命令 protected ParseCommand Parse() => new(Host.Services.GetRequiredService()); @@ -29,7 +51,8 @@ public abstract class CommandTestBase protected AnalyzeCommand Analyze() => new( Host.Services.GetRequiredService(), - Host.Services.GetRequiredService()); + Host.Services.GetRequiredService(), + Host.Services.GetRequiredService()); /// 在测试临时目录下新建一个子目录。 protected string NewDir() => diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AggregateCommandTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AggregateCommandTests.cs index e955635..58e7221 100644 --- a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AggregateCommandTests.cs +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AggregateCommandTests.cs @@ -38,9 +38,9 @@ public class AggregateCommandTests : CommandTestBase var record = await Host.ReleaseDb.ChargingParameters.SingleAsync(); Assert.Multiple(() => { - // 维度来自文件名:singleMold(TxPanel) / v1.0(TxHardware) / hex2_1(TxSoftware) / iPhone15(RxType) - Assert.That(record.CarFactory, Is.EqualTo("singleMold")); - Assert.That(record.CarModel, Is.EqualTo("v1.0")); + // 维度来自文件名:奇瑞(车厂) / E5(车型) / iPhone15(RxType) + Assert.That(record.CarFactory, Is.EqualTo("奇瑞")); + Assert.That(record.CarModel, Is.EqualTo("E5")); Assert.That(record.PhoneModel, Is.EqualTo("iPhone15")); // Field9=350 的两行 Field7 取平均 = (100+110)/2 Assert.That(record.Power350mW, Is.EqualTo(105)); diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AnalyzeCommandTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AnalyzeCommandTests.cs index 1960c7d..91463cd 100644 --- a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AnalyzeCommandTests.cs +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AnalyzeCommandTests.cs @@ -1,3 +1,5 @@ +using ClosedXML.Excel; + namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; [TestFixture] @@ -9,24 +11,23 @@ public class AnalyzeCommandTests : CommandTestBase /// 直接指向仓库根的 Resources 目录(gitignored,但本地存在);CI 无此文件则跳过。 /// [Test] - public async Task Analyze_Ploss_RealVivoFile_ReportsSummary() + public async Task Analyze_Ploss_RealVivoFile_GeneratesExcel() { var file = ResolveResourcesFile( "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.That(code, Is.EqualTo(0)); - Assert.That(stdout, Does.Contain("=== Ploss Analysis:")); - Assert.That(stdout, Does.Contain("Total: 35 (TwoLine: 35, Legacy: 0), FOD: 1, Errors: 0")); - Assert.That(stdout, Does.Contain("Normal margin>2000: 34/34 (100.0%)")); - 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")); + Assert.That(stdout, Does.Contain("[OK]")); + Assert.That(stdout, Does.Contain("Total: 35")); + Assert.That(File.Exists(outputPath), Is.True); }); } @@ -40,28 +41,22 @@ public class AnalyzeCommandTests : CommandTestBase /// 阈值 = (pureMax + foreignMin) / 2 = (5 + -49) / 2 = -22 /// [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 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( - "--type", "qfod", "--pure", pure, "--foreign", foreign); + "--type", "qfod", "--pure", pure, "--foreign", foreign, "--output", outputPath); Assert.Multiple(() => { Assert.That(code, Is.EqualTo(0)); - Assert.That(stdout, Does.Contain("=== Qfod Calibration ===")); - // Pure: 21 条,min=3 max=5 avg=4.0 - 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")); + Assert.That(stdout, Does.Contain("[OK]")); + Assert.That(File.Exists(outputPath), Is.True); }); } @@ -78,9 +73,14 @@ public class AnalyzeCommandTests : CommandTestBase } [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(() => { @@ -90,87 +90,55 @@ public class AnalyzeCommandTests : CommandTestBase } [Test] - public async Task Analyze_Ploss_MissingFile_PrintsPrompt() + public async Task Analyze_Ploss_MissingFile_TriggersAutoScan() { + // 不提供文件路径时触发自动扫描 + CleanupDatasAndOutput(); + 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] - public async Task Analyze_Ploss_ValidTwoLineFile_ReportsSummary() + public async Task Analyze_Ploss_ValidTwoLineFile_GeneratesExcel() { var dir = NewDir(); var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, - // 正常两行:margin = 3000-500 = 2500 > 2000,恒等式成立 - new TestFixtures.PlossTwoLineRow(), - // FOD 两行:ploss=2500 threshold=3000 margin=500,恒等式成立,delta_p'=2500+2001+2500-3000=4001 - new TestFixtures.PlossTwoLineRow(Field5: 8000, Field9: 2500, Field10: 3000, Field12: 1)); + // 正常两行:Field10=350(功率段),理论Δp = 6000-800-2200-500=2500 + new TestFixtures.PlossTwoLineRow(Field10: 350), + // FOD 两行:Field10=500(功率段) + 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.That(code, Is.EqualTo(0)); - Assert.That(stdout, Does.Contain("=== Ploss Analysis:")); - Assert.That(stdout, Does.Contain("Total: 2 (TwoLine: 2, Legacy: 0), 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%)")); - 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")); + Assert.That(stdout, Does.Contain("[OK]")); + Assert.That(File.Exists(outputPath), Is.True); }); } [Test] - public async Task Analyze_Ploss_LegacyFile_CountsLegacyRows() + public async Task Analyze_Qfod_MissingArgs_TriggersAutoScan() { - var dir = NewDir(); - var file = TestFixtures.WriteLegacyPlossLog( - 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) }); + // 不提供文件路径时触发自动扫描 + CleanupDatasAndOutput(); - 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"); - Assert.That(stdout, Does.Contain("Please specify --pure and --foreign for Qfod calibration")); + // 没有 Datas 文件夹时报错 + Assert.That(stdout, Does.Contain("未找到 Datas 文件夹")); } [Test] - public async Task Analyze_Ploss_LegacyFile_HexField1_DecodedCorrectly() - { - 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() + public async Task Analyze_Qfod_ValidFiles_GeneratesExcel() { var dir = NewDir(); var pure = TestFixtures.WriteQfodLog( @@ -178,19 +146,17 @@ public class AnalyzeCommandTests : CommandTestBase var foreign = TestFixtures.WriteQfodLog( 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.That(code, Is.EqualTo(0)); - Assert.That(stdout, Does.Contain("=== Qfod Calibration ===")); - Assert.That(stdout, Does.Contain("Pure phone")); - 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")); + Assert.That(stdout, Does.Contain("[OK]")); + Assert.That(File.Exists(outputPath), Is.True); }); } @@ -237,4 +203,309 @@ public class AnalyzeCommandTests : CommandTestBase Assert.Ignore($"真实数据文件不存在(已检索 {candidates.Count} 个候选路径):{fileName}"); return null!; // 不会执行 } + + #region --auto 自动扫描测试 + + /// + /// 测试 --auto 自动扫描 Datas 文件夹并执行 Ploss 分析 + /// + [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); + } + } + + /// + /// 测试 --auto 自动分类纯手机和异物日志 + /// + [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); + } + } + + /// + /// 测试 --auto 时 Datas 文件夹不存在 + /// + [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 输出目录结构测试 + + /// + /// 测试输出目录结构:Output/{车厂}/{车型}/{手机名}/{文件名}.xlsx + /// + [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 绝对值统计测试 + + /// + /// 测试 Qfod 统计使用绝对值,阈值用有符号 ΔQ(见使用指南 Threshold=-22 样例)。 + /// 本用例 pure ΔQ=-20,-25,-21,foreign ΔQ=30,35,40 → pureMax=-20, foreignMin=30, + /// 阈值 = (-20 + 30) / 2 = 5(有符号)。断言行7标定Q值单元格 == 5。 + /// + [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 标定值计算测试 + + /// + /// 测试 Ploss 标定值计算:标定Δp = 理论Δp最大值 + a + /// 功率段 350 的 a 值为 3000。理论Δp = 6000-(800+2200+500) = 2500 → 标定 = 5500。 + /// 读回 Excel 校验行7标定Δp单元格(350 段 col=2)== 5500。 + /// + [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)); + }); + } + + /// + /// RV-004:Field10 非标准功率段的记录应计入 DroppedCount 并在 stdout 暴露, + /// 而非静默丢弃。 + /// + [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")); + }); + } + + /// + /// RV-002:pure/foreign 日志存在但未提取到 Qfod 行 → 空记录守卫应返回失败而非从 0 起算成功。 + /// + [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")); + }); + } + + /// + /// RV-006:--output 指向不存在的嵌套目录时应自动创建而非崩溃。 + /// + [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); + }); + } + + /// + /// RV-005:--auto 多文件其中一文件损坏(无法解析)不应中止整批;其余文件仍产出 [OK], + /// 且 stdout 含 [FAILED] 与最终 [完成] 汇总。 + /// + [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 } diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/DatabaseMigrationInitializerTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/DatabaseMigrationInitializerTests.cs new file mode 100644 index 0000000..ca19207 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/DatabaseMigrationInitializerTests.cs @@ -0,0 +1,120 @@ +using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData; +using Microsoft.EntityFrameworkCore; + +namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; + +/// +/// RV-001:EF 迁移替代 EnsureCreated。验证两类场景: +/// 1. 全新库经迁移建全部表(CliTestHost 已覆盖,此处断言关键新表存在)。 +/// 2. 既有 EnsureCreated 库(无迁移历史但有表)经 MigrateOrUpgradeAsync 升级后补齐 +/// 本变更新增的 3 张维度表 + test_scenario 的 3 列 FK。 +/// +[TestFixture] +public class DatabaseMigrationInitializerTests +{ + /// 全新库经迁移建表后,3 张新维度表存在。 + [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); + } + } + + /// 既有 EnsureCreated 库(旧 schema:无新表/FK 列)经升级补齐。 + [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() + .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 TableExistsAsync(WctMinerDbContext context, string tableName) + { + var count = await context.Database.SqlQueryRaw( + $"SELECT count(*) AS Value FROM sqlite_master WHERE type='table' AND name = '{tableName}'").FirstOrDefaultAsync(); + return count > 0; + } + + private static async Task ColumnExistsAsync(WctMinerDbContext context, string table, string column) + { + var count = await context.Database.SqlQueryRaw( + $"SELECT count(*) AS Value FROM pragma_table_info('{table}') WHERE name = '{column}'").FirstOrDefaultAsync(); + return count > 0; + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/PlossParserTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/PlossParserTests.cs new file mode 100644 index 0000000..45baac4 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/PlossParserTests.cs @@ -0,0 +1,76 @@ +namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; + +/// +/// PlossParser 直接单测:覆盖 commit eba1d20 引入的 Field1 十六进制解析 +/// (0x 前缀与无前缀两种写法),该路径此前在 legacy 删除后零覆盖。 +/// +[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(); + } + + [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)); + }); + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/GlobalUsings.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/GlobalUsings.cs index 3244567..ef05994 100644 --- a/tests/Gpulse.WCT.DataAnalyzer.Tests/GlobalUsings.cs +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/GlobalUsings.cs @@ -1 +1,2 @@ global using NUnit.Framework; +global using Microsoft.Extensions.DependencyInjection; diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/TestFixtures.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/TestFixtures.cs index de486fb..562b390 100644 --- a/tests/Gpulse.WCT.DataAnalyzer.Tests/TestFixtures.cs +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/TestFixtures.cs @@ -2,16 +2,21 @@ namespace Gpulse.WCT.DataAnalyzer.Tests; /// /// 构造 CLI 测试用的日志 fixture 文件。文件名符合 FileNameParser 约定的 -/// `TX面板-TX硬件-TX软件-RX类型-测试目的-日期-序号` 格式。 +/// `车厂-车型-TX面板类型-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数` 格式。 /// public static class TestFixtures { - public const string PlossFileName = "singleMold-v1.0-hex2_1-iPhone15-ploss_test-20260710-1.log"; - public const string QfodFileName = "singleMold-v1.0-hex2_1-iPhone15-qfod_test-20260710-1.log"; + /// + /// 新格式文件名示例: + /// 车厂-车型-TX面板类型-TX硬件-TX软件-RX类型-异物类型-测试目的-年月日测试次数 + /// + 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"; /// /// 两行 Ploss 记录(header + FOD 16 字段)。 /// 默认值让恒等式成立(Field5 - Field4 - PowLoss - ploss == DeltaP)且余量 > 2000。 + /// Field10 为功率段,默认 350。 /// /// /// 当不为 null 时,覆写 Field1,按十六进制字面量写入日志(如 "0C" = 十进制 12), @@ -30,7 +35,7 @@ public static class TestFixtures int Field7 = 0, int Field8 = 0, 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 Field12 = 0, // FOD 标志 int Field13 = 0, @@ -56,24 +61,6 @@ public static class TestFixtures return path; } - /// 写旧单行格式 Ploss 日志(14 字段),仅指定 ploss/threshold/FOD 三个关键字段。 - /// 每行 FOD 记录关键字段(ploss/threshold/FOD) - /// 若非 null 则覆盖首字段为十六进制字面量(否则固定为 "1")。 - 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; - } - /// 写 Qfod 日志文件,每行 DeltaQ 一个。 public static string WriteQfodLog(string dir, string fileName, params int[] deltaQs) {