Files

512 lines
19 KiB
C#
Raw Permalink Normal View History

using ClosedXML.Excel;
2026-08-17 09:24:57 +08:00
namespace Gpulse.WCT.DataAnalyzer.Tests.Commands;
[TestFixture]
public class AnalyzeCommandTests : CommandTestBase
{
/// <summary>
/// 真实产线 .DAT含 35 条两行 Ploss1 条 FOD全部恒等式成立
/// 余量与 delta_p' 反解在真实数据上应当与手算一致。
/// 直接指向仓库根的 Resources 目录gitignored但本地存在CI 无此文件则跳过。
/// </summary>
[Test]
public async Task Analyze_Ploss_RealVivoFile_GeneratesExcel()
{
var file = ResolveResourcesFile(
"C车-单充快速成型件-A03-V55-vivo iQOD12 Pro-20250519-01.DAT");
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("[OK]"));
Assert.That(stdout, Does.Contain("Total: 35"));
Assert.That(File.Exists(outputPath), Is.True);
});
}
/// <summary>
/// 真实产线 Qfod 标定从「小米17功率上不去」文件中分离出纯手机与异物两段
/// 新建两个符合 FileNameParser 约定的 .log 文件:
/// <list type="bullet">
/// <item>pure前 21 条稳定读数ΔQ=3~5</item>
/// <item>foreign后 7 条衰减读数ΔQ 为负值(-5 ~ -49</item>
/// </list>
/// 阈值 = (pureMax + foreignMin) / 2 = (5 + -49) / 2 = -22
/// </summary>
[Test]
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, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(File.Exists(outputPath), Is.True);
});
}
2026-08-17 09:24:57 +08:00
[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_WithFile_PrintsMessage()
2026-08-17 09:24:57 +08:00
{
// 确保没有 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);
2026-08-17 09:24:57 +08:00
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_TriggersAutoScan()
2026-08-17 09:24:57 +08:00
{
// 不提供文件路径时触发自动扫描
CleanupDatasAndOutput();
2026-08-17 09:24:57 +08:00
var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss");
// 没有 Datas 文件夹时报错
Assert.That(stdout, Does.Contain("未找到 Datas 文件夹"));
2026-08-17 09:24:57 +08:00
}
[Test]
public async Task Analyze_Ploss_ValidTwoLineFile_GeneratesExcel()
2026-08-17 09:24:57 +08:00
{
var dir = NewDir();
var file = TestFixtures.WritePlossLog(dir, TestFixtures.PlossFileName,
// 正常两行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));
2026-08-17 09:24:57 +08:00
var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync(
"--type", "ploss", "--file", file, "--output", outputPath);
2026-08-17 09:24:57 +08:00
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(File.Exists(outputPath), Is.True);
2026-08-17 09:24:57 +08:00
});
}
[Test]
public async Task Analyze_Qfod_MissingArgs_TriggersAutoScan()
2026-08-17 09:24:57 +08:00
{
// 不提供文件路径时触发自动扫描
CleanupDatasAndOutput();
2026-08-17 09:24:57 +08:00
var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod");
// 没有 Datas 文件夹时报错
Assert.That(stdout, Does.Contain("未找到 Datas 文件夹"));
2026-08-17 09:24:57 +08:00
}
[Test]
public async Task Analyze_Qfod_ValidFiles_GeneratesExcel()
2026-08-17 09:24:57 +08:00
{
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 outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "qfod_result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync(
"--type", "qfod", "--pure", pure, "--foreign", foreign, "--output", outputPath);
2026-08-17 09:24:57 +08:00
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(File.Exists(outputPath), Is.True);
2026-08-17 09:24:57 +08:00
});
}
[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"));
}
/// <summary>
/// 在测试输出目录、以及从该目录向上一路找的仓库根 <c>Resources/</c> 中定位真实数据文件。
/// 找不到时调用 <see cref="Assert.Ignore(string)"/> 跳过CI 无产线 .DAT 时不会让用例变红。
/// 与其它 fixture 模式不同:真实数据不复制到 bin/ —— 直接以相对仓库根的路径读取。
/// </summary>
private static string ResolveResourcesFile(string fileName)
{
// 1) 测试输出目录下的 Resources 副本csproj 配置 CopyToOutputDirectory 时)
var candidates = new List<string>
{
Path.Combine(AppContext.BaseDirectory, "Resources", fileName),
};
// 2) 从测试输出目录向上回溯寻找仓库根的 Resources 目录
// 典型路径:…/tests/Gpulse.WCT.DataAnalyzer.Tests/bin/Debug/net8.0
// → 仓库根在这之上 5 级
var dir = new DirectoryInfo(AppContext.BaseDirectory);
for (int up = 0; up < 8 && dir != null; up++, dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "Resources", fileName);
candidates.Add(candidate);
if (File.Exists(candidate))
return candidate;
}
Assert.Ignore($"真实数据文件不存在(已检索 {candidates.Count} 个候选路径):{fileName}");
return null!; // 不会执行
}
#region --auto
/// <summary>
/// 测试 --auto 自动扫描 Datas 文件夹并执行 Ploss 分析
/// </summary>
[Test]
public async Task Analyze_Auto_Ploss_ScansDatasFolder()
{
// 创建 Datas 文件夹和测试文件
var datasDir = Path.Combine(AppContext.BaseDirectory, "Datas");
Directory.CreateDirectory(datasDir);
TestFixtures.WritePlossLog(datasDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
try
{
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--auto");
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("扫描 Datas 文件夹"));
Assert.That(stdout, Does.Contain("[完成] 已分析"));
});
}
finally
{
if (Directory.Exists(datasDir)) Directory.Delete(datasDir, true);
}
}
/// <summary>
/// 测试 --auto 自动分类纯手机和异物日志
/// </summary>
[Test]
public async Task Analyze_Auto_Qfod_ClassifiesPureAndForeign()
{
var datasDir = Path.Combine(AppContext.BaseDirectory, "Datas");
Directory.CreateDirectory(datasDir);
// 纯手机日志:异物类型 = 无
TestFixtures.WriteQfodLog(datasDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-qfod_pure-2026081701.log", 20, 25, 21);
// 异物日志:异物类型 = 硬币
TestFixtures.WriteQfodLog(datasDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-硬币-qfod_foreign-2026081701.log", -5, -10, -15);
try
{
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod", "--auto");
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
// 验证能识别纯手机和异物
Assert.That(stdout, Does.Contain("纯手机:"));
Assert.That(stdout, Does.Contain("异物:"));
});
}
finally
{
if (Directory.Exists(datasDir)) Directory.Delete(datasDir, true);
}
}
/// <summary>
/// 测试 --auto 时 Datas 文件夹不存在
/// </summary>
[Test]
public async Task Analyze_Auto_NoDatasFolder_ReportsError()
{
CleanupDatasAndOutput();
var (_, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--auto");
Assert.That(stdout, Does.Contain("未找到 Datas 文件夹"));
}
#endregion
#region
/// <summary>
/// 测试输出目录结构Output/{车厂}/{车型}/{手机名}/{文件名}.xlsx
/// </summary>
[Test]
public async Task Analyze_Ploss_CreatesOutputDirectoryStructure()
{
CleanupDatasAndOutput();
var dir = NewDir();
var file = TestFixtures.WritePlossLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
// 验证输出路径包含车厂/车型/手机名
Assert.That(stdout, Does.Contain("Output"));
Assert.That(stdout, Does.Contain("奇瑞"));
Assert.That(stdout, Does.Contain("E5"));
Assert.That(stdout, Does.Contain("iPhone15"));
});
}
#endregion
#region Qfod ΔQ
/// <summary>
/// 测试 Qfod 统计使用绝对值,阈值用有符号 ΔQ见使用指南 Threshold=-22 样例)。
/// 本用例 pure ΔQ=-20,-25,-21foreign ΔQ=30,35,40 → pureMax=-20, foreignMin=30
/// 阈值 = (-20 + 30) / 2 = 5有符号。断言行7标定Q值单元格 == 5。
/// </summary>
[Test]
public async Task Analyze_Qfod_WithNegativeValues_GeneratesExcel()
{
CleanupDatasAndOutput();
var dir = NewDir();
// 包含负值 ΔQ
var pure = TestFixtures.WriteQfodLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-qfod_pure-2026081701.log", -20, -25, -21);
var foreign = TestFixtures.WriteQfodLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-硬币-qfod_foreign-2026081701.log", 30, 35, 40);
var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod", "--pure", pure, "--foreign", foreign, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(File.Exists(outputPath), Is.True);
});
// 读回 Excel行6 标定Q值Q0 列 col=2应为有符号阈值 5
using var workbook = new XLWorkbook(outputPath);
var ws = workbook.Worksheet("Sheet1");
Assert.Multiple(() =>
{
Assert.That(ws.Cell(6, 1).GetString(), Is.EqualTo("标定Q值")); // 行6 是标定Q值行
// Q0 在行6合并了三列值在合并后的第一个单元格
Assert.That((int)ws.Cell(6, 2).GetDouble(), Is.EqualTo(5));
});
}
#endregion
#region Ploss
/// <summary>
/// 测试 Ploss 标定值计算标定Δp = 理论Δp最大值 + a
/// 功率段 350 的 a 值为 3000。理论Δp = 6000-(800+2200+500) = 2500 → 标定 = 5500。
/// 读回 Excel 校验行7标定Δp单元格350 段 col=2== 5500。
/// </summary>
[Test]
public async Task Analyze_Ploss_CalculatesCalibrationValue()
{
CleanupDatasAndOutput();
var dir = NewDir();
// 创建功率段 350 的数据
// 理论Δp = Field5 - (Field4 + PowLoss + Field9) = 6000 - (800 + 2200 + 500) = 2500
var file = TestFixtures.WritePlossLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(File.Exists(outputPath), Is.True);
Assert.That(stdout, Does.Contain("Total: 1"));
Assert.That(stdout, Does.Contain("Dropped: 0"));
});
// 读回 Excel行1 列2 含功率段 350行6 标定Δp值col=2= 2500 + 3000 = 5500
using var workbook = new XLWorkbook(outputPath);
var ws = workbook.Worksheet("Sheet1");
Assert.Multiple(() =>
{
Assert.That((int)ws.Cell(1, 2).GetDouble(), Is.EqualTo(350)); // 行1 是功率段标题行
Assert.That(ws.Cell(6, 1).GetString(), Is.EqualTo("标定Δp值")); // 行6 是标定行
Assert.That((long)ws.Cell(6, 2).GetDouble(), Is.EqualTo(5500L));
});
}
/// <summary>
/// RV-004Field10 非标准功率段的记录应计入 DroppedCount 并在 stdout 暴露,
/// 而非静默丢弃。
/// </summary>
[Test]
public async Task Analyze_Ploss_NonStandardPowerSegment_IsDropped()
{
CleanupDatasAndOutput();
var dir = NewDir();
// Field10=850 不在 9 个标准功率段内 → 被丢弃
var file = TestFixtures.WritePlossLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 850));
var outputDir = NewDir();
var outputPath = Path.Combine(outputDir, "result.xlsx");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(File.Exists(outputPath), Is.True);
Assert.That(stdout, Does.Contain("Total: 1"));
Assert.That(stdout, Does.Contain("Dropped: 1"));
});
}
/// <summary>
/// RV-002pure/foreign 日志存在但未提取到 Qfod 行 → 空记录守卫应返回失败而非从 0 起算成功。
/// </summary>
[Test]
public async Task Analyze_Qfod_EmptyRecords_ReportsFailure()
{
CleanupDatasAndOutput();
var dir = NewDir();
// 写一个只有空白行的"空"日志(文件存在但无 Qfod 行)
var pure = Path.Combine(dir, "pure_empty.log");
var foreign = Path.Combine(dir, "foreign_empty.log");
await File.WriteAllTextAsync(pure, "\n\n \n");
await File.WriteAllTextAsync(foreign, "\n\n \n");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "qfod", "--pure", pure, "--foreign", foreign);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("FAILED"));
Assert.That(stdout, Does.Contain("未提取到 Qfod"));
});
}
/// <summary>
/// RV-006--output 指向不存在的嵌套目录时应自动创建而非崩溃。
/// </summary>
[Test]
public async Task Analyze_Ploss_OutputToMissingDir_CreatesDir()
{
CleanupDatasAndOutput();
var dir = NewDir();
var file = TestFixtures.WritePlossLog(dir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
var outputRoot = NewDir();
var outputPath = Path.Combine(outputRoot, "nested", "deeper", "result.xlsx");
Assume.That(Directory.Exists(Path.GetDirectoryName(outputPath)), Is.False);
var (code, _, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--file", file, "--output", outputPath);
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(File.Exists(outputPath), Is.True);
});
}
/// <summary>
/// RV-005--auto 多文件其中一文件损坏(无法解析)不应中止整批;其余文件仍产出 [OK]
/// 且 stdout 含 [FAILED] 与最终 [完成] 汇总。
/// </summary>
[Test]
public async Task Analyze_Auto_Ploss_OneBadFile_DoesNotAbortBatch()
{
// 准备 Datas 文件夹:一个好文件 + 一个坏文件(只有 header 无 FOD 行)
var dataDir = Path.Combine(AppContext.BaseDirectory, "Datas");
try
{
Directory.CreateDirectory(dataDir);
TestFixtures.WritePlossLog(dataDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-ploss_test-2026081701.dat",
new TestFixtures.PlossTwoLineRow(Field10: 350));
// 坏文件:仅 header 行,无配对 FOD 行 → 解析错误计数
var badPath = Path.Combine(dataDir, "奇瑞-E5-singleMold-A00-V001-iPhone15-无-badfile-2026081702.dat");
await File.WriteAllTextAsync(badPath, "pow_loss = 2200, delta_p = 2500\n");
var (code, stdout, _) = await Analyze().RunCaptureAsync("--type", "ploss", "--auto");
Assert.Multiple(() =>
{
Assert.That(code, Is.EqualTo(0));
Assert.That(stdout, Does.Contain("[OK]"));
Assert.That(stdout, Does.Contain("[完成]"));
});
}
finally
{
if (Directory.Exists(dataDir)) Directory.Delete(dataDir, true);
}
}
#endregion
2026-08-17 09:24:57 +08:00
}