For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development 或 superpowers:executing-plans 逐任务实现。步骤用
- [ ]复选框跟踪。 关联设计:项目文档/需求文档/specs/2026-06-23-HIL硬件在环回归套件-design.md。
Goal: 新增永久入库的 xUnit HIL 测试工程 IvfTl.Hardware.HilTests,把 M-05/M-06/M-01-03 的真机检查固化为防回归用例;真机不在时优雅 Skip。
Architecture: net6.0-windows xUnit 工程,经真实 IvfTl.Hardware.Impl.SerialChannelImpl 端到端连真舱。collection fixture 扫口握手收集响应真舱,禁并行串行跑;[SkippableFact] + Skip.If 在无硬件/control 占口时 Skip。默认零写入,写回环用 HIL_ALLOW_WRITE=1 显式开关。
Tech Stack: C# / net6.0-windows / xUnit 2.4.2 / Xunit.SkippableFact / Microsoft.NET.Test.Sdk 17.6.0;SerialChannelImpl(System.IO.Ports)。
约定提醒: operate.exe 在跑会锁 DLL(MSB3021),编译前先确保 operate 未跑;control 占串口会致测试 Skip(预期行为)。真机方法签名:SerialChannelImpl(int index, string port) / bool Open() / int ShakeHandsWait() / int ReadOpenVentTimeWait() / bool WriteOpenVentTimeWait(int) / int ReadLightBrightnessWait() / bool WriteLightBrightnessWait(int) / int ReadWellFocusZeroWait(int well) / void Close()。
Files:
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csprojivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/AssemblyInfo.csModify: ivf_tl_operate_2.0/control/ivf_tl_Control.sln(加工程)
[ ] Step 1: 写 csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IvfTl.Hardware\IvfTl.Hardware.csproj" />
</ItemGroup>
</Project>
[ ] Step 2: 写 AssemblyInfo.cs(禁并行,防多测试抢 COM 口)
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]
[ ] Step 3: 加进 sln
Run: cd ivf_tl_operate_2.0/control && dotnet sln ivf_tl_Control.sln add IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj
Expected: Project ... added to the solution.
Run: dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
Expected: Build succeeded. 0 Error(s)。
若 Xunit.SkippableFact 离线 restore 失败:走回退——从 csproj 删该 PackageReference,Task 2 改用无依赖 [HilFact](见 Task 2 回退分支)。
Files:
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/Infrastructure/HardwareRigFixture.csCreate: ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/Infrastructure/HilCollection.cs
[ ] Step 1: 写 HardwareRigFixture.cs
using System;
using System.Collections.Generic;
using System.IO.Ports;
using IvfTl.Hardware.Impl;
namespace IvfTl.Hardware.HilTests.Infrastructure
{
/// <summary>一个有响应的真舱:串口 + 握手返回的舱号。</summary>
public sealed class ChamberInfo
{
public string Port { get; }
public int HouseSn { get; }
public ChamberInfo(string port, int houseSn) { Port = port; HouseSn = houseSn; }
}
/// <summary>
/// 全套件构造一次:扫描 COM 口、逐口 Open+握手,收集有响应的真舱。
/// 探测完即 Close 释放;各测试用时各自重开端口。
/// 无硬件 / control 正占用串口 → Chambers 为空 → 测试 Skip。
/// </summary>
public sealed class HardwareRigFixture : IDisposable
{
public IReadOnlyList<ChamberInfo> Chambers { get; }
public HardwareRigFixture()
{
var found = new List<ChamberInfo>();
string[] ports;
try { ports = SerialPort.GetPortNames(); } catch { ports = Array.Empty<string>(); }
foreach (var port in ports)
{
if (port == "COM1" || port == "COM2") continue; // 非舱口
SerialChannelImpl ch = null;
try
{
ch = new SerialChannelImpl(0, port);
if (!ch.Open()) continue;
int house = ch.ShakeHandsWait();
if (house < 0) continue;
found.Add(new ChamberInfo(port, house));
}
catch { /* 占口/无响应 → 不计入 */ }
finally { try { ch?.Close(); } catch { } }
}
Chambers = found;
}
/// <summary>取第一个有舱室 E方(排气阀/灯光/焦点)的真舱,排除舱11(缓冲瓶/总控)。</summary>
public ChamberInfo FirstChamberWithWells()
{
foreach (var c in Chambers) if (c.HouseSn != 11) return c;
return null;
}
public void Dispose() { }
}
}
[ ] Step 2: 写 HilCollection.cs
using Xunit;
namespace IvfTl.Hardware.HilTests.Infrastructure
{
[CollectionDefinition("HIL")]
public sealed class HilCollection : ICollectionFixture<HardwareRigFixture> { }
}
[ ] Step 3: 编译验证
Run: dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
Expected: Build succeeded. 0 Error(s)
回退分支(仅当 SkippableFact 不可用):在 Infrastructure 下加
HilFactAttribute.cs:public sealed class HilFactAttribute : FactAttribute {}(普通 Fact);各测试改用if (rig.Chambers.Count==0) { output.WriteLine("skip:无真舱"); return; }早返回(报 Passed)。后续 Task 的[SkippableFact]/Skip.If相应换成[HilFact]+ 早返回。
Files:
Create: ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/FocusZeroHilTests.cs
[ ] Step 1: 写测试
using System.Linq;
using System.Threading;
using IvfTl.Hardware.HilTests.Infrastructure;
using IvfTl.Hardware.Impl;
using Xunit;
using Xunit.Abstractions;
namespace IvfTl.Hardware.HilTests
{
[Collection("HIL")]
public class FocusZeroHilTests
{
private readonly HardwareRigFixture _rig;
private readonly ITestOutputHelper _out;
public FocusZeroHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
// M-06:按 well 读 Z 焦点零点。修前恒读 well-1(去重=1);修后各 well 分槽(去重>1)。纯 0x11 读,无电机。
[SkippableFact]
public void PerWellFocusZero_IsDistinctPerWell_AndWithinSafeZ()
{
var chamber = _rig.FirstChamberWithWells();
Skip.If(chamber == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
var vals = new int[17];
SerialChannelImpl ch = null;
try
{
ch = new SerialChannelImpl(0, chamber.Port);
Assert.True(ch.Open(), $"{chamber.Port} 打开失败");
for (int well = 1; well <= 16; well++)
{
vals[well] = ch.ReadWellFocusZeroWait(well);
Thread.Sleep(120);
}
}
finally { try { ch?.Close(); } catch { } }
var read = vals.Skip(1).ToArray();
_out.WriteLine($"舱{chamber.HouseSn} 各 well 焦点零点: {string.Join(",", read)}");
int distinct = read.Distinct().Count();
Assert.True(distinct > 1, $"按 well 读应各 well 不同(去重>1),实得去重={distinct}(=1 说明退回恒读 well-1=M-06 回归)");
Assert.All(read, v => Assert.InRange(v, 0, 125000)); // 安全 Z 区间
}
}
}
[ ] Step 2: 编译验证
Run: dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
Expected: Build succeeded. 0 Error(s)
Files:
Create: ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/FrameLengthHilTests.cs
[ ] Step 1: 写测试
using System.Threading;
using IvfTl.Hardware.HilTests.Infrastructure;
using IvfTl.Hardware.Impl;
using Xunit;
using Xunit.Abstractions;
namespace IvfTl.Hardware.HilTests
{
[Collection("HIL")]
public class FrameLengthHilTests
{
private readonly HardwareRigFixture _rig;
private readonly ITestOutputHelper _out;
public FrameLengthHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
// M-05:0x12 写 E方回包帧长修复为 12 后,读路径不被残留字节污染。
// 纯读变体:连续多轮读排气阀时间,断言全部 sane 非负(帧错位会出现 -1/垃圾)。
[SkippableFact]
public void RepeatedReads_AreClean_NoFrameCorruption()
{
var chamber = _rig.FirstChamberWithWells();
Skip.If(chamber == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
const int rounds = 12;
int clean = 0;
SerialChannelImpl ch = null;
try
{
ch = new SerialChannelImpl(0, chamber.Port);
Assert.True(ch.Open(), $"{chamber.Port} 打开失败");
for (int r = 0; r < rounds; r++)
{
int v = ch.ReadOpenVentTimeWait();
if (v >= 0) clean++;
else _out.WriteLine($"轮{r} 读={v}(脏/无响应)");
Thread.Sleep(80);
}
}
finally { try { ch?.Close(); } catch { } }
_out.WriteLine($"舱{chamber.HouseSn} 连续读 {rounds} 轮,干净 {clean}/{rounds}");
Assert.Equal(rounds, clean); // 帧长正确则每轮都 sane;有错位则出现 -1
}
}
}
[ ] Step 2: 编译验证
Run: dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
Expected: Build succeeded. 0 Error(s)
Files:
Create: ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/EepromWriteHilTests.cs
[ ] Step 1: 写测试
using System;
using System.Threading;
using IvfTl.Hardware.HilTests.Infrastructure;
using IvfTl.Hardware.Impl;
using Xunit;
using Xunit.Abstractions;
namespace IvfTl.Hardware.HilTests
{
[Collection("HIL")]
public class EepromWriteHilTests
{
private readonly HardwareRigFixture _rig;
private readonly ITestOutputHelper _out;
public EepromWriteHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
private static bool WriteAllowed =>
Environment.GetEnvironmentVariable("HIL_ALLOW_WRITE") == "1";
// M-01/02/03:写命令真下发到下位机(读回随写变化),写后立即恢复原值(非破坏)。
// 默认 Skip(零写入);仅 HIL_ALLOW_WRITE=1 才跑。
[SkippableFact]
public void WriteVentTime_RoundTrips_AndRestoresOriginal()
{
Skip.IfNot(WriteAllowed, "写路径默认关闭;设 HIL_ALLOW_WRITE=1 才跑(写后立即恢复原值)");
RoundTrip("排气阀时间(M-01写/M-02读)",
ch => ch.ReadOpenVentTimeWait(), (ch, v) => ch.WriteOpenVentTimeWait(v));
}
[SkippableFact]
public void WriteLightBrightness_RoundTrips_AndRestoresOriginal()
{
Skip.IfNot(WriteAllowed, "写路径默认关闭;设 HIL_ALLOW_WRITE=1 才跑(写后立即恢复原值)");
RoundTrip("灯光亮度(M-03写)",
ch => ch.ReadLightBrightnessWait(), (ch, v) => ch.WriteLightBrightnessWait(v));
}
private void RoundTrip(string label, Func<SerialChannelImpl, int> read, Action<SerialChannelImpl, int> write)
{
var chamber = _rig.FirstChamberWithWells();
Skip.If(chamber == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
SerialChannelImpl ch = null;
try
{
ch = new SerialChannelImpl(0, chamber.Port);
Assert.True(ch.Open(), $"{chamber.Port} 打开失败");
// 读前丢弃一帧 + 间隔排空(隔离任何尾噪声),拿稳定原值
int ReadStable() { try { read(ch); } catch { } Thread.Sleep(700); int a = read(ch); Thread.Sleep(700); return a; }
int v0 = ReadStable();
_out.WriteLine($"[{label}] 舱{chamber.HouseSn} 原值 V0={v0}");
Skip.If(v0 < 0, $"[{label}] 原值读不到(该舱无此项),跳过");
int target = v0 + 1;
write(ch, target); Thread.Sleep(700);
int v1 = ReadStable();
_out.WriteLine($"[{label}] 写 {target} 后读回={v1}");
write(ch, v0); Thread.Sleep(700); // 恢复原值
int v2 = ReadStable();
_out.WriteLine($"[{label}] 写回 {v0} 后读回={v2}");
Assert.Equal(target, v1); // 写真下发:读回随写变化
Assert.Equal(v0, v2); // 已恢复原值:非破坏
}
finally { try { ch?.Close(); } catch { } }
}
}
}
[ ] Step 2: 编译验证
Run: dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
Expected: Build succeeded. 0 Error(s)
Files:
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/README.mdModify: 文档同步(见 Step 4)
[ ] Step 1: 确认 control/operate 未占串口,真机跑 HIL(默认零写入)
Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
Expected:FocusZero/FrameLength 两测试 Passed(真验证);3 个写测试 Skipped(默认关)。若真机不在/被占 → 全 Skipped。记录输出。
Run(bash):HIL_ALLOW_WRITE=1 dotnet test ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
Expected:写回环 2 测试 Passed(读回随写变化、值已恢复)。记录输出。
内容:套件用途(防 M-05/M-06/M-01-03 回归)、运行前提(control 未占串口、operate 未跑)、dotnet test 默认零写入、HIL_ALLOW_WRITE=1 开写回环、Skip 语义(无真舱即 Skip 非 Fail)、非破坏安全说明。
Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_Control.sln -c Debug(control 全 sln)
Run: dotnet build ivf_tl_operate_2.0/ivf_tl_Operate.sln -c Release(operate Release,先确保 operate.exe 未跑)
Run: dotnet test ivf_tl_operate_2.0/control/ivf_tl_SerialHelper.Tests/ivf_tl_SerialHelper.Tests.csproj(既有 40 单测仍过)
Run: codegraph sync
Expected:全 0 错 / 40 过 / 索引同步。
更新:工作计划表.md(阶段3 加 HIL 套件入库行)、进度状态.yaml(覆盖断点)、交接卡.md(追加本次)、待验证清单.md(M-05/M-06/M-01-03 标注已有入库 HIL 守护)、进度数据.js(面板)。
[ ] Step 6: 提交
git add ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests ivf_tl_operate_2.0/control/ivf_tl_Control.sln 项目文档/
git commit -m "test(hil): 新增硬件在环回归套件 IvfTl.Hardware.HilTests(守护 M-05帧长/M-06按well焦点/M-01-03 EEPROM写;门控Skip+默认零写入)"
HardwareRigFixture.Chambers/FirstChamberWithWells()/ChamberInfo.{Port,HouseSn} 在 Task2 定义,Task3-5 一致引用;[SkippableFact]+Skip.If/IfNot 一致;方法签名对齐头部清单。✓