From 759da546eea2176dc4cb2eea80175758ef3e063c Mon Sep 17 00:00:00 2001 From: Scottxjw <13374147+scottxjw@user.noreply.gitee.com> Date: Mon, 17 Aug 2026 09:24:57 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=20CLI=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gpulse.WCT.DataAnalyzer.sln | 43 ++++- docs/README.md | 15 ++ .../CliStructureTests.cs | 53 ++++++ .../CliTestExtensions.cs | 38 +++++ .../CliTestHost.cs | 101 ++++++++++++ .../CommandTestBase.cs | 37 +++++ .../Commands/AggregateCommandTests.cs | 85 ++++++++++ .../Commands/AnalyzeCommandTests.cs | 129 +++++++++++++++ .../Commands/CleanCommandTests.cs | 81 +++++++++ .../Commands/ExportCommandTests.cs | 105 ++++++++++++ .../Commands/ParseCommandTests.cs | 154 ++++++++++++++++++ .../GlobalUsings.cs | 1 + .../Gpulse.WCT.DataAnalyzer.Tests.csproj | 29 ++++ .../TestFixtures.cs | 80 +++++++++ 14 files changed, 950 insertions(+), 1 deletion(-) create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/CliStructureTests.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestExtensions.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestHost.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/CommandTestBase.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AggregateCommandTests.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AnalyzeCommandTests.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/CleanCommandTests.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/ExportCommandTests.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/ParseCommandTests.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/GlobalUsings.cs create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/Gpulse.WCT.DataAnalyzer.Tests.csproj create mode 100644 tests/Gpulse.WCT.DataAnalyzer.Tests/TestFixtures.cs diff --git a/Gpulse.WCT.DataAnalyzer.sln b/Gpulse.WCT.DataAnalyzer.sln index 2092bcf..33b8d5f 100644 --- a/Gpulse.WCT.DataAnalyzer.sln +++ b/Gpulse.WCT.DataAnalyzer.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -7,24 +7,65 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gpulse.WCT.DataAnalyzer.Cor EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gpulse.WCT.DataAnalyzer", "src\Gpulse.WCT.DataAnalyzer\Gpulse.WCT.DataAnalyzer.csproj", "{A1B2C3D4-1234-5678-9ABC-DEF012345603}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gpulse.WCT.DataAnalyzer.Tests", "tests\Gpulse.WCT.DataAnalyzer.Tests\Gpulse.WCT.DataAnalyzer.Tests.csproj", "{FE1E3A36-A114-4B21-BE29-53ECA9B773FF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345601}.Release|x86.Build.0 = Release|Any CPU {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-1234-5678-9ABC-DEF012345603}.Release|x86.Build.0 = Release|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Debug|x64.Build.0 = Debug|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Debug|x86.Build.0 = Debug|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Release|Any CPU.Build.0 = Release|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Release|x64.ActiveCfg = Release|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Release|x64.Build.0 = Release|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Release|x86.ActiveCfg = Release|Any CPU + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {FE1E3A36-A114-4B21-BE29-53ECA9B773FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A1B2C3D4-1234-5678-9ABC-DEF012345600} EndGlobalSection diff --git a/docs/README.md b/docs/README.md index 6af2ecd..1c679d4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,21 @@ | ORM | Entity Framework Core | 8.0 | | 数据库 | SQLite / PostgreSQL | - | | CLI | System.CommandLine | 2.0-beta4 | +| 测试 | NUnit | 4.2 | + +--- + +## 测试 + +CLI 单元测试项目位于 `tests/Gpulse.WCT.DataAnalyzer.Tests`,使用 NUnit + 内存 SQLite: + +- 直接实例化 `ParseCommand` / `AggregateCommand` / `ExportCommand` / `CleanCommand` / `AnalyzeCommand`(构造方式与 `Program.Main` 一致),验证命令绑定、参数解析与输出。 +- 每个测试独立的「本地库/发布库」内存连接 + 临时日志 fixture 目录,互不污染。 +- 不启动完整 Host/Serilog,仅覆盖 CLI 命令层行为。 + +```bash +dotnet test Gpulse.WCT.DataAnalyzer.sln +``` --- diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/CliStructureTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliStructureTests.cs new file mode 100644 index 0000000..f34b42b --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliStructureTests.cs @@ -0,0 +1,53 @@ +using Gpulse.WCT.DataAnalyzer.Commands; +using Gpulse.WCT.DataAnalyzer.Core.Application; +using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; + +namespace Gpulse.WCT.DataAnalyzer.Tests; + +[TestFixture] +public class CliStructureTests : CommandTestBase +{ + /// 按 Program.Main 的方式组装根命令。 + private RootCommand BuildRoot() + { + var root = new RootCommand("Gpulse.WCT.DataAnalyzer - FOD Log Data Parser"); + root.AddCommand(new ParseCommand(Host.Services.GetRequiredService())); + root.AddCommand(new AggregateCommand(Host.Services.GetRequiredService())); + root.AddCommand(new ExportCommand(Host.Services.GetRequiredService())); + root.AddCommand(new CleanCommand(Host.Services.GetRequiredService())); + root.AddCommand(new AnalyzeCommand( + Host.Services.GetRequiredService(), + Host.Services.GetRequiredService())); + return root; + } + + [Test] + public async Task Help_ListsAllSubcommands() + { + var (code, stdout, _) = await BuildRoot().RunCaptureAsync("--help"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("parse")); + Assert.That(stdout, Does.Contain("aggregate")); + Assert.That(stdout, Does.Contain("export")); + Assert.That(stdout, Does.Contain("clean")); + Assert.That(stdout, Does.Contain("analyze")); + }); + } + + [Test] + public async Task UnknownCommand_ReturnsError() + { + var (code, stdout, stderr) = await BuildRoot().RunCaptureAsync("nosuchcommand"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(1)); + Assert.That(stdout + stderr, Does.Contain("nosuchcommand")); + }); + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestExtensions.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestExtensions.cs new file mode 100644 index 0000000..f5f9cfd --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestExtensions.cs @@ -0,0 +1,38 @@ +using System.CommandLine; + +namespace Gpulse.WCT.DataAnalyzer.Tests; + +/// +/// 命令执行辅助:运行命令并捕获标准输出/标准错误。 +/// 命令 handler 直接使用 Console.WriteLine,因此重定向 System.Console 即可捕获; +/// System.CommandLine 自身的帮助/校验输出也会经默认控制台写入同一流。 +/// +public static class CliTestExtensions +{ + /// + /// 执行命令并返回 (退出码, 标准输出, 标准错误)。 + /// + public static async Task<(int ExitCode, string StdOut, string StdErr)> RunCaptureAsync( + this Command command, + params string[] args) + { + var originalOut = Console.Out; + var originalError = Console.Error; + + using var outWriter = new StringWriter(); + using var errWriter = new StringWriter(); + + Console.SetOut(outWriter); + Console.SetError(errWriter); + try + { + var exitCode = await command.InvokeAsync(args); + return (exitCode, outWriter.ToString(), errWriter.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestHost.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestHost.cs new file mode 100644 index 0000000..219a6df --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/CliTestHost.cs @@ -0,0 +1,101 @@ +using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions; +using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Gpulse.WCT.DataAnalyzer.Tests; + +/// +/// CLI 测试宿主:用内存 SQLite 搭建与 Program.Main 一致的服务容器。 +/// 每个测试实例持有独立的「本地库」/「发布库」连接与一个临时目录,互不污染。 +/// +public sealed class CliTestHost : IDisposable +{ + private readonly SqliteConnection _localConnection; + private readonly SqliteConnection _releaseConnection; + private readonly ServiceProvider _provider; + private readonly IServiceScope _scope; + + /// 测试作用域的服务提供者(与命令执行共用同一作用域)。 + public IServiceProvider Services => _scope.ServiceProvider; + + /// 每个测试独立的临时目录,用于放置日志 fixture 文件。 + public string TempDir { get; } + + public WctMinerDbContext LocalDb => Services.GetRequiredService(); + public ReleaseDbContext ReleaseDb => Services.GetRequiredService(); + + private CliTestHost( + SqliteConnection localConnection, + SqliteConnection releaseConnection, + ServiceProvider provider, + IServiceScope scope, + string tempDir) + { + _localConnection = localConnection; + _releaseConnection = releaseConnection; + _provider = provider; + _scope = scope; + TempDir = tempDir; + } + + /// + /// 创建测试宿主:本地库/发布库各用一条独立的内存 SQLite 连接(镜像生产环境的两个库文件), + /// 并做 EnsureCreated 与种子数据初始化。 + /// + public static async Task CreateAsync() + { + var tempDir = Directory.CreateTempSubdirectory("wct-tests-").FullName; + + // 两个库各自独立连接,避免共享连接时 EnsureCreated 相互干扰 + var localConnection = new SqliteConnection("Data Source=:memory:"); + await localConnection.OpenAsync(); + + var releaseConnection = new SqliteConnection("Data Source=:memory:"); + await releaseConnection.OpenAsync(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Database:Type"] = "sqlite", + ["Parser:BatchSize"] = "1000", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddDbContext(o => o.UseSqlite(localConnection)); + services.AddDbContext(o => o.UseSqlite(releaseConnection)); + services.AddApplicationServices(); + + var provider = services.BuildServiceProvider(); + + // 初始化表结构与种子数据(与 Program.Main 一致) + using (var scope = provider.CreateScope()) + { + var local = scope.ServiceProvider.GetRequiredService(); + await local.Database.EnsureCreatedAsync(); + SeedData.Initialize(local); + + var release = scope.ServiceProvider.GetRequiredService(); + await release.Database.EnsureCreatedAsync(); + } + + var hostScope = provider.CreateScope(); + return new CliTestHost(localConnection, releaseConnection, provider, hostScope, tempDir); + } + + public void Dispose() + { + _scope.Dispose(); + _provider.Dispose(); + _localConnection.Dispose(); + _releaseConnection.Dispose(); + + try { Directory.Delete(TempDir, recursive: true); } + catch { /* 尽力清理临时目录 */ } + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/CommandTestBase.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/CommandTestBase.cs new file mode 100644 index 0000000..f1de6ee --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/CommandTestBase.cs @@ -0,0 +1,37 @@ +using Gpulse.WCT.DataAnalyzer.Commands; +using Gpulse.WCT.DataAnalyzer.Core.Application; +using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Gpulse.WCT.DataAnalyzer.Tests; + +/// +/// CLI 命令测试基类:每个测试一个独立的内存数据库 + 临时目录。 +/// +public abstract class CommandTestBase +{ + protected CliTestHost Host { get; private set; } = null!; + + [SetUp] + public async Task SetUp() => Host = await CliTestHost.CreateAsync(); + + [TearDown] + public void TearDown() => Host.Dispose(); + + // 命令构造方式与 Program.Main 一致:从 DI 解析服务再构造命令 + protected ParseCommand Parse() => new(Host.Services.GetRequiredService()); + + protected AggregateCommand Aggregate() => new(Host.Services.GetRequiredService()); + + protected ExportCommand Export() => new(Host.Services.GetRequiredService()); + + protected CleanCommand Clean() => new(Host.Services.GetRequiredService()); + + protected AnalyzeCommand Analyze() => new( + Host.Services.GetRequiredService(), + Host.Services.GetRequiredService()); + + /// 在测试临时目录下新建一个子目录。 + protected string NewDir() => + Directory.CreateDirectory(Path.Combine(Host.TempDir, Guid.NewGuid().ToString("N"))).FullName; +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AggregateCommandTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AggregateCommandTests.cs new file mode 100644 index 0000000..e955635 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AggregateCommandTests.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore; + +namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; + +[TestFixture] +public class AggregateCommandTests : CommandTestBase +{ + [Test] + public async Task Aggregate_EmptyDatabase_ReportsZero() + { + var (code, stdout, _) = await Aggregate().RunCaptureAsync(); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Aggregated 0 local Ploss records into 0 release records.")); + }); + } + + [Test] + public async Task Aggregate_AfterParse_GroupsByThresholdIntoReleaseDb() + { + var dir = NewDir(); + var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, + new TestFixtures.PlossTwoLineRow(Field7: 100, Field9: 350, Field10: 350), + new TestFixtures.PlossTwoLineRow(Field7: 110, Field9: 350, Field10: 350), + new TestFixtures.PlossTwoLineRow(Field7: 200, Field9: 500, Field10: 500)); + await Parse().RunCaptureAsync("--file", file); + + var (code, stdout, _) = await Aggregate().RunCaptureAsync(); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Aggregated 3 local Ploss records into 1 release records.")); + }); + + 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")); + Assert.That(record.PhoneModel, Is.EqualTo("iPhone15")); + // Field9=350 的两行 Field7 取平均 = (100+110)/2 + Assert.That(record.Power350mW, Is.EqualTo(105)); + Assert.That(record.Power500mW, Is.EqualTo(200)); + }); + } + + [Test] + public async Task Aggregate_DefaultRebuild_DoesNotDuplicateOnSecondRun() + { + var dir = NewDir(); + var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, + new TestFixtures.PlossTwoLineRow(Field7: 100, Field9: 350, Field10: 350)); + await Parse().RunCaptureAsync("--file", file); + + await Aggregate().RunCaptureAsync(); + await Aggregate().RunCaptureAsync(); + + Assert.That(await Host.ReleaseDb.ChargingParameters.CountAsync(), Is.EqualTo(1)); + } + + [Test] + public async Task Aggregate_RebuildFalse_InsertsIntoEmptyTable() + { + // 注:发布库对业务键 (车厂/车型/手机厂商/型号) 有唯一索引; + // append 模式会对全部本地记录重新入库,因此只适合表为空时使用, + // 对已聚合过的同一数据再 append 会因唯一索引冲突而失败(聚合命令捕获并输出 "Aggregation failed")。 + var dir = NewDir(); + var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, + new TestFixtures.PlossTwoLineRow(Field7: 100, Field9: 350, Field10: 350)); + await Parse().RunCaptureAsync("--file", file); + + var (code, stdout, _) = await Aggregate().RunCaptureAsync("--rebuild", "false"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Aggregated 1 local Ploss records into 1 release records.")); + }); + Assert.That(await Host.ReleaseDb.ChargingParameters.CountAsync(), Is.EqualTo(1)); + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AnalyzeCommandTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AnalyzeCommandTests.cs new file mode 100644 index 0000000..522e720 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/AnalyzeCommandTests.cs @@ -0,0 +1,129 @@ +namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; + +[TestFixture] +public class AnalyzeCommandTests : CommandTestBase +{ + [Test] + public async Task Analyze_NoType_ReturnsValidationError() + { + var (code, stdout, stderr) = await Analyze().RunCaptureAsync(); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(1)); + Assert.That(stdout + stderr, Does.Contain("--type")); + }); + } + + [Test] + public async Task Analyze_UnknownType_PrintsMessage() + { + var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "foo"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Unknown type: foo. Use 'ploss' or 'qfod'.")); + }); + } + + [Test] + public async Task Analyze_Ploss_MissingFile_PrintsPrompt() + { + var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss"); + + Assert.That(stdout, Does.Contain("Please specify --file for Ploss analysis")); + } + + [Test] + public async Task Analyze_Ploss_ValidTwoLineFile_ReportsSummary() + { + 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)); + + var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file); + + 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")); + }); + } + + [Test] + public async Task Analyze_Ploss_LegacyFile_CountsLegacyRows() + { + var dir = NewDir(); + var file = TestFixtures.WriteLegacyPlossLog( + dir, "singleMold-v1.0-hex2_1-iPhone15-ploss_legacy-20260710-1.log", + (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"); + + Assert.That(stdout, Does.Contain("Please specify --pure and --foreign for Qfod calibration")); + } + + [Test] + public async Task Analyze_Qfod_ValidFiles_ReportsThreshold() + { + var dir = NewDir(); + var pure = TestFixtures.WriteQfodLog( + dir, "singleMold-v1.0-hex2_1-iPhone15-qfod_pure-20260710-1.log", 20, 25, 21); + 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); + + 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")); + }); + } + + [Test] + public async Task Analyze_Qfod_MissingFile_ReportsFailure() + { + var dir = NewDir(); + var foreign = TestFixtures.WriteQfodLog( + dir, "singleMold-v1.0-hex2_1-iPhone15-qfod_foreign-20260710-1.log", 42, 40); + + var (_, stdout, _) = await Analyze().RunCaptureAsync( + "--type", "qfod", + "--pure", Path.Combine(dir, "nope.log"), + "--foreign", foreign); + + Assert.That(stdout, Does.Contain("FAILED")); + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/CleanCommandTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/CleanCommandTests.cs new file mode 100644 index 0000000..ddf4266 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/CleanCommandTests.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; + +namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; + +[TestFixture] +public class CleanCommandTests : CommandTestBase +{ + [Test] + public async Task Clean_NoConfirm_PrintsPrompt() + { + var (code, stdout, _) = await Clean().RunCaptureAsync("--all"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Please add --confirm to confirm the clean operation.")); + }); + } + + [Test] + public async Task Clean_InvalidScenarioGuid_PrintsMessage() + { + var (_, stdout, _) = await Clean().RunCaptureAsync("--scenario", "not-a-guid", "--confirm"); + + Assert.That(stdout, Does.Contain("Invalid scenario ID: not-a-guid")); + } + + [Test] + public async Task Clean_Scenario_NotExist_PrintsZeroCount() + { + var scenarioId = Guid.NewGuid(); + var (_, stdout, _) = await Clean().RunCaptureAsync("--scenario", scenarioId.ToString(), "--confirm"); + + Assert.That(stdout, Does.Contain($"Cleaned 0 records for scenario {scenarioId}")); + } + + [Test] + public async Task Clean_Scenario_WithConfirm_SoftDeletesRelatedRecords() + { + var dir = NewDir(); + var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, + new TestFixtures.PlossTwoLineRow(), + new TestFixtures.PlossTwoLineRow(Field9: 2500, Field12: 1)); + await Parse().RunCaptureAsync("--file", file); + + var scenarioId = await Host.LocalDb.TestScenarios.Select(s => s.Id).SingleAsync(); + + var (code, stdout, _) = await Clean().RunCaptureAsync("--scenario", scenarioId.ToString(), "--confirm"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain($"Cleaned 3 records for scenario {scenarioId}")); + }); + + // 2 条 Ploss + 1 个场景全部软删除 + Assert.That(await Host.LocalDb.PlossRecords.IgnoreQueryFilters().CountAsync(r => r.IsDeleted), Is.EqualTo(2)); + Assert.That(await Host.LocalDb.TestScenarios.IgnoreQueryFilters().CountAsync(s => s.IsDeleted), Is.EqualTo(1)); + } + + [Test] + public async Task Clean_All_WithConfirm_SoftDeletesEverything() + { + var dir = NewDir(); + var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, + new TestFixtures.PlossTwoLineRow(), + new TestFixtures.PlossTwoLineRow()); + await Parse().RunCaptureAsync("--file", file); + + var (code, stdout, _) = await Clean().RunCaptureAsync("--all", "--confirm"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Cleaned 3 records in total")); + }); + + Assert.That(await Host.LocalDb.PlossRecords.IgnoreQueryFilters().CountAsync(r => r.IsDeleted), Is.EqualTo(2)); + Assert.That(await Host.LocalDb.TestScenarios.IgnoreQueryFilters().CountAsync(s => s.IsDeleted), Is.EqualTo(1)); + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/ExportCommandTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/ExportCommandTests.cs new file mode 100644 index 0000000..ed71144 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/ExportCommandTests.cs @@ -0,0 +1,105 @@ +using Gpulse.WCT.DataAnalyzer.Core.Domain.Release; + +namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; + +[TestFixture] +public class ExportCommandTests : CommandTestBase +{ + private static string ResolveOutputDir(params string[] segments) => + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, string.Join(Path.DirectorySeparatorChar, segments)); + + [Test] + public async Task Export_ChargingParametersCsv_WritesFile() + { + Host.ReleaseDb.ChargingParameters.Add(new ChargingParameterRecord + { + CarFactory = "TestFactory", + CarModel = "ModelX", + PhoneBrand = "Apple", + PhoneModel = "iPhone15", + Power350mW = 100, + Power500mW = 200, + }); + await Host.ReleaseDb.SaveChangesAsync(); + + var (code, stdout, _) = await Export().RunCaptureAsync( + "--format", "csv", + "--type", "charging-parameters", + "--output", "data/output/export_cp"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("ChargingParameterDatabase CSV exported")); + }); + + var file = Path.Combine(ResolveOutputDir("data", "output", "export_cp"), "WCT-ChargingParameterDatabase.csv"); + Assert.That(File.Exists(file), Is.True); + + var content = await File.ReadAllTextAsync(file); + Assert.Multiple(() => + { + Assert.That(content, Does.Contain("车厂")); + Assert.That(content, Does.Contain("TestFactory")); + Assert.That(content, Does.Contain("iPhone15")); + }); + } + + [Test] + public async Task Export_QfodCsv_AfterParse_WritesFile() + { + var dir = NewDir(); + var file = TestFixtures.WriteQfodLog(dir, TestFixtures.QfodFileName, 20, 25, 21); + await Parse().RunCaptureAsync("--file", file); + + // 导出文件名带时间戳,先清空输出目录避免跨运行残留文件 + var outDir = ResolveOutputDir("data", "output", "export_qfod"); + if (Directory.Exists(outDir)) + Directory.Delete(outDir, recursive: true); + + var (code, stdout, _) = await Export().RunCaptureAsync( + "--format", "csv", + "--type", "qfod", + "--output", "data/output/export_qfod"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Qfod CSV exported")); + }); + + var exported = Directory.GetFiles(outDir, "qfod_export_*.csv").Single(); + var content = await File.ReadAllTextAsync(exported); + Assert.Multiple(() => + { + Assert.That(content, Does.Contain("delta_q")); + Assert.That(content, Does.Contain("iPhone15")); + }); + } + + [Test] + public async Task Export_UnknownFormat_PrintsMessage() + { + var (code, stdout, _) = await Export().RunCaptureAsync("--format", "xml"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Unknown format: xml. Use 'csv' or 'json'.")); + }); + } + + [Test] + public async Task Export_PathTraversal_PrintsFailure() + { + var (code, stdout, _) = await Export().RunCaptureAsync( + "--format", "csv", + "--output", "../evil"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Export failed")); + }); + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/ParseCommandTests.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/ParseCommandTests.cs new file mode 100644 index 0000000..4a6f1a2 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Commands/ParseCommandTests.cs @@ -0,0 +1,154 @@ +using Microsoft.EntityFrameworkCore; + +namespace Gpulse.WCT.DataAnalyzer.Tests.Commands; + +[TestFixture] +public class ParseCommandTests : CommandTestBase +{ + [Test] + public async Task Parse_File_Missing_ReturnsFailureReport() + { + var missing = Path.Combine(Host.TempDir, "not-exist.log"); + + var (code, stdout, _) = await Parse().RunCaptureAsync("--file", missing); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("not-exist.log")); + Assert.That(stdout, Does.Contain("FAILED")); + }); + } + + [Test] + public async Task Parse_File_WithBadFileName_ReportsFilenameError() + { + var dir = NewDir(); + var file = Path.Combine(dir, "bad-name.log"); + await File.WriteAllTextAsync(file, "0#:Q 1->25 45.5 20.5 2 0\n"); + + var (code, stdout, _) = await Parse().RunCaptureAsync("--file", file); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("bad-name.log")); + Assert.That(stdout, Does.Contain("文件名格式不匹配")); + }); + } + + [Test] + public async Task Parse_File_QfodLog_StoresRecordsAndReportsCount() + { + var dir = NewDir(); + var file = TestFixtures.WriteQfodLog(dir, TestFixtures.QfodFileName, 20, 25, 21, -8); + + var (code, stdout, _) = await Parse().RunCaptureAsync("--file", file); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain(TestFixtures.QfodFileName)); + Assert.That(stdout, Does.Contain("Qfod: 4")); + Assert.That(stdout, Does.Contain("Ploss: 0")); + }); + + Assert.That(await Host.LocalDb.QfodRecords.CountAsync(), Is.EqualTo(4)); + } + + [Test] + public async Task Parse_File_PlossLog_StoresRecordsAndReportsCount() + { + var dir = NewDir(); + var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, + new TestFixtures.PlossTwoLineRow(), + new TestFixtures.PlossTwoLineRow(Field9: 2500, Field10: 3000, Field12: 1)); + + var (code, stdout, _) = await Parse().RunCaptureAsync("--file", file); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Qfod: 0")); + Assert.That(stdout, Does.Contain("Ploss: 2")); + }); + + Assert.That(await Host.LocalDb.PlossRecords.CountAsync(), Is.EqualTo(2)); + } + + [Test] + public async Task Parse_File_WithForceOption_Accepted() + { + var dir = NewDir(); + var file = TestFixtures.WriteQfodLog(dir, TestFixtures.QfodFileName, 20, 25, 21); + + var (code, stdout, _) = await Parse().RunCaptureAsync("--file", file, "--force"); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Qfod: 3")); + }); + } + + [Test] + public async Task Parse_Dir_ReportsSummaryAndTotals() + { + var dir = NewDir(); + TestFixtures.WriteQfodLog(dir, TestFixtures.QfodFileName, 20, 25, 21); + TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName, + new TestFixtures.PlossTwoLineRow(), + new TestFixtures.PlossTwoLineRow()); + + var (code, stdout, _) = await Parse().RunCaptureAsync("--dir", dir); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("2 files processed")); + Assert.That(stdout, Does.Contain("Total: Qfod=3, Ploss=2, Errors=0")); + }); + } + + [Test] + public async Task Parse_Dir_Recursive_IncludesSubfolders() + { + var root = NewDir(); + var sub = Directory.CreateDirectory(Path.Combine(root, "sub")).FullName; + TestFixtures.WriteQfodLog(root, TestFixtures.QfodFileName, 20, 25); + TestFixtures.WritePlossLog(sub, TestFixtures.PlossFileName, new TestFixtures.PlossTwoLineRow()); + + var (_, plain, _) = await Parse().RunCaptureAsync("--dir", root); + Assert.That(plain, Does.Contain("1 files processed")); + + var (_, recursive, _) = await Parse().RunCaptureAsync("--dir", root, "--recursive"); + Assert.That(recursive, Does.Contain("2 files processed")); + } + + [Test] + public async Task Parse_Dir_NotExists_ReturnsFailureReport() + { + var missing = Path.Combine(Host.TempDir, "no-such-dir"); + + var (code, stdout, _) = await Parse().RunCaptureAsync("--dir", missing); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("FAILED")); + Assert.That(stdout, Does.Contain("目录不存在")); + }); + } + + [Test] + public async Task Parse_NeitherFileNorDir_PrintsPrompt() + { + var (code, stdout, _) = await Parse().RunCaptureAsync(); + + Assert.Multiple(() => + { + Assert.That(code, Is.EqualTo(0)); + Assert.That(stdout, Does.Contain("Please specify --file or --dir")); + }); + } +} diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/GlobalUsings.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/GlobalUsings.cs new file mode 100644 index 0000000..3244567 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/Gpulse.WCT.DataAnalyzer.Tests.csproj b/tests/Gpulse.WCT.DataAnalyzer.Tests/Gpulse.WCT.DataAnalyzer.Tests.csproj new file mode 100644 index 0000000..74cc276 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/Gpulse.WCT.DataAnalyzer.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + 12.0 + false + true + Gpulse.WCT.DataAnalyzer.Tests + + + + + + + + + + + + + + + + + + + diff --git a/tests/Gpulse.WCT.DataAnalyzer.Tests/TestFixtures.cs b/tests/Gpulse.WCT.DataAnalyzer.Tests/TestFixtures.cs new file mode 100644 index 0000000..fe9e271 --- /dev/null +++ b/tests/Gpulse.WCT.DataAnalyzer.Tests/TestFixtures.cs @@ -0,0 +1,80 @@ +namespace Gpulse.WCT.DataAnalyzer.Tests; + +/// +/// 构造 CLI 测试用的日志 fixture 文件。文件名符合 FileNameParser 约定的 +/// `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"; + + /// + /// 两行 Ploss 记录(header + FOD 16 字段)。 + /// 默认值让恒等式成立(Field5 - Field4 - PowLoss - ploss == DeltaP)且余量 > 2000。 + /// + public sealed record PlossTwoLineRow( + int PowLoss = 2200, + int DeltaP = 2500, + int Field1 = 1, + int Field2 = 0, + int Field3 = 0, + int Field4 = 800, // RX power + int Field5 = 6000, // TX power + int Field6 = 0, + int Field7 = 0, + int Field8 = 0, + int Field9 = 500, // ploss + int Field10 = 3000, // threshold + int Field11 = 0, + int Field12 = 0, // FOD 标志 + int Field13 = 0, + int Field14 = 0, + int Field15 = 0, + int Field16 = 0); + + /// 写两行格式 Ploss 日志文件。 + public static string WritePlossLog(string dir, string fileName, params PlossTwoLineRow[] rows) + { + var path = Path.Combine(dir, fileName); + var lines = new List(rows.Length * 2); + foreach (var r in rows) + { + lines.Add($"pow_loss = {r.PowLoss}, delta_p = {r.DeltaP}"); + lines.Add( + $"FOD-> {r.Field1} {r.Field2} {r.Field3} {r.Field4} {r.Field5} {r.Field6} " + + $"{r.Field7} {r.Field8} {r.Field9} {r.Field10} {r.Field11} {r.Field12} " + + $"{r.Field13} {r.Field14} {r.Field15} {r.Field16}"); + } + File.WriteAllLines(path, lines); + return path; + } + + /// 写旧单行格式 Ploss 日志(14 字段),仅指定 ploss/threshold/FOD 三个关键字段。 + public static string WriteLegacyPlossLog(string dir, string fileName, params (int Ploss, int Threshold, int Fod)[] rows) + { + var path = Path.Combine(dir, fileName); + var lines = rows + .Select(r => $"FOD-> 1 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) + { + var path = Path.Combine(dir, fileName); + var lines = deltaQs + .Select((d, i) => + { + var charger = i % 2; + var coil = i % 3; + var currentQ = 40 + d; + return $"{charger}#:Q {coil}->{d} {currentQ}.0 20.0 0 0"; + }) + .ToArray(); + File.WriteAllLines(path, lines); + return path; + } +}