2026-06-23-HIL硬件在环回归套件实现计划.md 17 KB

HIL 硬件在环回归套件 实现计划

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()


Task 1: 工程骨架 + 加入 sln + 编译通过

Files:

  • Create: ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj
  • Create: ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/AssemblyInfo.cs
  • Modify: 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.

  • Step 4: 编译验证(含 NuGet restore SkippableFact)

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 回退分支)。

  • Step 5: 提交点(暂不单独 commit,按 §3.4 末尾合并提交)

Task 2: HardwareRigFixture(扫口握手收集响应真舱)+ collection

Files:

  • Create: ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/Infrastructure/HardwareRigFixture.cs
  • Create: 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] + 早返回。


Task 3: FocusZeroHilTests(M-06 按 well 焦点零点,纯读)

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)


Task 4: FrameLengthHilTests(M-05 连续读干净度,纯读)

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)


Task 5: EepromWriteHilTests(M-01/02/03 写回环,默认 Skip)

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)


Task 6: 真机跑 + README + 双编译 + 文档同步 + 提交

Files:

  • Create: ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/README.md
  • Modify: 文档同步(见 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。记录输出。

  • Step 2: 开写开关重跑,验证写回环(写后恢复原值)

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(读回随写变化、值已恢复)。记录输出。

  • Step 3: 写 README.md

内容:套件用途(防 M-05/M-06/M-01-03 回归)、运行前提(control 未占串口、operate 未跑)、dotnet test 默认零写入、HIL_ALLOW_WRITE=1 开写回环、Skip 语义(无真舱即 Skip 非 Fail)、非破坏安全说明。

  • Step 4: 双编译 0 错 + 回归既有单测 + codegraph sync

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 过 / 索引同步。

  • Step 5: 文档同步(提交边界=文档已对齐)

更新:工作计划表.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+默认零写入)"
    

自检(Self-Review)

  • Spec 覆盖:形态(xUnit 门控)=Task1-2;M-06=Task3;M-05=Task4;M-01/02/03 写开关=Task5;不收录 FrameLenProbe=已述;README+验收+文档同步=Task6。✓ 全覆盖。
  • 占位扫描:无 TBD;每个代码步骤含完整源码。✓
  • 类型一致:HardwareRigFixture.Chambers/FirstChamberWithWells()/ChamberInfo.{Port,HouseSn} 在 Task2 定义,Task3-5 一致引用;[SkippableFact]+Skip.If/IfNot 一致;方法签名对齐头部清单。✓