From 559890c482f5973f06aafdfec9550fee5ba3c7f2 Mon Sep 17 00:00:00 2001
From: wenxiangchao <wenxiangchao@ihengxin.com.cn>
Date: 星期一, 09 十一月 2026 17:02:15 +0800
Subject: [PATCH] feat: CAN 模拟器 TCP 化 + 多设备并发调试 + 后台生命周期管理

---
 tests/Aging.MvpTests/BrokerE2ETests.cs |  334 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 334 insertions(+), 0 deletions(-)

diff --git a/tests/Aging.MvpTests/BrokerE2ETests.cs b/tests/Aging.MvpTests/BrokerE2ETests.cs
new file mode 100644
index 0000000..dcd0d15
--- /dev/null
+++ b/tests/Aging.MvpTests/BrokerE2ETests.cs
@@ -0,0 +1,334 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Aging.Infrastructure.Broker;
+using Aging.Infrastructure.Core;
+using Aging.Infrastructure.Drivers;
+using Aging.ResourceContracts;
+using CanSimulatorServer;
+using DeviceBroker;
+using Xunit;
+
+namespace Aging.MvpTests
+{
+    /// <summary>
+    /// Broker 鍏ㄩ摼璺� e2e锛氬鎴风锛坅rb.db 绉熺害 + CanDeviceDriver BrokerRpc 浠g悊锛夆啍
+    /// DeviceBroker 瀹夸富锛堝懡鍚嶇閬� + 鏍呮爮鍐嶆牎楠� + configHash 瀵硅处 + SessionGate 鍘熷瓙鐗囨锛夈��
+    /// 瀹夸富鐨勭墿鐞� CAN 椹卞姩缁� TCP 杩炶繘绋嬪唴鎵樼鐨勬ā鎷熷櫒鏈嶅姟锛堝浐瀹氱鍙� 19555锛屼笌 App.config 涓�鑷达級銆�
+    /// 姣忎釜娴嬭瘯浣跨敤鍞竴 brokerId 鈫� 鐙珛绠¢亾鍚嶏紝閬垮厤骞惰涓叉壈銆�
+    /// </summary>
+    public class BrokerE2ETests : IDisposable
+    {
+        private const string DevId = "CAN-B1";
+        private const int SimPort = 19555;
+
+        private readonly string _dir;
+        private readonly string _dataDb;
+        private readonly string _arbDb;
+        private readonly string _brokerId;
+        private readonly string _pipeName;
+        private readonly CancellationTokenSource _cts = new CancellationTokenSource();
+        private readonly Task _serverTask;
+        private readonly DbLeaseStore _clientLease;
+        private readonly TcpSimulatorHost _sim;
+
+        public BrokerE2ETests()
+        {
+            _dir = Path.Combine(Path.GetTempPath(), "brk_" + Guid.NewGuid().ToString("N"));
+            Directory.CreateDirectory(_dir);
+            _dataDb = Path.Combine(_dir, "data.db");
+            _arbDb = Path.Combine(_dir, "arb.db");
+            _brokerId = "broker-" + Guid.NewGuid().ToString("N").Substring(0, 8);
+            _pipeName = BrokerPipeName.For(_brokerId);
+
+            CreateDataDb();
+
+            // 妯℃嫙鍣ㄦ湇鍔″厛浜庡涓诲氨浣嶏紙瀹夸富棣栨 CONNECT 浼氭媺璧风墿鐞� TCP 浼氳瘽锛�
+            _sim = new TcpSimulatorHost();
+            _sim.StartAsync(SimPort).GetAwaiter().GetResult();
+
+            var server = new BrokerServer(_dataDb, _arbDb, _brokerId);
+            server.LoadDevices();
+            _serverTask = Task.Run(() => server.RunAsync(_cts.Token));
+            _clientLease = new DbLeaseStore(_arbDb, "local-main");
+
+            // 绛夌洃鍚氨浣嶏紙瀹㈡埛绔湰韬湁 3s 杩炴帴瓒呮椂锛屽彟鏈� BROKER_UNREACHABLE 閲嶈瘯鍏滃簳锛�
+            Thread.Sleep(250);
+        }
+
+        public void Dispose()
+        {
+            try { _cts.Cancel(); } catch { }
+            try { _serverTask.Wait(1500); } catch { }
+            try { _sim?.Dispose(); } catch { }
+            _cts.Dispose();
+            foreach (var f in Directory.GetFiles(_dir))
+            {
+                try { File.Delete(f); } catch { }
+            }
+            try { Directory.Delete(_dir); } catch { }
+        }
+
+        // ==================== 姝e悜閾捐矾 ====================
+
+        [Fact]
+        public async Task WithLease_Connect_Send_Query_Succeeds()
+        {
+            var grant = GrabLease();
+            var hash = DeviceConfigHash.Compute(_dataDb);
+            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
+
+            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
+
+            var tx = await driver.SendCommandAsync("SEND:0x123:11 22", CancellationToken.None);
+            Assert.Contains("OK:TX", tx);
+
+            var status = await driver.QueryAsync("STATUS", CancellationToken.None);
+            Assert.Contains("STATUS", status);
+
+            // Broker 渚ф墽琛屾垚鍔熷悗绉熺害浠嶆湁鏁堬紙鏈璇噴鏀撅級
+            Assert.Equal(LeasePhase.Active, _clientLease.PhaseOf(DevId));
+            _clientLease.Release(grant, "test-end");
+        }
+
+        [Fact]
+        public async Task TwoSequencedFragments_SameSession_ExecuteSerially()
+        {
+            var grant = GrabLease();
+            var hash = DeviceConfigHash.Compute(_dataDb);
+            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
+            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
+
+            // SessionGate 涓嬪悓浼氳瘽鐗囨涓ユ牸涓茶锛堝悓杩涚▼鍗曞鎴风鏈氨涓茶锛岄獙璇佷笉浜х敓浜ら敊閿欒锛�
+            var a = await driver.SendCommandAsync("SEND:0x100:01", CancellationToken.None);
+            var b = await driver.SendCommandAsync("SEND:0x200:02", CancellationToken.None);
+            Assert.Contains("OK:TX", a);
+            Assert.Contains("OK:TX", b);
+            _clientLease.Release(grant, "test-end");
+        }
+
+        // ==================== 璐熷悜閾捐矾 ====================
+
+        [Fact]
+        public async Task NoLease_ConnectAllowed_Send_RejectedStaleLease()
+        {
+            // CONNECT/DISCONNECT 浠呯櫥璁�/閲婃斁瀹夸富鐗╃悊浼氳瘽锛岃眮鍏嶇绾︼紱
+            // SEND 鏈夋�荤嚎杈撳嚭锛屾棤绉熺害蹇呴』鎷掔粷锛圛NV-01 绂佹鏃犵绾︾洿鎺� I/O锛�
+            var hash = DeviceConfigHash.Compute(_dataDb);
+            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
+
+            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
+
+            var ex = await Record.ExceptionAsync(
+                () => driver.SendCommandAsync("SEND:0x100:01", CancellationToken.None));
+            Assert.NotNull(ex);
+            Assert.Contains("LEASE_STALE", ex.Message);
+        }
+
+        [Fact]
+        public async Task ConfigHashMismatch_Rejected()
+        {
+            GrabLease(); // 鏈夌绾︼紝浣嗗搱甯屽涓嶄笂 鈫� INV-04 鎷︽埅
+            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, "deadbeefdeadbeef");
+
+            var ex = await Record.ExceptionAsync(
+                () => RetryConnectAsync(driver, expectThrow: true));
+            Assert.NotNull(ex);
+            Assert.Contains("CONFIG_MISMATCH", ex.Message);
+        }
+
+        [Fact]
+        public async Task ReleasedLease_Send_Rejected()
+        {
+            var grant = GrabLease();
+            var hash = DeviceConfigHash.Compute(_dataDb);
+            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
+            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
+
+            // 绉熺害褰掕繕鍚庡啀 I/O 鈫� 鏍呮爮蹇呴』鎷掔粷锛堢姝㈡棤绉熺害鐩存帴 I/O锛�
+            _clientLease.Release(grant, "released-by-test");
+            var ex = await Record.ExceptionAsync(
+                () => driver.SendCommandAsync("SEND:0x100:01", CancellationToken.None));
+            Assert.NotNull(ex);
+            Assert.Contains("LEASE_STALE", ex.Message);
+        }
+
+        [Fact]
+        public async Task MaintenanceLease_BrokeredWrite_Succeeds()
+        {
+            // 璋冭瘯椤电淮鎶ゅ啓鏉冮摼璺細缁存姢绉熺害鍐欏叆 arb.db锛圤wnerType=Maintenance锛夊悗锛�
+            // Broker 鏍呮爮鍚屾牱鏀捐锛堝崰鐢ㄥ彲瑙�/鎷掔粷璇箟宸茬敱鐢熶骇鍦烘櫙鐢ㄤ緥瑕嗙洊锛�
+            var outcome = _clientLease.Acquire(new AcquireRequest
+            {
+                OwnerId = "maint-session-1",
+                OwnerType = OwnerTypeV3.Maintenance,
+                ResourceKeys = new[] { DevId },
+                TtlSeconds = 30
+            });
+            Assert.Equal(AcquireResultCode.Acquired, outcome.Code);
+
+            var hash = DeviceConfigHash.Compute(_dataDb);
+            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
+            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
+            var tx = await driver.SendCommandAsync("SEND:0x300:AB", CancellationToken.None);
+            Assert.Contains("OK:TX", tx);
+
+            _clientLease.Release(outcome.Grants[0], "maint-end");
+        }
+
+        // ==================== 杈呭姪 ====================
+
+        [Fact]
+        public async Task BrokerReceive_PiggybackPush_DeliveredToProxyDriver()
+        {
+            // 楠屾敹鐐癸細鍏变韩璁惧(Broker)鑳芥敹鍒板悓鎬荤嚎瀵圭鍙戞潵鐨勫抚锛堢粡瀹夸富鎹庡甫鎺ㄩ�侊級銆�
+            // 淇鍘嗗彶涓� BrokeredCanDriver 鏃犳帴鏀朵簨浠躲�侀潰鏉挎敹涓嶅埌 RX 鐨勯〗鐥囥��
+            var grant = GrabLease();
+            var hash = DeviceConfigHash.Compute(_dataDb);
+            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
+            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
+
+            CanFrameish received = null;
+            driver.DriverFrameReceived += (s, f) => received = new CanFrameish { Id = f.FrameIdHex, Data = f.DataHex };
+
+            // 瀵圭锛氬悓妯℃嫙鍣ㄥ悓鎬荤嚎 CAN_E2E 鐨勮妭鐐� 1锛屾湰鍦扮洿杩炲彂甯�
+            using var peer = new CanDeviceDriver("CAN-PEER", "CAN_E2E:1", "127.0.0.1", SimPort);
+            peer.ConnectAsync(CancellationToken.None).GetAwaiter().GetResult();
+            peer.SendCommandAsync("SEND:0x7E7:EE FF", CancellationToken.None).GetAwaiter().GetResult();
+
+            // 瑙﹀彂瀹夸富 drain 鎹庡甫锛氫换涓�闈炴帴鏀剁被鏌ヨ锛圫TATUS锛夊嵆鍙甫鍥炴帹閫�
+            for (int i = 0; i < 20 && received == null; i++)
+            {
+                await driver.QueryAsync("STATUS", CancellationToken.None);
+                if (received == null) await Task.Delay(50);
+            }
+
+            Assert.NotNull(received);
+            Assert.Equal("0x7E7", received.Id);
+            Assert.Contains("EE FF", received.Data);
+
+            _clientLease.Release(grant, "test-end");
+        }
+
+        private sealed class CanFrameish { public string Id; public string Data; public string Src; }
+
+        [Fact]
+        public async Task BrokerResponderEcho_ViaBusEcho_DeliveredToProxyDriver()
+        {
+            // 澶嶇幇鐢ㄦ埛鍦烘櫙锛氫粎 Broker 璁惧鑷繁鍙戝抚 + 鎬荤嚎搴旂瓟鍣ㄥ紑鍚� 鈫� 搴旀敹鍒� RESPONDER 鍥炴樉銆�
+            var grant = GrabLease();
+            var hash = DeviceConfigHash.Compute(_dataDb);
+            using var driver = CanDeviceDriver.CreateBrokerProxy(DevId, "CAN_E2E:0", _pipeName, hash);
+            await RetryAsync(() => driver.ConnectAsync(CancellationToken.None));
+
+            CanFrameish echo = null;
+            driver.DriverFrameReceived += (s, f) => echo = new CanFrameish { Id = f.FrameIdHex, Data = f.DataHex, Src = f.SourceNodeId };
+
+            // 缁� RPC 寮�鍚�荤嚎搴旂瓟鍣�
+            var on = await driver.QueryAsync("BUS_ECHO:ON", CancellationToken.None);
+            Assert.StartsWith("OK:", on);
+
+            // Broker 璁惧鑷繁鍙戜竴甯�
+            await driver.SendCommandAsync("SEND:0x111:AA BB CC", CancellationToken.None);
+
+            // 杞 STATUS 瑙﹀彂瀹夸富 drain 鎹庡甫锛岀瓑鍥炴樉閫佽揪
+            for (int i = 0; i < 30 && echo == null; i++)
+            {
+                await driver.QueryAsync("STATUS", CancellationToken.None);
+                if (echo == null) await Task.Delay(50);
+            }
+
+            Assert.NotNull(echo);
+            Assert.Equal("0x111", echo.Id);
+            Assert.Contains("RESPONDER", echo.Src);
+
+            _clientLease.Release(grant, "test-end");
+        }
+
+        // ==================== 杈呭姪锛堝師鏈夛級 ====================
+
+        private LeaseGrant GrabLease()
+        {
+            var outcome = _clientLease.Acquire(new AcquireRequest
+            {
+                OwnerId = "e2e-" + Guid.NewGuid().ToString("N"),
+                OwnerType = OwnerTypeV3.Production,
+                ResourceKeys = new[] { DevId },
+                TtlSeconds = 30
+            });
+            Assert.Equal(AcquireResultCode.Acquired, outcome.Code);
+            return outcome.Grants[0];
+        }
+
+        private static async Task RetryAsync(Func<Task> action, int attempts = 4)
+        {
+            Exception last = null;
+            for (int i = 0; i < attempts; i++)
+            {
+                try { await action().ConfigureAwait(false); return; }
+                catch (InvalidOperationException ex) when (ex.Message.Contains("BROKER_UNREACHABLE"))
+                {
+                    last = ex; // 瀹夸富灏氭湭寮�濮嬬洃鍚紝鐭殏閲嶈瘯
+                    await Task.Delay(400).ConfigureAwait(false);
+                }
+            }
+            throw last ?? new InvalidOperationException("閲嶈瘯浠嶅け璐�");
+        }
+
+        /// <summary>璐熷悜鐢ㄤ緥锛氭彙鎵嬪彲閲嶈瘯锛堢閬撴湭灏变綅锛夛紝浣嗘爡鏍�/鍝堝笇鎷掔粷灞炵粓鎬佸簲鐩存帴涓婃姏銆�</summary>
+        private async Task RetryConnectAsync(CanDeviceDriver driver, bool expectThrow)
+        {
+            Exception last = null;
+            for (int i = 0; i < 4; i++)
+            {
+                try
+                {
+                    await driver.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+                    return; // 鎰忓鎴愬姛鈥斺�旂敱澶栧眰鏂█鎹曟崏
+                }
+                catch (InvalidOperationException ex) when (ex.Message.Contains("BROKER_UNREACHABLE"))
+                {
+                    last = ex;
+                    await Task.Delay(400).ConfigureAwait(false);
+                }
+                catch (InvalidOperationException)
+                {
+                    throw; // LEASE_STALE / CONFIG_MISMATCH 缁堟��
+                }
+            }
+            throw last ?? new InvalidOperationException("鏈幏寰楅鏈熸嫆缁�");
+        }
+
+        private void CreateDataDb()
+        {
+            using var conn = new System.Data.SQLite.SQLiteConnection(
+                $"Data Source={_dataDb};Version=3;");
+            conn.Open();
+            using (var ddl = conn.CreateCommand())
+            {
+                ddl.CommandText = @"
+                CREATE TABLE DeviceDefinition (
+                    DeviceId TEXT PRIMARY KEY, Kind TEXT NOT NULL, DriverType TEXT NOT NULL,
+                    Transport TEXT NOT NULL, Endpoint TEXT NOT NULL,
+                    ExecutionMode TEXT NOT NULL DEFAULT 'Local',
+                    AuthorityId TEXT NOT NULL DEFAULT '', BrokerId TEXT NOT NULL DEFAULT '',
+                    OwnerProcessId TEXT NOT NULL DEFAULT '', PhysicalSessionKey TEXT NOT NULL DEFAULT '',
+                    EffectiveVersion TEXT NOT NULL DEFAULT '', CreatedAt TEXT NOT NULL, UpdatedAt TEXT NOT NULL);";
+                ddl.ExecuteNonQuery();
+            }
+            using (var ins = conn.CreateCommand())
+            {
+                ins.CommandText = @"
+                INSERT INTO DeviceDefinition VALUES (
+                    'CAN-B1','CanDevice','CAN','Can','CAN_E2E:0',
+                    'Broker',@auth,@broker,'','can/CAN_E2E/main',
+                    'V1','2026-11-08 00:00:00','2026-11-08 00:00:00');";
+                ins.Parameters.AddWithValue("@auth", _brokerId);
+                ins.Parameters.AddWithValue("@broker", _brokerId);
+                ins.ExecuteNonQuery();
+            }
+        }
+    }
+}

--
Gitblit v1.9.1