feat(lmxproxy): phase 6 — client extras (builder, factory, DI, streaming extensions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 00:29:16 -04:00
parent 8ba75b50e8
commit 215cfa29f3
12 changed files with 1082 additions and 3 deletions

View File

@@ -0,0 +1,157 @@
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;
using ZB.MOM.WW.LmxProxy.Client.Tests.Fakes;
namespace ZB.MOM.WW.LmxProxy.Client.Tests;
public class StreamingExtensionsTests
{
[Fact]
public async Task ReadStreamAsync_BatchesCorrectly()
{
var fake = new FakeLmxProxyClient();
var addresses = Enumerable.Range(0, 250).Select(i => $"tag{i}").ToList();
var results = new List<KeyValuePair<string, Vtq>>();
await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 100))
{
results.Add(kvp);
}
// 250 tags at batchSize=100 => 3 batch calls (100, 100, 50)
Assert.Equal(3, fake.ReadBatchCalls.Count);
Assert.Equal(100, fake.ReadBatchCalls[0].Count);
Assert.Equal(100, fake.ReadBatchCalls[1].Count);
Assert.Equal(50, fake.ReadBatchCalls[2].Count);
Assert.Equal(250, results.Count);
}
[Fact]
public async Task ReadStreamAsync_RetriesOnError()
{
var fake = new FakeLmxProxyClient
{
ReadBatchExceptionToThrow = new InvalidOperationException("transient"),
ReadBatchExceptionCount = 1 // First call throws, second succeeds
};
var addresses = Enumerable.Range(0, 5).Select(i => $"tag{i}").ToList();
var results = new List<KeyValuePair<string, Vtq>>();
await foreach (var kvp in fake.ReadStreamAsync(addresses, batchSize: 10))
{
results.Add(kvp);
}
// Should retry: first call throws, second succeeds
Assert.Equal(2, fake.ReadBatchCalls.Count);
Assert.Equal(5, results.Count);
}
[Fact]
public async Task WriteStreamAsync_BatchesAndReturnsCount()
{
var fake = new FakeLmxProxyClient();
var values = GenerateWriteValues(250);
int total = await fake.WriteStreamAsync(values, batchSize: 100);
Assert.Equal(250, total);
Assert.Equal(3, fake.WriteBatchCalls.Count);
Assert.Equal(100, fake.WriteBatchCalls[0].Count);
Assert.Equal(100, fake.WriteBatchCalls[1].Count);
Assert.Equal(50, fake.WriteBatchCalls[2].Count);
}
[Fact]
public async Task ProcessInParallelAsync_RespectsMaxConcurrency()
{
int maxConcurrency = 2;
int currentConcurrency = 0;
int maxObservedConcurrency = 0;
var lockObj = new object();
var source = GenerateAsyncSequence(10);
await source.ProcessInParallelAsync(async (item, ct) =>
{
int current;
lock (lockObj)
{
currentConcurrency++;
current = currentConcurrency;
if (current > maxObservedConcurrency)
maxObservedConcurrency = current;
}
await Task.Delay(50, ct);
lock (lockObj)
{
currentConcurrency--;
}
}, maxConcurrency: maxConcurrency);
Assert.True(maxObservedConcurrency <= maxConcurrency,
$"Max observed concurrency {maxObservedConcurrency} exceeded limit {maxConcurrency}");
}
[Fact]
public async Task SubscribeStreamAsync_YieldsFromChannel()
{
var fake = new FakeLmxProxyClient();
var addresses = new[] { "tag1", "tag2" };
using var cts = new CancellationTokenSource();
var results = new List<(string Tag, Vtq Vtq)>();
// Start the subscription stream in a background task
var streamTask = Task.Run(async () =>
{
await foreach (var item in fake.SubscribeStreamAsync(addresses, cts.Token))
{
results.Add(item);
if (results.Count >= 3)
await cts.CancelAsync();
}
});
// Wait for subscribe to be called with a polling loop
for (int i = 0; i < 50 && fake.CapturedOnUpdate is null; i++)
await Task.Delay(50);
// Simulate updates via captured callback
Assert.NotNull(fake.CapturedOnUpdate);
fake.CapturedOnUpdate!("tag1", new Vtq(1.0, DateTime.UtcNow, Quality.Good));
fake.CapturedOnUpdate!("tag2", new Vtq(2.0, DateTime.UtcNow, Quality.Good));
fake.CapturedOnUpdate!("tag1", new Vtq(3.0, DateTime.UtcNow, Quality.Good));
// Wait for stream task to complete (cancelled after 3 items)
try { await streamTask; }
catch (OperationCanceledException) { }
Assert.Equal(3, results.Count);
Assert.Equal("tag1", results[0].Tag);
Assert.Equal("tag2", results[1].Tag);
Assert.Equal("tag1", results[2].Tag);
}
private static async IAsyncEnumerable<KeyValuePair<string, TypedValue>> GenerateWriteValues(int count)
{
for (int i = 0; i < count; i++)
{
yield return new KeyValuePair<string, TypedValue>(
$"tag{i}",
new TypedValue { DoubleValue = i * 1.0 });
await Task.Yield();
}
}
private static async IAsyncEnumerable<int> GenerateAsyncSequence(int count)
{
for (int i = 0; i < count; i++)
{
yield return i;
await Task.Yield();
}
}
}