瀏覽代碼

feat(d2-02-t3): operate DebugSessionClient(acquire/command/release/心跳定时器/失效回调)+AcquireResult+4单测(链入control测试工程)

huangjie 3 天之前
父節點
當前提交
9b667f5ded

+ 82 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionClientTests.cs

@@ -0,0 +1,82 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using ivf_tl_Operate.Debug;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    /// <summary>
+    /// operate 端 DebugSessionClient 纯逻辑单测:用 fake HttpMessageHandler 注入响应,
+    /// 验证 acquire 解析(含培养态)/SESSION_EXPIRED 触发失效回调/release 幂等/心跳定时器。
+    /// </summary>
+    public class DebugSessionClientTests
+    {
+        private sealed class FakeHandler : HttpMessageHandler
+        {
+            public Func<HttpRequestMessage, (HttpStatusCode, string)> Responder;
+            public int HeartbeatCount;
+            protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
+            {
+                if (req.RequestUri.AbsolutePath.Contains("heartbeat")) Interlocked.Increment(ref HeartbeatCount);
+                var (code, body) = Responder(req);
+                return Task.FromResult(new HttpResponseMessage(code) { Content = new StringContent(body) });
+            }
+        }
+
+        [Fact]
+        public async Task Acquire_ParsesSessionIdAndCultivation()
+        {
+            var h = new FakeHandler { Responder = _ => (HttpStatusCode.OK, "{\"ok\":true,\"result\":\"sid123\",\"cultivating\":true,\"embryoCount\":3}") };
+            var c = new DebugSessionClient("http://127.0.0.1:9/", new HttpClient(h));
+            var r = await c.AcquireAsync(6);
+            Assert.True(r.Ok);
+            Assert.Equal("sid123", r.SessionId);
+            Assert.True(r.Cultivating);
+            Assert.Equal(3, r.EmbryoCount);
+            await c.ReleaseAsync();
+        }
+
+        [Fact]
+        public async Task Command_SessionExpired_FiresOnSessionExpired()
+        {
+            bool fired = false;
+            var h = new FakeHandler
+            {
+                Responder = req => req.RequestUri.AbsolutePath.Contains("command")
+                    ? (HttpStatusCode.Gone, "{\"ok\":false,\"code\":\"SESSION_EXPIRED\",\"error\":\"过期\"}")
+                    : (HttpStatusCode.OK, "{\"ok\":true,\"result\":\"sid123\"}")
+            };
+            var c = new DebugSessionClient("http://127.0.0.1:9/", new HttpClient(h));
+            c.OnSessionExpired = () => fired = true;
+            await c.AcquireAsync(6);
+            var r = await c.CommandAsync("ReadTemp", null);
+            Assert.False(r.Ok);
+            Assert.Equal("SESSION_EXPIRED", r.Code);
+            Assert.True(fired);
+        }
+
+        [Fact]
+        public async Task Release_IsIdempotent_NoThrowWhenCalledTwice()
+        {
+            var h = new FakeHandler { Responder = _ => (HttpStatusCode.OK, "{\"ok\":true,\"result\":\"sid123\"}") };
+            var c = new DebugSessionClient("http://127.0.0.1:9/", new HttpClient(h));
+            await c.AcquireAsync(6);
+            await c.ReleaseAsync();
+            await c.ReleaseAsync();   // 第二次不抛
+        }
+
+        [Fact]
+        public async Task Heartbeat_TimerSendsAfterAcquire()
+        {
+            var h = new FakeHandler { Responder = _ => (HttpStatusCode.OK, "{\"ok\":true,\"result\":\"sid123\"}") };
+            var c = new DebugSessionClient("http://127.0.0.1:9/", new HttpClient(h)) { HeartbeatIntervalMs = 50 };
+            await c.AcquireAsync(6);
+            await Task.Delay(180);
+            await c.ReleaseAsync();
+            Assert.True(h.HeartbeatCount >= 2);   // 50ms 间隔 180ms 内至少 2 次
+        }
+    }
+}

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

@@ -17,5 +17,8 @@
   <ItemGroup>
     <!-- operate 的纯逻辑 MjpegFrameParser 链入源码做单测(operate 主工程是 WPF 无单测工程)。 -->
     <Compile Include="..\..\ivf_tl_Operate\Debug\MjpegFrameParser.cs" Link="Linked\MjpegFrameParser.cs" />
+    <!-- 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" />
   </ItemGroup>
 </Project>

+ 21 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/AcquireResult.cs

@@ -0,0 +1,21 @@
+using Newtonsoft.Json;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// acquire/command/release/heartbeat 的统一响应 DTO(对齐 control 端 DebugCommandResult 的 JSON)。
+    /// acquire 成功时 Result=sessionId 字符串;cultivating/embryoCount 仅 acquire 回带(供调试前培养态确认框)。
+    /// </summary>
+    public class AcquireResult
+    {
+        [JsonProperty("ok")] public bool Ok { get; set; }
+        [JsonProperty("result")] public object Result { get; set; }
+        [JsonProperty("error")] public string Error { get; set; }
+        [JsonProperty("code")] public string Code { get; set; }
+        [JsonProperty("cultivating")] public bool Cultivating { get; set; }
+        [JsonProperty("embryoCount")] public int EmbryoCount { get; set; }
+
+        /// <summary>acquire 时 control 把 sessionId 放在 result 字段。</summary>
+        public string SessionId => Result?.ToString();
+    }
+}

+ 98 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/Debug/DebugSessionClient.cs

@@ -0,0 +1,98 @@
+using System;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// operate 端调试会话客户端:封 acquire/command/release/heartbeat 的本地 HTTP + 心跳定时器 + 会话失效回调。
+    /// 一次会话:AcquireAsync 成功起心跳,ReleaseAsync 停心跳。
+    /// 任一请求返回 code=SESSION_EXPIRED(control 端 TTL 已回收该会话)→ 触发 OnSessionExpired(UI 据此提示"请重新进入调试")并停心跳。
+    /// 心跳是必需的:control 端 10s TTL 看门狗收不到心跳会自动回收会话、把借用的舱还回去恢复采集。
+    /// </summary>
+    public sealed class DebugSessionClient : IDisposable
+    {
+        private readonly string _baseUrl;
+        private readonly HttpClient _http;
+        private string _sessionId;
+        private Timer _heartbeatTimer;
+        private volatile bool _expiredFired;
+
+        /// <summary>心跳间隔(ms),默认 2.5s(远小于 control 端 10s TTL,容忍偶发卡顿)。</summary>
+        public int HeartbeatIntervalMs { get; set; } = 2500;
+
+        /// <summary>会话失效回调(收到 SESSION_EXPIRED 时触发一次)。</summary>
+        public Action OnSessionExpired { get; set; }
+
+        public string SessionId => _sessionId;
+
+        public DebugSessionClient(string baseUrl, HttpClient http = null)
+        {
+            _baseUrl = baseUrl.TrimEnd('/');
+            _http = http ?? new HttpClient();
+        }
+
+        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();
+            var r = JsonConvert.DeserializeObject<AcquireResult>(s) ?? new AcquireResult { Ok = false, Error = "空响应" };
+            if (r.Code == "SESSION_EXPIRED" && !_expiredFired)
+            {
+                _expiredFired = true;
+                StopHeartbeat();
+                try { OnSessionExpired?.Invoke(); } catch { }
+            }
+            return r;
+        }
+
+        /// <summary>借用某舱。成功则记录 sessionId 并起心跳。返回体含 cultivating/embryoCount(供��认框)。</summary>
+        public async Task<AcquireResult> AcquireAsync(int houseSn)
+        {
+            var r = await PostAsync("/debug/acquire", new { houseSn });
+            if (r.Ok && !string.IsNullOrEmpty(r.SessionId))
+            {
+                _sessionId = r.SessionId;
+                _expiredFired = false;
+                StartHeartbeat();
+            }
+            return r;
+        }
+
+        /// <summary>在当前会话上执行一个操作(电机/读数/阀门等)。</summary>
+        public Task<AcquireResult> CommandAsync(string op, object args)
+            => PostAsync("/debug/command", new { sessionId = _sessionId, op, args });
+
+        /// <summary>归还会话(幂等):停心跳、清 sessionId、通知 control。多次调用不抛。</summary>
+        public async Task ReleaseAsync()
+        {
+            StopHeartbeat();
+            var sid = _sessionId;
+            _sessionId = null;
+            if (sid != null) { try { await PostAsync("/debug/release", new { sessionId = sid }); } catch { } }
+        }
+
+        private void StartHeartbeat()
+        {
+            StopHeartbeat();
+            _heartbeatTimer = new Timer(async _ =>
+            {
+                var sid = _sessionId;
+                if (sid == null) return;
+                try { await PostAsync("/debug/heartbeat", new { sessionId = sid }); } catch { }
+            }, null, HeartbeatIntervalMs, HeartbeatIntervalMs);
+        }
+
+        private void StopHeartbeat()
+        {
+            try { _heartbeatTimer?.Dispose(); } catch { }
+            _heartbeatTimer = null;
+        }
+
+        public void Dispose() { StopHeartbeat(); }
+    }
+}