diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8273414..8c66d02 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,11 +29,14 @@ jobs:
10.0.x
- name: Build
run: dotnet build SecsFrame.slnx -c Release
- - name: Test
+ - name: Test supported projects
shell: bash
env:
FRAMEWORKS: ${{ matrix.frameworks }}
run: |
for fw in $FRAMEWORKS; do
- dotnet test SecsFrame.slnx -c Release --no-build -f "$fw"
+ dotnet test test/SecsFrame.Tests/SecsFrame.Tests.csproj -c Release --no-build -f "$fw"
+ if [ "$fw" != "net48" ]; then
+ dotnet test test/SecsFrame.Secs4NetInterop.Tests/SecsFrame.Secs4NetInterop.Tests.csproj -c Release --no-build -f "$fw"
+ fi
done
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e396ee2..81fc041 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,5 +47,9 @@
单消费者事件流、入站一次性回复和控制命令。
- 增加公共 API 选项边界、生命周期、等待/事务取消和 Active/Passive
双端真实 TCP 往返测试;入站回复令牌绑定原连接与原运输会话。
+- 增加独立的官方 Secs4Net 3.1.0 NuGet 互操作测试项目,
+ 由中央包管理固定版本且只从 nuget.org 还原,不引用本地源码 checkout。
+- 增加双方 Active/Passive、Select、双方 Linktest、双方
+ Primary/Secondary 及嵌套 Item 边界值的真实 TCP 跨实现测试。
[Unreleased]: https://github.com/CSJ608/SecsFrame/compare/main...HEAD
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 466245e..e8e3141 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,6 +8,7 @@
+
diff --git a/README.md b/README.md
index c5dbfc4..d05d080 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,8 @@ Deselect、Separate、T6/T7 和 Selected 数据门控。内部数据事务 actor
[docs/HSMS-DATA-TRANSACTIONS.md](docs/HSMS-DATA-TRANSACTIONS.md),
公共连接用法见
[docs/HSMS-CONNECTION.md](docs/HSMS-CONNECTION.md),
+官方 secs4net 跨实现证据见
+[docs/SECS4NET-INTEROP.md](docs/SECS4NET-INTEROP.md),
整体路线图见
[docs/ROADMAP.md](docs/ROADMAP.md)。
diff --git a/SecsFrame.slnx b/SecsFrame.slnx
index b657fde..625e5de 100644
--- a/SecsFrame.slnx
+++ b/SecsFrame.slnx
@@ -3,6 +3,7 @@
+
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 93b541d..0fb31c2 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -62,9 +62,10 @@ HSMS 会话切换后,旧会话的控制请求、数据事务和等待中的应
- 原始接收字节独立跟踪长度前缀和剩余 Payload,仅有未完成帧时运行 T8,
并用代次标识隔离接收进展与替换会话的陈旧回调。
-这些能力隔离 StreamFrame #38/#39 尚未提供的高级语义,不进入公共 API。
-上游支持后只替换 IHsmsTransport 实现。详细失效模式与替换
-条件见 [STREAMFRAME-ADAPTER.md](STREAMFRAME-ADAPTER.md)。
+这些能力隔离当前 StreamFrame 2.2.0 尚未提供的高级语义,不进入公共
+API。#38 已合入上游但尚未发布,#39 正在实现;正式包可用后只替换
+IHsmsTransport 实现。详细失效模式与替换条件见
+[STREAMFRAME-ADAPTER.md](STREAMFRAME-ADAPTER.md)。
### 单线程会话状态机
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index 6c7fc49..e953608 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -19,8 +19,9 @@
## 上游能力适配
-StreamFrame #38(未完成帧超时)和 #39(会话感知发送确认与消息上下文)
-仍未提供原生 API。SecsFrame 当前通过内部
+StreamFrame #38(未完成帧超时)已经合入上游 main,但尚未
+进入正式 NuGet 版本;#39(会话感知发送确认与消息上下文)正在实现。
+SecsFrame 当前固定使用 StreamFrame 2.2.0,并通过内部
StreamFrameHsmsTransport 补足对应语义。
这不是设备兼容模式:
@@ -35,10 +36,13 @@ StreamFrame #38(未完成帧超时)和 #39(会话感知发送确认与消
## 跨实现测试依赖
-计划中的 secs4net 互操作夹具只能使用 nuget.org 发布的官方 NuGet 包,
-并在 Directory.Packages.props 中固定明确版本。不得使用
-本地源码 checkout、ProjectReference、本地 feed 或自行修改的程序集作为
-互操作对端。源码仓库只读参考不进入 SecsFrame 的还原、构建或测试图。
+当前 secs4net 互操作夹具使用 nuget.org 发布的官方
+Secs4Net 3.1.0 包,并在
+Directory.Packages.props 中固定版本。仓库
+NuGet.Config 清除其它源并把全部包映射到 nuget.org。不得
+使用本地源码 checkout、ProjectReference、本地 feed 或自行修改的程序集
+作为互操作对端。源码仓库只读参考不进入 SecsFrame 的还原、构建或测试图。
-夹具和文档必须记录官方包 ID、版本、运行框架、测试方向与通过/失败结果,
-避免把本地差异误判为上游互操作能力。
+包身份、运行框架、测试方向与结果见
+[SECS4NET-INTEROP.md](SECS4NET-INTEROP.md)。这些证据避免把本地差异
+误判为上游互操作能力。
diff --git a/docs/HSMS-SESSION-STATE-MACHINE.md b/docs/HSMS-SESSION-STATE-MACHINE.md
index c7a0984..85b4caa 100644
--- a/docs/HSMS-SESSION-STATE-MACHINE.md
+++ b/docs/HSMS-SESSION-STATE-MACHINE.md
@@ -79,14 +79,17 @@ System Bytes。旧会话的帧、发送完成和计时器回调不会推进替
事务细节见 [HSMS-DATA-TRANSACTIONS.md](HSMS-DATA-TRANSACTIONS.md)。
公共 HsmsConnection 另行验证同一路径的外部组合契约,见
[HSMS-CONNECTION.md](HSMS-CONNECTION.md)。
-测试证明当前实现路径可互操作,不替代认证工具或第三方设备互操作证据。
+独立夹具还与 nuget.org 官方 Secs4Net 3.1.0 在双方 Active/Passive 模式下
+完成 Select、双方 Linktest 和双方 Primary/Secondary,见
+[SECS4NET-INTEROP.md](SECS4NET-INTEROP.md)。测试证明当前实现路径可互操作,
+不替代认证工具或真实设备证据。
## 尚未实现
- 授权标准核对后的 T5/T8 默认值和完整标准语义;
- 自动周期 Linktest 调度;
- Host/Equipment 能力 API;
-- 与 secs4net 或真实设备的跨实现互操作矩阵。
+- 与真实设备、认证工具和更多实现版本的互操作矩阵。
实现这些行为前,需要使用团队合法获得的 SEMI E37-0222 与 E37.1-0819
副本核对精确状态转换、控制字段、状态/原因值和计时边界。仓库不得提交
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 3c6b2c0..d1c4db4 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -18,15 +18,14 @@
- [x] 显式 T5 固定重连节流、T8 接收进展监视和跨会话旧回调隔离。
- [x] 最小公共连接外观、显式 T3/T5/T6/T7/T8 配置、动态消息事件与
确定的取消/释放语义。
+- [x] 使用 nuget.org 官方 Secs4Net 3.1.0 的 Active/Passive、Select、
+ 双方 Linktest 和双方 Primary/Secondary 跨实现测试。
- [ ] 使用授权 E37/E37.1 核对 T5/T8 默认值与精确启停边界。
-- Host、Equipment 双角色回环集成测试,以及与 nuget.org 官方 secs4net
- 包的互操作测试。
-
-阶段 1 的下一最小切片是跨实现互操作夹具:通过中央包管理固定
-nuget.org 官方 secs4net 包版本,覆盖 Select、Linktest 和
-Primary/Secondary。不得引用、构建或链接本地 secs4net 源码 checkout。
-随后根据互操作结果收敛公共诊断模型。任何标准默认值和完整合规结论都
-依赖团队合法获得的 E37/E37.1 副本与一致性测试。
+- [ ] Host、Equipment 双角色能力及对应回环集成测试。
+
+阶段 1 的下一最小切片是根据跨实现证据收敛公共诊断模型,同时继续跟踪
+StreamFrame #38/#39 的正式包发布并准备等价迁移。任何标准默认值和完整
+合规结论都依赖团队合法获得的 E37/E37.1 副本与一致性测试。
## 阶段 2:GEM 通用能力
diff --git a/docs/SECS4NET-INTEROP.md b/docs/SECS4NET-INTEROP.md
new file mode 100644
index 0000000..44bf1ee
--- /dev/null
+++ b/docs/SECS4NET-INTEROP.md
@@ -0,0 +1,59 @@
+# 官方 secs4net 互操作证据
+
+## 依赖身份
+
+互操作项目 SecsFrame.Secs4NetInterop.Tests 只引用 nuget.org
+发布的官方包:
+
+| 字段 | 值 |
+|---|---|
+| Package ID | Secs4Net |
+| 固定版本 | 3.1.0 |
+| NuGet 源 | https://api.nuget.org/v3/index.json |
+| 作者 / 许可 | mkjeff / MIT |
+| 包内仓库提交 | 6f540a7540fc7a531d1d526c04375f4b05a7641f |
+| 测试框架 | net8.0、net10.0 |
+
+版本由根目录 Directory.Packages.props 中央管理。仓库
+NuGet.Config 先清除继承源,再把所有包映射到 nuget.org。
+测试项目没有指向 secs4net 源码的 ProjectReference、本地 feed
+或程序集路径;本地源码 checkout 不参与还原、编译或运行。
+
+Secs4Net 3.1.0 的正式包只提供 net8.0 和 net10.0 资产,所以互操作项目
+只运行这两个目标。SecsFrame 自身的 net48 构建和主测试矩阵继续保留,
+但不伪造官方包未提供的 net48 跨实现结果。
+
+## 真实 TCP 矩阵
+
+每个目标框架都在 IPv4 loopback 上运行以下两种独立拓扑:
+
+| SecsFrame | 官方 Secs4Net | Select 发起方 | 已验证行为 |
+|---|---|---|---|
+| Active | Passive | SecsFrame | Select、双方 Linktest、双方 Primary/Secondary |
+| Passive | Active | Secs4Net | Select、双方 Linktest、双方 Primary/Secondary |
+
+“双方 Linktest”包括 SecsFrame 主动请求并等待官方实现响应,以及启用官方
+实现的周期 Linktest 后确认它收到 SecsFrame 的响应。测试中的短周期只用于
+确定性验证,不是标准默认值或部署建议。
+
+## 消息向量
+
+SecsFrame 到 Secs4Net 使用带 W-Bit 的 S6F11,根 Item 包含嵌套 List、
+ASCII、U1 的 0/最大值、I4 的最小/最大值和 Boolean;官方实现读取每个值
+后以 S6F12 Boolean 回复。反向使用带 W-Bit 的 S1F1,包含 ASCII、嵌套
+List、U2 的 0/最大值和 Boolean;SecsFrame 严格解码后以 S1F2 Binary
+回复。两边都验证 Stream、Function、协议 Session/Device ID 和 Item 值。
+
+每个目标框架包含两个测试用例,对应上述两种连接拓扑。验证命令:
+
+~~~bash
+dotnet test test/SecsFrame.Secs4NetInterop.Tests/SecsFrame.Secs4NetInterop.Tests.csproj -c Release -f net8.0
+dotnet test test/SecsFrame.Secs4NetInterop.Tests/SecsFrame.Secs4NetInterop.Tests.csproj -c Release -f net10.0
+~~~
+
+## 结论边界
+
+该矩阵证明 SecsFrame 当前工程实现能与指定官方软件版本完成所列 HSMS-SS
+控制流程和动态 SECS-II 消息往返。它没有覆盖真实设备、网络故障注入、
+所有 Item 组合、全部控制状态或长期运行,也不替代 SEMI 标准核对、认证
+工具或一致性测试,不据此声明完整 E5/E37/E37.1 合规。
diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md
index eac65ce..c5ba911 100644
--- a/docs/STANDARDS.md
+++ b/docs/STANDARDS.md
@@ -60,3 +60,7 @@
构建;互操作夹具只允许使用 nuget.org 发布并固定版本的官方 NuGet 包。
未来如引入实质性开源代码,必须保留对应版权与许可证文本,并在变更说明
中记录来源。
+
+与官方 Secs4Net 3.1.0 的 Select、Linktest 和动态消息往返是独立软件实现
+之间的工程互操作证据,不代表任何一方通过 SEMI 一致性认证。具体矩阵见
+[SECS4NET-INTEROP.md](SECS4NET-INTEROP.md)。
diff --git a/docs/STREAMFRAME-ADAPTER.md b/docs/STREAMFRAME-ADAPTER.md
index 94b1634..dcaada8 100644
--- a/docs/STREAMFRAME-ADAPTER.md
+++ b/docs/STREAMFRAME-ADAPTER.md
@@ -5,9 +5,9 @@
SecsFrame 当前依赖 StreamFrame 2.2.0。以下上游能力仍在跟踪:
- [StreamFrame #38](https://github.com/CSJ608/StreamFrame/issues/38):
- 未完成帧超时;
+ 未完成帧超时已经合入上游 main,但尚未进入正式包;
- [StreamFrame #39](https://github.com/CSJ608/StreamFrame/issues/39):
- 会话感知的发送确认与消息上下文。
+ 会话感知的发送确认与消息上下文正在实现。
StreamFrame 现有 SendAsync 在消息进入连接级队列后完成,队列
会跨 TCP 重连保留;GetMessages 也是跨重连的稳定消息流。
@@ -83,7 +83,7 @@ T8 到期关闭。
## 替换条件
-当 #38/#39 发布稳定 API 后:
+当 #38/#39 都发布稳定 NuGet API 后:
1. 运行现有会话、发送确认、迟到消息和超时测试向量验证语义等价;
2. 用原生 Session ID、发送完成任务和未完成帧超时替换内部回调适配;
diff --git a/test/SecsFrame.Secs4NetInterop.Tests/Secs4NetInteropTests.cs b/test/SecsFrame.Secs4NetInterop.Tests/Secs4NetInteropTests.cs
new file mode 100644
index 0000000..162f981
--- /dev/null
+++ b/test/SecsFrame.Secs4NetInterop.Tests/Secs4NetInteropTests.cs
@@ -0,0 +1,295 @@
+using System.Collections.Concurrent;
+using System.Net;
+using Microsoft.Extensions.Options;
+using OfficialConnectionState = Secs4Net.ConnectionState;
+using OfficialHsmsConnection = Secs4Net.HsmsConnection;
+using OfficialItem = Secs4Net.Item;
+using OfficialSecsGem = Secs4Net.SecsGem;
+using OfficialSecsGemOptions = Secs4Net.SecsGemOptions;
+using OfficialSecsMessage = Secs4Net.SecsMessage;
+
+namespace SecsFrame.Secs4NetInterop.Tests;
+
+public sealed class Secs4NetInteropTests
+{
+ private const ushort DeviceId = 10;
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Official_package_interoperates_in_both_connection_modes(
+ bool secs4NetIsActive)
+ {
+ var port = GetFreePort();
+ using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(20));
+ var logger = new RecordingSecs4NetLogger();
+ var officialOptions = Options.Create(
+ CreateOfficialOptions(port, secs4NetIsActive));
+ await using var connection = new HsmsConnection(
+ CreateSecsFrameOptions(port, secs4NetIsActive));
+ await using var officialConnection = new OfficialHsmsConnection(
+ officialOptions,
+ logger);
+ using var officialGem = new OfficialSecsGem(
+ officialOptions,
+ officialConnection,
+ logger);
+ var officialSelected = WaitUntilOfficialSelectedAsync(
+ officialConnection,
+ lifetime.Token);
+
+ connection.Start();
+ await using var events = connection
+ .GetEventsAsync(lifetime.Token)
+ .GetAsyncEnumerator();
+ officialConnection.Start(lifetime.Token);
+ await Task.WhenAll(
+ connection.WaitUntilSelectedAsync(lifetime.Token),
+ officialSelected).ConfigureAwait(true);
+
+ await VerifyLinktestsAsync(
+ connection,
+ officialConnection,
+ logger,
+ lifetime.Token).ConfigureAwait(true);
+ await VerifySecsFramePrimaryAsync(
+ connection,
+ officialGem,
+ lifetime.Token).ConfigureAwait(true);
+ await VerifyOfficialPrimaryAsync(
+ connection,
+ officialGem,
+ events,
+ lifetime.Token).ConfigureAwait(true);
+ }
+
+ private static async Task VerifyLinktestsAsync(
+ HsmsConnection connection,
+ OfficialHsmsConnection officialConnection,
+ RecordingSecs4NetLogger logger,
+ CancellationToken cancellationToken)
+ {
+ await connection.LinktestAsync(cancellationToken).ConfigureAwait(true);
+
+ officialConnection.LinkTestEnabled = true;
+ try
+ {
+ await logger.WaitForInfoAsync(
+ "Receive Control message: LinkTestResponse",
+ cancellationToken).ConfigureAwait(true);
+ }
+ finally
+ {
+ officialConnection.LinkTestEnabled = false;
+ }
+
+ Assert.Equal(HsmsSessionState.Selected, connection.State);
+ Assert.Equal(OfficialConnectionState.Selected, officialConnection.State);
+ }
+
+ private static async Task VerifySecsFramePrimaryAsync(
+ HsmsConnection connection,
+ OfficialSecsGem officialGem,
+ CancellationToken cancellationToken)
+ {
+ var officialMessages = officialGem
+ .GetPrimaryMessageAsync(cancellationToken)
+ .GetAsyncEnumerator();
+ await using var ownedMessages = officialMessages.ConfigureAwait(true);
+ var send = connection.SendAsync(
+ new SecsMessage(
+ 6,
+ 11,
+ true,
+ SecsItem.List(
+ SecsItem.Ascii("LOT-42"),
+ SecsItem.List(
+ SecsItem.U1(0, 1, byte.MaxValue),
+ SecsItem.I4(int.MinValue, int.MaxValue),
+ SecsItem.Boolean(false, true)))),
+ cancellationToken);
+
+ Assert.True(await officialMessages.MoveNextAsync().ConfigureAwait(true));
+ var incoming = officialMessages.Current;
+ using var primary = incoming.PrimaryMessage;
+ AssertOfficialPrimary(primary);
+ using var reply = new OfficialSecsMessage(6, 12, replyExpected: false)
+ {
+ SecsItem = OfficialItem.Boolean(true),
+ };
+ Assert.True(await incoming.TryReplyAsync(reply, cancellationToken)
+ .ConfigureAwait(true));
+
+ var secondary = await send.ConfigureAwait(true);
+ Assert.NotNull(secondary);
+ Assert.Equal(6, secondary.Message.Stream);
+ Assert.Equal(12, secondary.Message.Function);
+ Assert.Equal(SecsItem.Boolean(true), secondary.Message.RootItem);
+ }
+
+ private static async Task VerifyOfficialPrimaryAsync(
+ HsmsConnection connection,
+ OfficialSecsGem officialGem,
+ IAsyncEnumerator events,
+ CancellationToken cancellationToken)
+ {
+ using var primary = new OfficialSecsMessage(1, 1)
+ {
+ SecsItem = OfficialItem.L(
+ OfficialItem.A("EQ-01"),
+ OfficialItem.L(
+ OfficialItem.U2(0, ushort.MaxValue),
+ OfficialItem.Boolean(true, false))),
+ };
+ var send = officialGem.SendAsync(primary, cancellationToken);
+ var received = await NextDataMessageAsync(events).ConfigureAwait(true);
+ var incoming = received.IncomingMessage!;
+
+ Assert.Equal(DeviceId, incoming.DataMessage.SessionId);
+ Assert.Equal(1, incoming.DataMessage.Message.Stream);
+ Assert.Equal(1, incoming.DataMessage.Message.Function);
+ Assert.Equal(
+ SecsItem.List(
+ SecsItem.Ascii("EQ-01"),
+ SecsItem.List(
+ SecsItem.U2(0, ushort.MaxValue),
+ SecsItem.Boolean(true, false))),
+ incoming.DataMessage.Message.RootItem);
+ await connection.ReplyAsync(
+ incoming,
+ new SecsMessage(
+ 1,
+ 2,
+ rootItem: SecsItem.Binary(0)),
+ cancellationToken).ConfigureAwait(true);
+
+ using var secondary = await send.ConfigureAwait(true);
+ Assert.Equal(1, secondary.S);
+ Assert.Equal(2, secondary.F);
+ Assert.Equal(new byte[] { 0 }, secondary.SecsItem!.GetMemory().ToArray());
+ }
+
+ private static void AssertOfficialPrimary(OfficialSecsMessage primary)
+ {
+ Assert.Equal(6, primary.S);
+ Assert.Equal(11, primary.F);
+ Assert.True(primary.ReplyExpected);
+ var root = primary.SecsItem!;
+ Assert.Equal("LOT-42", root[0].GetString());
+ Assert.Equal(
+ new byte[] { 0, 1, byte.MaxValue },
+ root[1][0].GetMemory().ToArray());
+ Assert.Equal(
+ new[] { int.MinValue, int.MaxValue },
+ root[1][1].GetMemory().ToArray());
+ Assert.Equal(
+ new[] { false, true },
+ root[1][2].GetMemory().ToArray());
+ }
+
+ private static async Task NextDataMessageAsync(
+ IAsyncEnumerator events)
+ {
+ while (await events.MoveNextAsync().ConfigureAwait(true))
+ {
+ if (events.Current.Kind == HsmsConnectionEventKind.DataMessageReceived)
+ return events.Current;
+ }
+
+ Assert.Fail("The expected secs4net Primary was not received.");
+ return null!;
+ }
+
+ private static async Task WaitUntilOfficialSelectedAsync(
+ OfficialHsmsConnection connection,
+ CancellationToken cancellationToken)
+ {
+ if (connection.State == OfficialConnectionState.Selected)
+ return;
+
+ var selected = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ void OnStateChanged(object? sender, OfficialConnectionState state)
+ {
+ if (state == OfficialConnectionState.Selected)
+ selected.TrySetResult(true);
+ }
+
+ connection.ConnectionChanged += OnStateChanged;
+ try
+ {
+ if (connection.State == OfficialConnectionState.Selected)
+ return;
+
+ await selected.Task.WaitAsync(cancellationToken).ConfigureAwait(true);
+ }
+ finally
+ {
+ connection.ConnectionChanged -= OnStateChanged;
+ }
+ }
+
+ private static HsmsConnectionOptions CreateSecsFrameOptions(
+ int port,
+ bool secs4NetIsActive)
+ => new(
+ IPAddress.Loopback,
+ port,
+ secs4NetIsActive
+ ? HsmsConnectionMode.Passive
+ : HsmsConnectionMode.Active,
+ DeviceId,
+ t3: TimeSpan.FromSeconds(5),
+ t5: TimeSpan.FromMilliseconds(25),
+ t6: TimeSpan.FromSeconds(2),
+ t7: TimeSpan.FromSeconds(5),
+ t8: TimeSpan.FromSeconds(2));
+
+ private static OfficialSecsGemOptions CreateOfficialOptions(
+ int port,
+ bool isActive)
+ => new()
+ {
+ DeviceId = DeviceId,
+ IsActive = isActive,
+ IpAddress = IPAddress.Loopback.ToString(),
+ Port = port,
+ T3 = 5000,
+ T5 = 25,
+ T6 = 2000,
+ T7 = 5000,
+ T8 = 2000,
+ LinkTestInterval = 100,
+ };
+
+ private static int GetFreePort()
+ {
+ var listener = new System.Net.Sockets.TcpListener(
+ IPAddress.Loopback,
+ 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ private sealed class RecordingSecs4NetLogger : Secs4Net.ISecsGemLogger
+ {
+ private readonly ConcurrentDictionary>
+ _infoMessages = new(StringComparer.Ordinal);
+
+ public void Info(string message)
+ => GetSignal(message).TrySetResult(true);
+
+ public Task WaitForInfoAsync(
+ string message,
+ CancellationToken cancellationToken)
+ => GetSignal(message).Task.WaitAsync(cancellationToken);
+
+ private TaskCompletionSource GetSignal(string message)
+ => _infoMessages.GetOrAdd(
+ message,
+ static _ => new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously));
+ }
+}
diff --git a/test/SecsFrame.Secs4NetInterop.Tests/SecsFrame.Secs4NetInterop.Tests.csproj b/test/SecsFrame.Secs4NetInterop.Tests/SecsFrame.Secs4NetInterop.Tests.csproj
new file mode 100644
index 0000000..43ac6b7
--- /dev/null
+++ b/test/SecsFrame.Secs4NetInterop.Tests/SecsFrame.Secs4NetInterop.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+ net8.0;net10.0
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+