Compare commits

...
3 Commits
Author SHA1 Message Date
huangsucan 7a56eb98d2 测试报告导出修改 2026-08-30 15:36:57 +08:00
huangsucan 61d7293afb 测试报告导出优化 2026-08-27 14:51:15 +08:00
huangsucan 47b4d3837b 弹窗添加 2026-08-27 14:26:22 +08:00
14 changed files with 642 additions and 6 deletions
+55
View File
@@ -1,4 +1,5 @@
using Common; using Common;
using Command;
using ADP.ViewModels; using ADP.ViewModels;
using ADP.ViewModels.Dialogs; using ADP.ViewModels.Dialogs;
using ADP.Views; using ADP.Views;
@@ -12,6 +13,8 @@ using Service.Interface;
using System.Configuration; using System.Configuration;
using System.Data; using System.Data;
using System.Reflection; using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Windows; using System.Windows;
using UIShare.PubEvent; using UIShare.PubEvent;
using static System.Runtime.InteropServices.JavaScript.JSType; using static System.Runtime.InteropServices.JavaScript.JSType;
@@ -23,6 +26,7 @@ using DeviceCommand.Base;
using AutoMapper; using AutoMapper;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using ADP.Profiles; using ADP.Profiles;
using Prism.Dialogs;
using ; using ;
namespace ADP namespace ADP
@@ -60,6 +64,56 @@ namespace ADP
} }
protected override void OnInitialized() protected override void OnInitialized()
{ {
// 向命令库 CommandDialog 注入弹窗能力(命令库是纯 .NET 类库,不引用 WPF,这里通过委托实现依赖倒置)
var dialogService = Container.Resolve<IDialogService>();
CommandDialog. = (, , , , ct) =>
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationTokenRegistration registration = default;
if ()
{
registration = ct.Register(() => tcs.TrySetCanceled(ct));
}
// 命令可能在后台线程执行,统一切回 UI 线程弹窗
Application.Current.Dispatcher.Invoke(() =>
{
var param = new DialogParameters
{
{ "Title", switch
{
CommandDialog.DialogType.Warning => "警告",
CommandDialog.DialogType.Error => "错误",
_ => "信息提示"
} },
{ "Message", ?? string.Empty },
{ "Icon", switch
{
CommandDialog.DialogType.Warning => "warn",
CommandDialog.DialogType.Error => "error",
_ => "info"
} },
{ "ShowOk", true },
{ "AutoCloseSeconds", (double) }
};
if ()
{
// 阻塞:用户手动关闭或自动关闭后才继续执行步骤
dialogService.ShowDialog("MessageBox", param, _ =>
{
registration.Dispose();
tcs.TrySetResult(true);
});
}
else
{
// 非阻塞:弹出后步骤立即继续(不等关闭回调)
dialogService.Show("MessageBox", param, _ => { });
tcs.TrySetResult(true);
}
});
return tcs.Task;
};
// 配置全局日志分发器:按 CurrentScope 路由到对应 LogArea // 配置全局日志分发器:按 CurrentScope 路由到对应 LogArea
var globalInfo = Container.Resolve<GlobalInfo>(); var globalInfo = Container.Resolve<GlobalInfo>();
LoggerHelper.Progress = new ScopeLogDispatcher(globalInfo); LoggerHelper.Progress = new ScopeLogDispatcher(globalInfo);
@@ -152,6 +206,7 @@ namespace ADP
//注册服务 //注册服务
containerRegistry.Register<IMonitorValueService, MonitorValueService>(); containerRegistry.Register<IMonitorValueService, MonitorValueService>();
containerRegistry.Register<ITestReportService, TestReportService>(); containerRegistry.Register<ITestReportService, TestReportService>();
containerRegistry.Register<ITestCheckRecordService, TestCheckRecordService>();
} }
//指定模块加载方式(需要手动将模块生成的dll放入Modules文件夹中) //指定模块加载方式(需要手动将模块生成的dll放入Modules文件夹中)
protected override IModuleCatalog CreateModuleCatalog() protected override IModuleCatalog CreateModuleCatalog()
@@ -1,7 +1,9 @@
using UIShare.PubEvent; using UIShare.PubEvent;
using UIShare.ViewModelBase; using UIShare.ViewModelBase;
using System;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Threading;
namespace ADP.ViewModels.Dialogs namespace ADP.ViewModels.Dialogs
{ {
@@ -69,6 +71,11 @@ namespace ADP.ViewModels.Dialogs
public DialogCloseListener RequestClose { get; set; } public DialogCloseListener RequestClose { get; set; }
/// <summary>
/// 自动关闭定时器:自动关闭秒数大于 0 时启动,到时自动关闭弹窗(同时解除阻塞等待)
/// </summary>
private DispatcherTimer _autoCloseTimer;
public MessageBoxViewModel(IContainerProvider containerProvider):base(containerProvider) public MessageBoxViewModel(IContainerProvider containerProvider):base(containerProvider)
{ {
YesCommand = new DelegateCommand(OnYes); YesCommand = new DelegateCommand(OnYes);
@@ -93,6 +100,7 @@ namespace ADP.ViewModels.Dialogs
public override void OnDialogClosed() public override void OnDialogClosed()
{ {
_autoCloseTimer?.Stop();
_eventAggregator.GetEvent<OverlayEvent>().Publish(false); _eventAggregator.GetEvent<OverlayEvent>().Publish(false);
} }
@@ -117,6 +125,21 @@ namespace ADP.ViewModels.Dialogs
ShowNo = parameters.GetValue<bool>("ShowNo"); ShowNo = parameters.GetValue<bool>("ShowNo");
ShowOk = parameters.GetValue<bool>("ShowOk"); ShowOk = parameters.GetValue<bool>("ShowOk");
ShowCancel = parameters.GetValue<bool>("ShowCancel"); ShowCancel = parameters.GetValue<bool>("ShowCancel");
// 自动关闭:秒数大于 0 时启动定时器,到时自动关闭(供命令库 弹窗 命令的自动关闭参数使用)
if (parameters.TryGetValue("AutoCloseSeconds", out double autoCloseSeconds) && autoCloseSeconds > 0)
{
_autoCloseTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(autoCloseSeconds)
};
_autoCloseTimer.Tick += (s, e) =>
{
_autoCloseTimer.Stop();
RequestClose.Invoke(new DialogResult(ButtonResult.OK));
};
_autoCloseTimer.Start();
}
} }
#endregion #endregion
} }
+62
View File
@@ -0,0 +1,62 @@
using Common.Attributes;
using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
namespace Command
{
/// <summary>
/// 对话框命令:在测试流程中弹出提示窗口。
/// 命令库为纯 .NET 类库,不直接引用 WPF 程序集;
/// 实际的弹窗能力由宿主程序(ADP 外壳)在启动时注入到 <see cref="弹窗处理器"/> 委托中实现(依赖倒置)。
/// </summary>
[ADPCommand]
public static class CommandDialog
{
/// <summary>
/// 弹窗类型
/// </summary>
public enum DialogType
{
/// <summary>
/// 信息提示
/// </summary>
Info,
/// <summary>
/// 警告提示
/// </summary>
Warning,
/// <summary>
/// 错误提示
/// </summary>
Error
}
/// <summary>
/// 弹窗处理委托(参数依次为:弹窗类型、弹窗详细、是否阻塞、自动关闭秒数、取消令牌)。
/// 由宿主程序启动时注入实现;未注入时弹窗命令降级为仅输出日志,不会抛异常。
/// </summary>
[Browsable(false)]
public static Func<DialogType, string, bool, float, CancellationToken, Task> ;
/// <summary>
/// 弹窗:在界面上显示一个提示对话框。
/// </summary>
/// <param name="弹窗类型">弹窗样式:Info 信息 / Warning 警告 / Error 错误</param>
/// <param name="弹窗详细">弹窗中显示的详细内容</param>
/// <param name="是否阻塞">true = 步骤暂停,等待用户关闭(或自动关闭)后才继续;false = 弹出后步骤立即继续</param>
/// <param name="自动关闭秒数">大于 0 时,弹窗在指定秒数后自动关闭;小于等于 0 时不自动关闭,需用户手动关闭</param>
/// <param name="ct">异步取消令牌</param>
public static async Task (DialogType , string , bool , float , CancellationToken ct)
{
var handler = ;
if (handler == null)
{
Console.WriteLine($"[弹窗命令] 宿主未注入弹窗处理器,跳过弹窗:{弹窗详细}");
return;
}
await handler(, , , , ct);
}
}
}
+25 -1
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -23,7 +24,7 @@ namespace Common.Tools
{ {
foreach (var kvp in processedVariables) foreach (var kvp in processedVariables)
{ {
expr.Parameters[kvp.Key] = kvp.Value; expr.Parameters[kvp.Key] = NormalizeValue(kvp.Value);
} }
} }
@@ -95,6 +96,29 @@ namespace Common.Tools
return (processedExpression, newVariables); return (processedExpression, newVariables);
} }
/// <summary>
/// 归一化变量取值:输入变量的值常以字符串形式保存(如 "10"),
/// 若直接参与比较,NCalc 会按字符串逐位比较("10" > "5" 为假),
/// 导致大于/小于判断失真。此处将可解析为数字/布尔的字符串转换为对应类型,
/// 使比较按数值语义进行;无法解析的字符串保持原样。
/// </summary>
private static object? NormalizeValue(object? value)
{
if (value is string s)
{
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)
|| double.TryParse(s, out d))
{
return d;
}
if (bool.TryParse(s, out var b))
{
return b;
}
}
return value;
}
// 检查字符串是否包含中文字符 // 检查字符串是否包含中文字符
private static bool ContainsChinese(string text) private static bool ContainsChinese(string text)
{ {
+165
View File
@@ -82,19 +82,23 @@ namespace ExportModule.ViewModels
public ICommand LoadedCommand { get; } public ICommand LoadedCommand { get; }
public ICommand QueryCommand { get; } public ICommand QueryCommand { get; }
public ICommand ExportCommand { get; } public ICommand ExportCommand { get; }
public ICommand ExportReportCommand { get; }
#endregion #endregion
#region #region
private readonly ITestReportService _testReportService; private readonly ITestReportService _testReportService;
private readonly ITestCheckRecordService _testCheckRecordService;
#endregion #endregion
public ExportViewModel(IContainerProvider containerProvider) : base(containerProvider) public ExportViewModel(IContainerProvider containerProvider) : base(containerProvider)
{ {
_testReportService = containerProvider.Resolve<ITestReportService>(); _testReportService = containerProvider.Resolve<ITestReportService>();
_testCheckRecordService = containerProvider.Resolve<ITestCheckRecordService>();
LoadedCommand = new AsyncDelegateCommand(OnLoad); LoadedCommand = new AsyncDelegateCommand(OnLoad);
QueryCommand = new AsyncDelegateCommand(OnQuery); QueryCommand = new AsyncDelegateCommand(OnQuery);
ExportCommand = new AsyncDelegateCommand(OnExport); ExportCommand = new AsyncDelegateCommand(OnExport);
ExportReportCommand = new AsyncDelegateCommand(OnExportReportCommand);
} }
#region #region
@@ -183,6 +187,39 @@ namespace ExportModule.ViewModels
await Task.Run(() => ExportToExcel(dialog.FileName, entities)); await Task.Run(() => ExportToExcel(dialog.FileName, entities));
ShowInfoMessageBox($"导出完成,共 {entities.Count} 条步骤记录,已保存至:{dialog.FileName}", () => { }); ShowInfoMessageBox($"导出完成,共 {entities.Count} 条步骤记录,已保存至:{dialog.FileName}", () => { });
} }
/// <summary>
/// 导出测试报告:上层为各测试项(IsTestItem 子程序)的 PASS/NG 汇总,下层为每次 OKExpression 判断明细
/// </summary>
private async Task OnExportReportCommand()
{
if (SelectedTestReport == null)
{
ShowErrorMessageBox("请先在列表中选择一条测试记录。", () => { });
return;
}
StatusMessage = "正在查询测试项判断记录...";
var checkResult = await _testCheckRecordService.GetByTestRoundIdAsync(SelectedTestReport.TestRoundId);
if (!checkResult.IsSuccess || checkResult.Data == null || checkResult.Data.Count == 0)
{
ShowErrorMessageBox("未找到该测试记录的测试项判断数据(需将子程序标记为测试项后运行)。", () => { });
return;
}
var dialog = new SaveFileDialog
{
Filter = "Excel 工作簿 (*.xlsx)|*.xlsx|所有文件 (*.*)|*.*",
DefaultExt = ".xlsx",
FileName = $"测试报告_{SelectedTestReport.Scope}_{SelectedTestReport.StartTime:yyyyMMdd_HHmmss}.xlsx"
};
if (dialog.ShowDialog() != true) return;
var records = checkResult.Data;
StatusMessage = $"正在导出 {records.Count} 条测试项判断记录...";
await Task.Run(() => ExportReportToExcel(dialog.FileName, records, SelectedTestReport));
ShowInfoMessageBox($"导出完成,已保存至:{dialog.FileName}", () => { });
}
#endregion #endregion
@@ -263,6 +300,134 @@ namespace ExportModule.ViewModels
workbook.SaveAs(filePath); workbook.SaveAs(filePath);
} }
/// <summary>
/// 测试报告导出:上层为各测试项(IsTestItem 子程序)的 PASS/NG 汇总,下层为每次 OKExpression 判断明细(含重复判断、判断时间、数据值)
/// </summary>
private static void ExportReportToExcel(string filePath, List<TestCheckRecordEntity> records, TestReportModel report)
{
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("测试报告");
const int colCount = 6;
var passColor = XLColor.FromArgb(0xB2, 0xFF, 0xB2); // 通过 - 浅绿(与现有导出配色一致)
var ngColor = XLColor.FromArgb(0xFF, 0xB2, 0xB2); // 失败 - 浅红
var headerColor = XLColor.FromArgb(0xEC, 0xEF, 0xF4);
int row = 1;
// ===== 标题与信息行 =====
ws.Range(row, 1, row, colCount).Merge();
ws.Cell(row, 1).Value = "测 试 报 告";
ws.Range(row, 1, row, colCount).Style.Font.Bold = true;
ws.Range(row, 1, row, colCount).Style.Font.FontSize = 16;
ws.Range(row, 1, row, colCount).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
row++;
ws.Cell(row, 1).Value = $"台架:{report.Scope ?? ""}";
ws.Cell(row, 3).Value = $"测试文件:{report.FileName ?? ""}";
row++;
ws.Cell(row, 1).Value = $"开始时间:{report.StartTime:yyyy-MM-dd HH:mm:ss}";
ws.Cell(row, 3).Value = $"结束时间:{report.EndTime:yyyy-MM-dd HH:mm:ss}";
row += 2;
// 按测试项分组(保持首次出现顺序)
var groups = records.GroupBy(r => r.TestItemName).ToList();
// ===== 上层:测试项汇总 =====
ws.Cell(row, 1).Value = "【测试项汇总】";
ws.Cell(row, 1).Style.Font.Bold = true;
row++;
string[] summaryHeaders = { "序号", "测试项", "判定结果", "执行次数", "判定次数", "NG次数" };
for (int c = 0; c < summaryHeaders.Length; c++)
ws.Cell(row, c + 1).Value = summaryHeaders[c];
var headerRange = ws.Range(row, 1, row, colCount);
headerRange.Style.Font.Bold = true;
headerRange.Style.Fill.BackgroundColor = headerColor;
headerRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
headerRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
row++;
int seq = 1;
foreach (var group in groups)
{
var summaries = group.Where(x => x.IsSummary).ToList();
var details = group.Where(x => !x.IsSummary).ToList();
bool overallPass = summaries.All(s => s.Pass) && details.All(d => d.Pass);
ws.Cell(row, 1).Value = seq++;
ws.Cell(row, 2).Value = group.Key;
ws.Cell(row, 3).Value = overallPass ? "PASS" : "NG";
ws.Cell(row, 4).Value = summaries.Count;
ws.Cell(row, 5).Value = details.Count;
ws.Cell(row, 6).Value = details.Count(d => !d.Pass);
var dataRange = ws.Range(row, 1, row, colCount);
dataRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
dataRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
ws.Cell(row, 3).Style.Font.Bold = true;
ws.Cell(row, 3).Style.Fill.BackgroundColor = overallPass ? passColor : ngColor;
row++;
}
row++;
// ===== 下层:判断明细 =====
ws.Cell(row, 1).Value = "【判断明细】";
ws.Cell(row, 1).Style.Font.Bold = true;
row++;
string[] detailHeaders = { "序号", "判定时间", "步骤名称", "OKExpression", "判定结果", "数据值" };
foreach (var group in groups)
{
var details = group.Where(x => !x.IsSummary).ToList();
bool overallPass = group.Where(x => x.IsSummary).All(s => s.Pass) && details.All(d => d.Pass);
// 测试项块标题行(合并单元格)
ws.Range(row, 1, row, colCount).Merge();
ws.Cell(row, 1).Value = $"■ 测试项:{group.Key}  总体结果:{(overallPass ? "PASS" : "NG")}";
ws.Cell(row, 1).Style.Font.Bold = true;
ws.Range(row, 1, row, colCount).Style.Fill.BackgroundColor = overallPass ? passColor : ngColor;
row++;
for (int c = 0; c < detailHeaders.Length; c++)
ws.Cell(row, c + 1).Value = detailHeaders[c];
var detailHeaderRange = ws.Range(row, 1, row, colCount);
detailHeaderRange.Style.Font.Bold = true;
detailHeaderRange.Style.Fill.BackgroundColor = headerColor;
detailHeaderRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
detailHeaderRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
row++;
if (details.Count == 0)
{
ws.Range(row, 1, row, colCount).Merge();
ws.Cell(row, 1).Value = "(无 OKExpression 判断,结果由步骤执行成败决定)";
row++;
}
int detailSeq = 1;
foreach (var d in details)
{
ws.Cell(row, 1).Value = detailSeq++;
ws.Cell(row, 2).Value = d.CreateTime.ToString("yyyy-MM-dd HH:mm:ss.fff");
ws.Cell(row, 3).Value = d.StepName ?? "";
ws.Cell(row, 4).Value = d.OKExpression ?? "";
ws.Cell(row, 5).Value = d.Pass ? "PASS" : "NG";
ws.Cell(row, 6).Value = d.Values ?? "";
var detailRange = ws.Range(row, 1, row, colCount);
detailRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
detailRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
ws.Cell(row, 5).Style.Font.Bold = true;
ws.Cell(row, 5).Style.Fill.BackgroundColor = d.Pass ? passColor : ngColor;
row++;
}
row++;
}
ws.Columns().AdjustToContents();
workbook.SaveAs(filePath);
}
#endregion #endregion
#region #region
+9 -1
View File
@@ -60,6 +60,7 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
@@ -109,13 +110,20 @@
Margin="4,0" Margin="4,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Grid.Column="7" <Button Grid.Column="7"
Content="导出 Excel" Content="导出详细测试步骤"
Command="{Binding ExportCommand}" Command="{Binding ExportCommand}"
Style="{StaticResource MaterialDesignFlatButton}" Style="{StaticResource MaterialDesignFlatButton}"
Padding="16,6" Padding="16,6"
Margin="4,0" Margin="4,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Grid.Column="8" <Button Grid.Column="8"
Content="导出测试报告"
Command="{Binding ExportReportCommand}"
Style="{StaticResource MaterialDesignFlatButton}"
Padding="16,6"
Margin="4,0"
VerticalAlignment="Center"/>
<Button Grid.Column="9"
Content="加载全部" Content="加载全部"
Command="{Binding LoadedCommand}" Command="{Binding LoadedCommand}"
Style="{StaticResource MaterialDesignFlatButton}" Style="{StaticResource MaterialDesignFlatButton}"
+53
View File
@@ -0,0 +1,53 @@
using SqlSugar;
using System;
namespace Model.Entity
{
/// <summary>
/// 测试项判断记录:运行过程中测试项(IsTestItem 子程序)范围内每一次 OKExpression 判断。
/// 同一次运行的所有记录共享同一个 TestRoundId,导出测试报告时按此 Guid 查询。
/// IsSummary=true 的行为该测试项单次执行的汇总(PASS/NG),IsSummary=false 的行为单次判断明细。
/// </summary>
public class TestCheckRecordEntity : BaseEntity
{
/// <summary>同一次运行的统一标识(StepRunning.TestRoundID</summary>
[SugarColumn(ColumnName = "TestRoundId", ColumnDescription = "运行轮次标识")]
public Guid TestRoundId { get; set; }
/// <summary>作用域/台架名称</summary>
[SugarColumn(ColumnName = "Scope", ColumnDescription = "台架名称")]
public string Scope { get; set; } = string.Empty;
/// <summary>测试项名称(当前ADP文件路径)</summary>
[SugarColumn(ColumnName = "FileName", ColumnDescription = "测试项名称")]
public string FileName { get; set; } = string.Empty;
/// <summary>测试项名称(IsTestItem 子程序步骤的名称)</summary>
[SugarColumn(ColumnName = "TestItemName", Length = 200)]
public string TestItemName { get; set; } = string.Empty;
/// <summary>发生判断的步骤名称</summary>
[SugarColumn(ColumnName = "StepName", Length = 200)]
public string StepName { get; set; } = string.Empty;
/// <summary>子程序嵌套深度(0=主程序)</summary>
[SugarColumn(ColumnName = "Depth")]
public int Depth { get; set; }
/// <summary>判断表达式</summary>
[SugarColumn(ColumnName = "OKExpression", Length = 1000, IsNullable = true)]
public string? OKExpression { get; set; }
/// <summary>判断结果(true=PASS / false=NG</summary>
[SugarColumn(ColumnName = "Pass")]
public bool Pass { get; set; }
/// <summary>表达式变量的实际取值(如 "电压=12.5; 电流=3.2; "</summary>
[SugarColumn(ColumnName = "Values", Length = 2000, IsNullable = true)]
public string? Values { get; set; }
/// <summary>是否为汇总行(每个测试项单次执行结束时写入一条)</summary>
[SugarColumn(ColumnName = "IsSummary")]
public bool IsSummary { get; set; }
}
}
+3
View File
@@ -12,6 +12,9 @@ namespace Model.Models
public bool IsUsed { get; set; } = true; public bool IsUsed { get; set; } = true;
/// <summary>是否测试项(仅子程序步骤可标记,运行时记录其范围内所有 OKExpression 判断)</summary>
public bool IsTestItem { get; set; } = false;
public int Index { get; set; } public int Index { get; set; }
public string? Name { get; set; } public string? Name { get; set; }
@@ -0,0 +1,38 @@
using Model;
using Model.Entity;
using ORM;
using Service.Interface;
using SqlSugar;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Service.Implement
{
public class TestCheckRecordService : BaseService<TestCheckRecordEntity>, ITestCheckRecordService
{
public TestCheckRecordService(SqlSugarRepository<TestCheckRecordEntity> repository) : base(repository)
{
}
/// <summary>
/// 根据 TestRoundId 查询该次运行的所有测试项判断记录(按创建时间升序)
/// </summary>
public async Task<Result<List<TestCheckRecordEntity>>> GetByTestRoundIdAsync(Guid testRoundId)
{
try
{
var list = await _repository.Entities
.Where(x => x.TestRoundId == testRoundId)
.OrderBy(x => x.CreateTime)
.ToListAsync();
return Result<List<TestCheckRecordEntity>>.Success(list);
}
catch (Exception ex)
{
return Result<List<TestCheckRecordEntity>>.Error("根据 TestRoundId 查询测试项判断记录失败", ex);
}
}
}
}
@@ -0,0 +1,19 @@
using Model;
using Model.Entity;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Service.Interface
{
/// <summary>
/// 测试项判断记录服务:运行时记录测试项范围内每一次 OKExpression 判断,导出测试报告时查询。
/// </summary>
public interface ITestCheckRecordService : IBaseService<TestCheckRecordEntity>
{
/// <summary>
/// 根据 TestRoundId 查询该次运行的所有测试项判断记录(按创建时间升序)
/// </summary>
Task<Result<List<TestCheckRecordEntity>>> GetByTestRoundIdAsync(Guid testRoundId);
}
}
@@ -96,6 +96,7 @@ namespace TestingModule.ViewModels
public ICommand SelectionChangedCommand { get;set; } public ICommand SelectionChangedCommand { get;set; }
public ICommand OpenSubProgramCommand { get; set; } public ICommand OpenSubProgramCommand { get; set; }
public ICommand GoBackCommand { get; set; } public ICommand GoBackCommand { get; set; }
public ICommand ToggleTestItemCommand { get; set; }
#endregion #endregion
#region / #region /
@@ -133,6 +134,7 @@ namespace TestingModule.ViewModels
SelectionChangedCommand = new DelegateCommand<object>(SelectionChanged); SelectionChangedCommand = new DelegateCommand<object>(SelectionChanged);
OpenSubProgramCommand = new DelegateCommand(OpenSubProgram); OpenSubProgramCommand = new DelegateCommand(OpenSubProgram);
GoBackCommand = new DelegateCommand(GoBack); GoBackCommand = new DelegateCommand(GoBack);
ToggleTestItemCommand = new DelegateCommand(ToggleTestItem);
SubscribeStepCollections(); SubscribeStepCollections();
Program.PropertyChanged += Program_PropertyChanged; Program.PropertyChanged += Program_PropertyChanged;
Admin = _globalInfo.IsAdmin; Admin = _globalInfo.IsAdmin;
@@ -297,6 +299,25 @@ namespace TestingModule.ViewModels
nav.CurrentProgram = SelectedStep.SubProgram; nav.CurrentProgram = SelectedStep.SubProgram;
} }
/// <summary>
/// 切换选中步骤的测试项标记(仅子程序可标记,运行时将记录其范围内所有 OKExpression 判断)
/// </summary>
private void ToggleTestItem()
{
if (!_globalInfo.IsAdmin) return;
var source = (SelectedItems != null && SelectedItems.Any())
? SelectedItems
: (SelectedStep != null ? new List<StepVM> { SelectedStep } : null);
if (source == null || !source.Any()) return;
foreach (var item in source.Where(x => x.StepType == "子程序" && x.SubProgram != null))
{
item.IsTestItem = !item.IsTestItem;
}
}
/// <summary> /// <summary>
/// 返回上一级程序(操作当前激活的 Tab 的导航状态) /// 返回上一级程序(操作当前激活的 Tab 的导航状态)
/// </summary> /// </summary>
+12
View File
@@ -99,6 +99,10 @@
<DataGridCheckBoxColumn Width="58" <DataGridCheckBoxColumn Width="58"
Binding="{Binding IsUsed, UpdateSourceTrigger=PropertyChanged}" Binding="{Binding IsUsed, UpdateSourceTrigger=PropertyChanged}"
Header="启用" /> Header="启用" />
<DataGridCheckBoxColumn Width="58"
Binding="{Binding IsTestItem}"
Header="测试项"
IsReadOnly="True" />
<DataGridTextColumn Binding="{Binding Index}" <DataGridTextColumn Binding="{Binding Index}"
Header="序号" Header="序号"
IsReadOnly="True" /> IsReadOnly="True" />
@@ -142,6 +146,8 @@
<Separator/> <Separator/>
<MenuItem Header="打开子程序" <MenuItem Header="打开子程序"
Command="{Binding OpenSubProgramCommand}" /> Command="{Binding OpenSubProgramCommand}" />
<MenuItem Header="标记/取消测试项"
Command="{Binding ToggleTestItemCommand}" />
</ContextMenu> </ContextMenu>
</DataGrid.ContextMenu> </DataGrid.ContextMenu>
@@ -224,6 +230,10 @@
<DataGridCheckBoxColumn Width="58" <DataGridCheckBoxColumn Width="58"
Binding="{Binding IsUsed, UpdateSourceTrigger=PropertyChanged}" Binding="{Binding IsUsed, UpdateSourceTrigger=PropertyChanged}"
Header="启用" /> Header="启用" />
<DataGridCheckBoxColumn Width="58"
Binding="{Binding IsTestItem}"
Header="测试项"
IsReadOnly="True" />
<DataGridTextColumn Binding="{Binding Index}" <DataGridTextColumn Binding="{Binding Index}"
Header="序号" Header="序号"
IsReadOnly="True" /> IsReadOnly="True" />
@@ -267,6 +277,8 @@
<Separator/> <Separator/>
<MenuItem Header="打开子程序" <MenuItem Header="打开子程序"
Command="{Binding OpenSubProgramCommand}" /> Command="{Binding OpenSubProgramCommand}" />
<MenuItem Header="标记/取消测试项"
Command="{Binding ToggleTestItemCommand}" />
</ContextMenu> </ContextMenu>
</DataGrid.ContextMenu> </DataGrid.ContextMenu>
+146 -3
View File
@@ -13,6 +13,7 @@ using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using UIShare.GlobalVariable; using UIShare.GlobalVariable;
using static UIShare.UIViewModel.ParameterVM; using static UIShare.UIViewModel.ParameterVM;
@@ -29,6 +30,7 @@ namespace UIShare
private IContainerProvider containerProvider; private IContainerProvider containerProvider;
private IEventAggregator _eventAggregator; private IEventAggregator _eventAggregator;
private ITestReportService _testReportService; private ITestReportService _testReportService;
private ITestCheckRecordService _testCheckRecordService;
private readonly Dictionary<Guid, ParameterVM> tmpParameters = []; private readonly Dictionary<Guid, ParameterVM> tmpParameters = [];
@@ -38,6 +40,9 @@ namespace UIShare
private readonly Stack<LoopContext> loopStack = new(); private readonly Stack<LoopContext> loopStack = new();
/// <summary>测试项上下文栈:进入 IsTestItem 子程序时压栈,栈非空期间每次 OKExpression 判断都归属栈顶测试项</summary>
private readonly Stack<TestItemContext> testItemStack = new();
public CancellationTokenSource stepCTS = new(); public CancellationTokenSource stepCTS = new();
public CancellationTokenSource errorStepCTS = new(); public CancellationTokenSource errorStepCTS = new();
private bool SubSingleStep = false; private bool SubSingleStep = false;
@@ -46,13 +51,14 @@ namespace UIShare
private volatile bool _disposed = false; private volatile bool _disposed = false;
public Guid TestRoundID; public Guid TestRoundID;
public StepRunning(ScopedContext ScopedContext, SystemConfig systemConfig,IEventAggregator eventAggregator, DeviceManager deviceManager, ITestReportService testReportService) public StepRunning(ScopedContext ScopedContext, SystemConfig systemConfig,IEventAggregator eventAggregator, DeviceManager deviceManager, ITestReportService testReportService, ITestCheckRecordService testCheckRecordService)
{ {
_scopedContext = ScopedContext; _scopedContext = ScopedContext;
_systemConfig = systemConfig; _systemConfig = systemConfig;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_deviceManager= deviceManager; _deviceManager= deviceManager;
_testReportService = testReportService; _testReportService = testReportService;
_testCheckRecordService = testCheckRecordService;
//_devices = containerProvider.Resolve<Devices>(); //_devices = containerProvider.Resolve<Devices>();
} }
public async Task<bool> ExecuteErrorSteps(ProgramVM program, int depth = 0, CancellationToken cancellationToken = default) public async Task<bool> ExecuteErrorSteps(ProgramVM program, int depth = 0, CancellationToken cancellationToken = default)
@@ -253,6 +259,7 @@ namespace UIShare
loopStopwatchStack.Clear(); loopStopwatchStack.Clear();
ResetAllStepStatus(program.StepCollection); ResetAllStepStatus(program.StepCollection);
tmpParameters.Clear(); tmpParameters.Clear();
testItemStack.Clear();
TestRoundID = Guid.NewGuid(); TestRoundID = Guid.NewGuid();
} }
int initialLoopStackCount = loopStack.Count; int initialLoopStackCount = loopStack.Count;
@@ -381,14 +388,24 @@ namespace UIShare
SubProgram = step.SubProgram, SubProgram = step.SubProgram,
StepName = step.Name StepName = step.Name
}); });
bool isTestItemStep = step.IsTestItem;
if (isTestItemStep)
{
testItemStack.Push(new TestItemContext { Name = step.Name ?? "未命名测试项" });
}
stepSuccess = await ExecuteSteps(step.SubProgram, depth + 1, cancellationToken); stepSuccess = await ExecuteSteps(step.SubProgram, depth + 1, cancellationToken);
// 先评估本步骤自身(含自身 OKExpression 判断,归属本测试项),再写测试项汇总并弹栈
UpdateCurrentStepResult(step, true, stepSuccess, depth);
if (isTestItemStep)
{
await FinalizeTestItemAsync(depth);
}
// 发布退出子程序导航事件 // 发布退出子程序导航事件
_eventAggregator.GetEvent<SubProgramNavigateEvent>().Publish(new SubProgramNavigatePayload _eventAggregator.GetEvent<SubProgramNavigateEvent>().Publish(new SubProgramNavigatePayload
{ {
Scope = _systemConfig.Title, Scope = _systemConfig.Title,
Action = NavigateAction.Exit Action = NavigateAction.Exit
}); });
UpdateCurrentStepResult(step, true, stepSuccess, depth);
if (SubSingleStep) if (SubSingleStep)
{ {
SubSingleStep = false; SubSingleStep = false;
@@ -812,12 +829,46 @@ namespace UIShare
paraDic.TryAdd(item.Name, item.Value!); paraDic.TryAdd(item.Name, item.Value!);
} }
} }
bool re = ExpressionEvaluator.EvaluateExpression(step.OKExpression, paraDic); bool re;
try
{
re = ExpressionEvaluator.EvaluateExpression(step.OKExpression, paraDic);
}
catch (Exception ex)
{
// 表达式执行错误也视为 NG,并记录到测试项判断明细
LoggerHelper.ErrorWithNotify(_systemConfig.Title, $"指令 [ {step.Index} ] OKExpression 执行异常: {ex.Message}", depth: depth);
re = false;
}
step.Result = re ? 1 : 2; step.Result = re ? 1 : 2;
if (step.Result == 2) if (step.Result == 2)
{ {
LoggerHelper.WarnWithNotify(_systemConfig.Title, $"指令 [ {step.Index} ] NG:条件表达式验证失败", depth: depth); LoggerHelper.WarnWithNotify(_systemConfig.Title, $"指令 [ {step.Index} ] NG:条件表达式验证失败", depth: depth);
} }
// 测试项范围内:记录本次判断(重复判断逐次记录)
if (testItemStack.Count > 0)
{
var ctx = testItemStack.Peek();
bool isSelfCheck = ctx.Name == (step.Name ?? "未命名测试项") && step.SubProgram != null;
if (!re) ctx.HasFailure = true;
if (!isSelfCheck)
{
SaveCheckRecordAsync(new TestCheckRecordEntity
{
TestRoundId = TestRoundID,
Scope = _systemConfig.Title,
FileName = _systemConfig.CurrentADPFile ?? "",
TestItemName = ctx.Name,
StepName = step.Name ?? "",
Depth = depth,
OKExpression = step.OKExpression,
Pass = re,
Values = ExtractExpressionValues(step.OKExpression, paraDic),
IsSummary = false,
CreateTime = DateTime.Now
});
}
}
} }
} }
else else
@@ -828,6 +879,90 @@ namespace UIShare
} }
step.Result = 2; step.Result = 2;
} }
// 测试项范围内的步骤执行错误/表达式 NG 均计入当前测试项汇总(自身步骤的错误已在压栈期间计入)
if (step.Result == 2 && testItemStack.Count > 0)
{
testItemStack.Peek().HasFailure = true;
}
}
/// <summary>
/// 测试项执行结束:写入汇总记录(PASS/NG)并弹栈。
/// 测试项内无任何判断时,结果由范围内步骤执行成败决定。
/// </summary>
private async Task FinalizeTestItemAsync(int depth)
{
if (testItemStack.Count == 0) return;
var ctx = testItemStack.Pop();
SaveCheckRecordAsync(new TestCheckRecordEntity
{
TestRoundId = TestRoundID,
Scope = _systemConfig.Title,
FileName = _systemConfig.CurrentADPFile ?? "",
TestItemName = ctx.Name,
StepName = ctx.Name,
Depth = depth,
OKExpression = null,
Pass = !ctx.HasFailure,
Values = null,
IsSummary = true,
CreateTime = DateTime.Now
});
await Task.CompletedTask;
}
/// <summary>
/// 保存测试项判断记录(不阻塞执行主流程)
/// </summary>
private void SaveCheckRecordAsync(TestCheckRecordEntity entity)
{
_ = Task.Run(async () =>
{
try
{
var result = await _testCheckRecordService.InsertAsync(entity);
if (!result.IsSuccess)
{
LoggerHelper.Error($"保存测试项判断记录失败 [{entity.TestItemName}]: {result.Msg}");
}
}
catch (Exception ex)
{
LoggerHelper.Error($"保存测试项判断记录失败 [{entity.TestItemName}]: {ex.Message}");
}
});
}
/// <summary>
/// 从表达式中提取实际出现的变量并取其当前值,拼接为 "变量=值; " 格式(长名优先,避免子串误匹配)
/// </summary>
private static string? ExtractExpressionValues(string expression, Dictionary<string, object> paraDic)
{
try
{
var sb = new StringBuilder();
var matchedSpans = new List<(int Start, int End)>();
foreach (var name in paraDic.Keys.OrderByDescending(k => k.Length))
{
if (string.IsNullOrWhiteSpace(name)) continue;
foreach (Match m in Regex.Matches(expression, $@"\b{Regex.Escape(name)}\b"))
{
bool overlaps = matchedSpans.Any(s => m.Index < s.End && m.Index + m.Length > s.Start);
if (!overlaps)
{
matchedSpans.Add((m.Index, m.Index + m.Length));
sb.Append($"{name}={paraDic[name]}; ");
break;
}
}
}
return sb.Length > 0 ? sb.ToString() : null;
}
catch
{
return null;
}
} }
public void Dispose() public void Dispose()
@@ -861,6 +996,7 @@ namespace UIShare
tmpParameters.Clear(); tmpParameters.Clear();
loopStack.Clear(); loopStack.Clear();
loopStopwatchStack.Clear(); loopStopwatchStack.Clear();
testItemStack.Clear();
stepStopwatch.Stop(); stepStopwatch.Stop();
} }
@@ -876,6 +1012,13 @@ namespace UIShare
public StepVM? LoopStartStep { get; set; } public StepVM? LoopStartStep { get; set; }
} }
/// <summary>测试项执行上下文:记录测试项名称与范围内是否出现过失败</summary>
private class TestItemContext
{
public string Name { get; set; } = string.Empty;
public bool HasFailure { get; set; }
}
#endregion #endregion
} }
+10
View File
@@ -26,6 +26,7 @@ namespace UIShare.UIViewModel
NGGotoStepID = source.NGGotoStepID; NGGotoStepID = source.NGGotoStepID;
Description = source.Description; Description = source.Description;
IsUsed = source.IsUsed; IsUsed = source.IsUsed;
IsTestItem = source.IsTestItem;
if (source.Method != null) if (source.Method != null)
{ {
@@ -55,6 +56,15 @@ namespace UIShare.UIViewModel
set => SetProperty(ref _isUsed, value); set => SetProperty(ref _isUsed, value);
} }
private bool _isTestItem = false;
/// <summary>是否测试项(仅子程序步骤可标记,运行时记录其范围内所有 OKExpression 判断)</summary>
public bool IsTestItem
{
get => _isTestItem;
set => SetProperty(ref _isTestItem, value);
}
private int _index; private int _index;
public int Index public int Index