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;
+ }
+}