Browse Source

feat(d2-02-t3): operate CalibrationClient(start/progress/recalibrate/stop封装+进度DTO)+单测(TDD,链入control测试工程)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 2 days ago
parent
commit
a5ac2b4e98

+ 114 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/CalibrationClientTests.cs

@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using ivf_tl_Operate.Debug;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    /// <summary>
+    /// operate 端 CalibrationClient 纯逻辑单测:用 fake HttpMessageHandler 注入 control 端
+    /// /debug/calibrate/* 的响应,验证四个端点的路径/请求体/解析。
+    /// 进度响应是 control 端 JsonConvert.SerializeObject(DebugCommandResult)——result 内嵌
+    /// CalibProgress(PascalCase 字段,WellCalibState 枚举默认序列化为数字)。
+    /// </summary>
+    public class CalibrationClientTests
+    {
+        private sealed class FakeHandler : HttpMessageHandler
+        {
+            public Func<HttpRequestMessage, string, (HttpStatusCode, string)> Responder;
+            public string LastPath;
+            public string LastBody;
+            protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
+            {
+                LastPath = req.RequestUri.AbsolutePath;
+                LastBody = req.Content != null ? await req.Content.ReadAsStringAsync() : "";
+                var (code, body) = Responder(req, LastBody);
+                return new HttpResponseMessage(code) { Content = new StringContent(body) };
+            }
+        }
+
+        private const string Sid = "sid123";
+
+        [Fact]
+        public async Task StartAsync_PostsToStart_WithSessionIdAndWells_ParsesOk()
+        {
+            var h = new FakeHandler { Responder = (_, __) => (HttpStatusCode.OK, "{\"ok\":true}") };
+            var c = new CalibrationClient("http://127.0.0.1:9/", Sid, new HttpClient(h));
+            var r = await c.StartAsync(new[] { 1, 2, 3 });
+            Assert.True(r.Ok);
+            Assert.Equal("/debug/calibrate/start", h.LastPath);
+            var jo = JObject.Parse(h.LastBody);
+            Assert.Equal(Sid, jo["sessionId"]?.ToString());
+            Assert.Equal(new[] { 1, 2, 3 }, ((JArray)jo["wells"]).ToObject<int[]>());
+        }
+
+        [Fact]
+        public async Task PollProgressAsync_ParsesCalibProgress_TotalCompletedIsRunning_AndWells()
+        {
+            // control 端真实形态:DebugCommandResult{ok, result=CalibProgress}(PascalCase,枚举=数字)。
+            string body =
+                "{\"ok\":true,\"result\":{" +
+                "\"Total\":16,\"Completed\":2,\"CurrentWell\":3,\"IsRunning\":true,\"Wells\":[" +
+                "{\"WellSn\":1,\"State\":2,\"FocusZ\":90000,\"Exposure\":120,\"PeakRatio\":1.8,\"CenterOffsetPct\":0.5,\"CircleFound\":true,\"Note\":\"\"}," +
+                "{\"WellSn\":2,\"State\":3,\"FocusZ\":0,\"Exposure\":0,\"PeakRatio\":1.0,\"CenterOffsetPct\":0.0,\"CircleFound\":false,\"Note\":\"伪峰\"}" +
+                "]}}";
+            var h = new FakeHandler { Responder = (_, __) => (HttpStatusCode.OK, body) };
+            var c = new CalibrationClient("http://127.0.0.1:9/", Sid, new HttpClient(h));
+            var p = await c.PollProgressAsync();
+            Assert.Equal("/debug/calibrate/progress", h.LastPath);
+            Assert.Equal(Sid, JObject.Parse(h.LastBody)["sessionId"]?.ToString());
+            Assert.NotNull(p);
+            Assert.Equal(16, p.Total);
+            Assert.Equal(2, p.Completed);
+            Assert.Equal(3, p.CurrentWell);
+            Assert.True(p.IsRunning);
+            Assert.Equal(2, p.Wells.Count);
+            Assert.Equal(1, p.Wells[0].WellSn);
+            Assert.Equal(2, p.Wells[0].State);          // Qualified=2
+            Assert.Equal(90000, p.Wells[0].FocusZ);
+            Assert.True(p.Wells[0].CircleFound);
+            Assert.Equal(1.8, p.Wells[0].PeakRatio, 3);
+            Assert.Equal(3, p.Wells[1].State);          // FakePeak=3
+            Assert.Equal("伪峰", p.Wells[1].Note);
+        }
+
+        [Fact]
+        public async Task RecalibrateAsync_PostsToRecalibrate_WithWellSn()
+        {
+            var h = new FakeHandler { Responder = (_, __) => (HttpStatusCode.OK, "{\"ok\":true,\"result\":true}") };
+            var c = new CalibrationClient("http://127.0.0.1:9/", Sid, new HttpClient(h));
+            var r = await c.RecalibrateAsync(7);
+            Assert.True(r.Ok);
+            Assert.Equal("/debug/calibrate/recalibrate", h.LastPath);
+            var jo = JObject.Parse(h.LastBody);
+            Assert.Equal(Sid, jo["sessionId"]?.ToString());
+            Assert.Equal(7, (int)jo["wellSn"]);
+        }
+
+        [Fact]
+        public async Task StopAsync_PostsToStop_WithSessionId()
+        {
+            var h = new FakeHandler { Responder = (_, __) => (HttpStatusCode.OK, "{\"ok\":true}") };
+            var c = new CalibrationClient("http://127.0.0.1:9/", Sid, new HttpClient(h));
+            var r = await c.StopAsync();
+            Assert.True(r.Ok);
+            Assert.Equal("/debug/calibrate/stop", h.LastPath);
+            Assert.Equal(Sid, JObject.Parse(h.LastBody)["sessionId"]?.ToString());
+        }
+
+        [Fact]
+        public async Task SessionExpired_CodeIsReadable()
+        {
+            var h = new FakeHandler { Responder = (_, __) => (HttpStatusCode.Gone, "{\"ok\":false,\"code\":\"SESSION_EXPIRED\",\"error\":\"会话不存在或已过期\"}") };
+            var c = new CalibrationClient("http://127.0.0.1:9/", Sid, new HttpClient(h));
+            var r = await c.StartAsync();
+            Assert.False(r.Ok);
+            Assert.Equal("SESSION_EXPIRED", r.Code);
+        }
+    }
+}

+ 2 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj

@@ -20,5 +20,7 @@
     <!-- D2-02 第三阶段:operate 纯逻辑 DebugSessionClient + AcquireResult 链入做单测(零 WPF 依赖)。 -->
     <Compile Include="..\..\ivf_tl_Operate\Debug\DebugSessionClient.cs" Link="Linked\DebugSessionClient.cs" />
     <Compile Include="..\..\ivf_tl_Operate\Debug\AcquireResult.cs" Link="Linked\AcquireResult.cs" />
+    <Compile Include="..\..\ivf_tl_Operate\Debug\CalibrationClient.cs" Link="Linked\CalibrationClient.cs" />
+    <Compile Include="..\..\ivf_tl_Operate\Debug\CalibProgressDto.cs" Link="Linked\CalibProgressDto.cs" />
   </ItemGroup>
 </Project>

+ 35 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/CalibProgressDto.cs

@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// operate 端标定进度反序列化 DTO,对齐 control 端 CalibProgress(Task3.1/3.2a)的 JSON。
+    /// control 端 CalibProgress/WellCalibProgress 是公有字段、用 JsonConvert.SerializeObject 默认序列化,
+    /// 故属性名为 PascalCase(Total/Wells/WellSn...);WellCalibState 枚举无 StringEnumConverter,
+    /// 默认序列化为【数字】(Pending=0 Running=1 Qualified=2 FakePeak=3 Failed=4),故 State 用 int 接。
+    /// 取法:轮询 /debug/calibrate/progress 返回 DebugCommandResult{ok,result=CalibProgress},
+    /// 本 DTO 对应 result 内嵌对象,由 CalibrationClient 经 envelope 取出。
+    /// </summary>
+    public sealed class CalibProgressDto
+    {
+        [JsonProperty("Total")] public int Total { get; set; }
+        [JsonProperty("Completed")] public int Completed { get; set; }
+        [JsonProperty("CurrentWell")] public int? CurrentWell { get; set; }
+        [JsonProperty("IsRunning")] public bool IsRunning { get; set; }
+        [JsonProperty("Wells")] public List<WellCalibProgressDto> Wells { get; set; } = new List<WellCalibProgressDto>();
+    }
+
+    /// <summary>单 well 进度(对齐 control 端 WellCalibProgress);State 接数字枚举值。</summary>
+    public sealed class WellCalibProgressDto
+    {
+        [JsonProperty("WellSn")] public int WellSn { get; set; }
+        [JsonProperty("State")] public int State { get; set; }   // WellCalibState:0待标定/1标定中/2合格/3伪峰/4失败
+        [JsonProperty("FocusZ")] public int FocusZ { get; set; }
+        [JsonProperty("Exposure")] public int Exposure { get; set; }
+        [JsonProperty("PeakRatio")] public double PeakRatio { get; set; }
+        [JsonProperty("CenterOffsetPct")] public double CenterOffsetPct { get; set; }
+        [JsonProperty("CircleFound")] public bool CircleFound { get; set; }
+        [JsonProperty("Note")] public string Note { get; set; } = "";
+    }
+}

+ 78 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/CalibrationClient.cs

@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// operate 端"16 孔标定协作"客户端:封装 control 的 /debug/calibrate/* 四端点(start/progress/recalibrate/stop)
+    /// + 轮询进度。复用 operate 已借到的 sessionId(由 DebugSessionClient.Acquire 取得),全程同一会话。
+    /// 纯逻辑可单测:HttpClient 可注入(测试用 FakeHandler);http=null 则自建并自 Dispose(对齐 DebugSessionClient)。
+    /// start/recalibrate/stop 返回 AcquireResult(对齐 control DebugCommandResult:Ok/Result/Error/Code);
+    /// progress 返回 CalibProgressDto(从 DebugCommandResult.result 内嵌对象反序列化)。
+    /// 实时画面复用 MjpegStreamClient(不在本类)。
+    /// </summary>
+    public sealed class CalibrationClient : IDisposable
+    {
+        private readonly string _baseUrl;
+        private readonly string _sessionId;
+        private readonly HttpClient _http;
+        private readonly bool _ownsHttp;
+
+        public CalibrationClient(string baseUrl, string sessionId, HttpClient http = null)
+        {
+            _baseUrl = baseUrl.TrimEnd('/');
+            _sessionId = sessionId;
+            _ownsHttp = http == null;          // 自建的由本类 Dispose;注入的归调用方
+            _http = http ?? new HttpClient();
+        }
+
+        // /progress 的 result 内嵌 CalibProgress,用 envelope 把外层 ok/code 与内层 result 一并取出。
+        private sealed class ProgressEnvelope
+        {
+            [JsonProperty("ok")] public bool Ok { get; set; }
+            [JsonProperty("code")] public string Code { get; set; }
+            [JsonProperty("error")] public string Error { get; set; }
+            [JsonProperty("result")] public CalibProgressDto Result { get; set; }
+        }
+
+        private async Task<AcquireResult> PostAsync(string path, object body)
+        {
+            var content = new StringContent(body == null ? "{}" : JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
+            var resp = await _http.PostAsync($"{_baseUrl}{path}", content);
+            string s = await resp.Content.ReadAsStringAsync();
+            return JsonConvert.DeserializeObject<AcquireResult>(s) ?? new AcquireResult { Ok = false, Error = "空响应" };
+        }
+
+        /// <summary>起一次 16 孔标定。wells 为空则 control 端默认 1..16。</summary>
+        public Task<AcquireResult> StartAsync(IEnumerable<int> wells = null)
+            => PostAsync("/debug/calibrate/start", new { sessionId = _sessionId, wells = wells?.ToArray() });
+
+        /// <summary>轮询进度:返回 CalibProgressDto(无进度/会话失效则返回 null)。</summary>
+        public async Task<CalibProgressDto> PollProgressAsync()
+        {
+            var content = new StringContent(JsonConvert.SerializeObject(new { sessionId = _sessionId }), Encoding.UTF8, "application/json");
+            var resp = await _http.PostAsync($"{_baseUrl}/debug/calibrate/progress", content);
+            string s = await resp.Content.ReadAsStringAsync();
+            var env = JsonConvert.DeserializeObject<ProgressEnvelope>(s);
+            return env?.Result;   // ok=false(SESSION_EXPIRED 等)时 result 缺省 → null
+        }
+
+        /// <summary>单孔重标。</summary>
+        public Task<AcquireResult> RecalibrateAsync(int wellSn)
+            => PostAsync("/debug/calibrate/recalibrate", new { sessionId = _sessionId, wellSn });
+
+        /// <summary>中止标定。</summary>
+        public Task<AcquireResult> StopAsync()
+            => PostAsync("/debug/calibrate/stop", new { sessionId = _sessionId });
+
+        public void Dispose()
+        {
+            if (_ownsHttp) { try { _http?.Dispose(); } catch { } }   // 只释放自建的;注入的归调用方
+        }
+    }
+}