From 5ea657edae787b83ae47d7cf7b5a4ccdecccffae Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:01:27 -0500 Subject: [PATCH 1/9] feat: add TransformationExtensions and FlatMapExtensions operators Add TransformationExtensions with: - MapTo: project all values to a constant - CompactMap: select+filter nulls (ref and value type overloads) - WithIndex: pair each element with its zero-based index (Pattern B) - RunningFold: alias for Scan with seed - RunningReduce: alias for Scan without seed Add FlatMapExtensions with: - ConcatMap: sequential inner subscription with pending queue - SwitchMap / FlatMapLatest: cancel-and-replace with generation guard - ExhaustMap: ignore source while inner is active - Expand: breadth-first recursive expansion with active counter - MergeScan: scan with merged inner observables - SwitchScan: scan with switched inner observables Add TransformationExtensionsTests (25 tests) and FlatMapExtensionsTests (27 tests) covering normal behavior, completion propagation, null/arg validation, and edge cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- R3Ext.Tests/FlatMapExtensionsTests.cs | 430 ++++++++ R3Ext.Tests/TransformationExtensionsTests.cs | 293 +++++ R3Ext/Extensions/FlatMapExtensions.cs | 1014 ++++++++++++++++++ R3Ext/Extensions/TransformationExtensions.cs | 172 +++ 4 files changed, 1909 insertions(+) create mode 100644 R3Ext.Tests/FlatMapExtensionsTests.cs create mode 100644 R3Ext.Tests/TransformationExtensionsTests.cs create mode 100644 R3Ext/Extensions/FlatMapExtensions.cs create mode 100644 R3Ext/Extensions/TransformationExtensions.cs diff --git a/R3Ext.Tests/FlatMapExtensionsTests.cs b/R3Ext.Tests/FlatMapExtensionsTests.cs new file mode 100644 index 0000000..7fc5d47 --- /dev/null +++ b/R3Ext.Tests/FlatMapExtensionsTests.cs @@ -0,0 +1,430 @@ +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class FlatMapExtensionsTests +{ + // ── ConcatMap ──────────────────────────────────────────────────────────── + + [Fact] + public void ConcatMap_ProcessesSequentially() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.ConcatMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); // queued; inner1 not yet done + + inner1.OnNext("a"); + Assert.Equal(new[] { "a" }, result.ToArray()); // inner2 not started yet + + inner1.OnCompleted(); + inner2.OnNext("b"); + Assert.Equal(new[] { "a", "b" }, result.ToArray()); + } + + [Fact] + public void ConcatMap_EmitsInOrder() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.ConcatMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + inner1.OnNext(10); + inner1.OnNext(11); + inner1.OnCompleted(); + inner2.OnNext(20); + inner2.OnCompleted(); + subject.OnCompleted(); + + Assert.Equal(new[] { 10, 11, 20 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ConcatMap_CompletionPropagatesAfterAllInners() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.ConcatMap(_ => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); // inner still active + + inner.OnNext("a"); + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ConcatMap_CompletesImmediatelyWhenNoInners() + { + Subject subject = new(); + LiveList result = subject.ConcatMap(x => Observable.Return(x)).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void ConcatMap_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.ConcatMap(x => Observable.Return(x))); + } + + [Fact] + public void ConcatMap_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.ConcatMap(null!)); + } + + // ── SwitchMap ──────────────────────────────────────────────────────────── + + [Fact] + public void SwitchMap_CancelsOnNewValue() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.SwitchMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + inner1.OnNext("a"); + subject.OnNext(2); // cancels inner1 + inner1.OnNext("dropped"); + inner2.OnNext("b"); + + Assert.Equal(new[] { "a", "b" }, result.ToArray()); + } + + [Fact] + public void SwitchMap_CompletesWhenSourceAndCurrentInnerDone() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.SwitchMap(_ => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); // inner still active + + inner.OnNext("a"); + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SwitchMap_CompletesImmediatelyWhenSourceCompletesWithNoActiveInner() + { + Subject subject = new(); + LiveList result = subject.SwitchMap(x => Observable.Return(x)).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void SwitchMap_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.SwitchMap(x => Observable.Return(x))); + } + + [Fact] + public void SwitchMap_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.SwitchMap(null!)); + } + + // ── FlatMapLatest ──────────────────────────────────────────────────────── + + [Fact] + public void FlatMapLatest_IsAliasForSwitchMap() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.FlatMapLatest(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + inner1.OnNext("a"); + subject.OnNext(2); + inner1.OnNext("dropped"); + inner2.OnNext("b"); + + Assert.Equal(new[] { "a", "b" }, result.ToArray()); + } + + // ── ExhaustMap ─────────────────────────────────────────────────────────── + + [Fact] + public void ExhaustMap_IgnoresValuesWhileInnerActive() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.ExhaustMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); // ignored; inner1 still active + inner1.OnNext("a"); + inner1.OnCompleted(); + + subject.OnNext(2); // now accepted + inner2.OnNext("b"); + + Assert.Equal(new[] { "a", "b" }, result.ToArray()); + } + + [Fact] + public void ExhaustMap_CompletesAfterSourceAndInner() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.ExhaustMap(_ => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); + + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ExhaustMap_AcceptsNextValueAfterInnerCompletes() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.ExhaustMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + inner1.OnNext(10); + inner1.OnCompleted(); + subject.OnNext(2); + inner2.OnNext(20); + inner2.OnCompleted(); + subject.OnCompleted(); + + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ExhaustMap_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.ExhaustMap(x => Observable.Return(x))); + } + + [Fact] + public void ExhaustMap_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.ExhaustMap(null!)); + } + + // ── Expand ─────────────────────────────────────────────────────────────── + + [Fact] + public void Expand_RecursivelyAppliesSelector() + { + Subject subject = new(); + + // Expand values < 3 by adding 1; stop at 3 + LiveList result = subject + .Expand(x => x < 3 ? Observable.Return(x + 1) : Observable.Empty()) + .ToLiveList(); + + subject.OnNext(0); + subject.OnCompleted(); + + Assert.Equal(new[] { 0, 1, 2, 3 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Expand_CompletesWhenNoMoreExpansions() + { + Subject subject = new(); + + LiveList result = subject.Expand(_ => Observable.Empty()).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.Equal(new[] { 1 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Expand_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.Expand(x => Observable.Return(x))); + } + + [Fact] + public void Expand_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.Expand(null!)); + } + + // ── MergeScan ──────────────────────────────────────────────────────────── + + [Fact] + public void MergeScan_UpdatesAccumulatorState() + { + Subject subject = new(); + + LiveList result = subject + .MergeScan(0, (acc, x) => Observable.Return(acc + x)) + .ToLiveList(); + + subject.OnNext(1); // acc=0 → Return(1) → current=1, emit 1 + subject.OnNext(2); // acc=1 → Return(3) → current=3, emit 3 + subject.OnNext(3); // acc=3 → Return(6) → current=6, emit 6 + + Assert.Equal(new[] { 1, 3, 6 }, result.ToArray()); + } + + [Fact] + public void MergeScan_CompletesWhenSourceAndAllInnersDone() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.MergeScan(0, (_, _) => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); + + inner.OnNext(10); + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void MergeScan_EmitsFromActiveInner() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.MergeScan(0, (acc, x) => inner).ToLiveList(); + + subject.OnNext(1); + inner.OnNext(42); + + Assert.Equal(new[] { 42 }, result.ToArray()); + } + + [Fact] + public void MergeScan_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.MergeScan(0, (acc, x) => Observable.Return(acc + x))); + } + + [Fact] + public void MergeScan_ThrowsOnNullAccumulator() + { + Subject subject = new(); + Assert.Throws(() => subject.MergeScan(0, null!)); + } + + // ── SwitchScan ─────────────────────────────────────────────────────────── + + [Fact] + public void SwitchScan_SwitchesToLatestInner() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + int callCount = 0; + + LiveList result = subject.SwitchScan(0, (acc, x) => + { + callCount++; + return callCount == 1 ? inner1 : inner2; + }).ToLiveList(); + + subject.OnNext(1); + inner1.OnNext(10); + subject.OnNext(2); // switches away from inner1 + inner1.OnNext(99); // dropped + inner2.OnNext(20); + + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + } + + [Fact] + public void SwitchScan_UpdatesAccumulatorOnEachSwitch() + { + Subject subject = new(); + + LiveList result = subject + .SwitchScan(0, (acc, x) => Observable.Return(acc + x)) + .ToLiveList(); + + subject.OnNext(1); // acc=0 → Return(1) → current=1, emit 1 + subject.OnNext(2); // acc=1 → Return(3) → current=3, emit 3 + subject.OnNext(4); // acc=3 → Return(7) → current=7, emit 7 + + Assert.Equal(new[] { 1, 3, 7 }, result.ToArray()); + } + + [Fact] + public void SwitchScan_CompletesWhenSourceAndCurrentInnerDone() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.SwitchScan(0, (_, _) => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); + + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SwitchScan_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.SwitchScan(0, (acc, x) => Observable.Return(acc + x))); + } + + [Fact] + public void SwitchScan_ThrowsOnNullAccumulator() + { + Subject subject = new(); + Assert.Throws(() => subject.SwitchScan(0, null!)); + } +} diff --git a/R3Ext.Tests/TransformationExtensionsTests.cs b/R3Ext.Tests/TransformationExtensionsTests.cs new file mode 100644 index 0000000..a1a264a --- /dev/null +++ b/R3Ext.Tests/TransformationExtensionsTests.cs @@ -0,0 +1,293 @@ +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class TransformationExtensionsTests +{ + // ── MapTo ──────────────────────────────────────────────────────────────── + + [Fact] + public void MapTo_ReplacesAllValues() + { + Subject subject = new(); + LiveList result = subject.MapTo("x").ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + + Assert.Equal(new[] { "x", "x", "x" }, result.ToArray()); + } + + [Fact] + public void MapTo_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.MapTo("x").ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void MapTo_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.MapTo("x")); + } + + [Fact] + public void MapTo_WorksWithNullConstant() + { + Subject subject = new(); + LiveList result = subject.MapTo(null).ToLiveList(); + + subject.OnNext(1); + + Assert.Single(result); + Assert.Null(result[0]); + } + + // ── CompactMap (reference type) ────────────────────────────────────────── + + [Fact] + public void CompactMap_ReferenceType_FiltersNulls() + { + Subject subject = new(); + LiveList result = subject.CompactMap(x => x % 2 == 0 ? x.ToString() : null).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + + Assert.Equal(new[] { "2", "4" }, result.ToArray()); + } + + [Fact] + public void CompactMap_ReferenceType_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.CompactMap(x => x.ToString()).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void CompactMap_ReferenceType_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.CompactMap(x => x.ToString())); + } + + [Fact] + public void CompactMap_ReferenceType_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.CompactMap(null!)); + } + + // ── CompactMap (value type) ────────────────────────────────────────────── + + [Fact] + public void CompactMap_ValueType_FiltersNulls() + { + Subject subject = new(); + LiveList result = subject.CompactMap(x => x % 2 == 0 ? (int?)x : null).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + + Assert.Equal(new[] { 2, 4 }, result.ToArray()); + } + + [Fact] + public void CompactMap_ValueType_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.CompactMap(x => (int?)x).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void CompactMap_ValueType_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.CompactMap(x => x)); + } + + [Fact] + public void CompactMap_ValueType_AllNullsProducesEmpty() + { + Subject subject = new(); + LiveList result = subject.CompactMap(_ => (int?)null).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + + Assert.Empty(result); + Assert.True(result.IsCompleted); + } + + // ── WithIndex ──────────────────────────────────────────────────────────── + + [Fact] + public void WithIndex_EmitsValueAndZeroBasedIndex() + { + Subject subject = new(); + LiveList<(string Value, int Index)> result = subject.WithIndex().ToLiveList(); + + subject.OnNext("a"); + subject.OnNext("b"); + subject.OnNext("c"); + + Assert.Equal(new[] { ("a", 0), ("b", 1), ("c", 2) }, result.ToArray()); + } + + [Fact] + public void WithIndex_CompletionPropagates() + { + Subject subject = new(); + LiveList<(int Value, int Index)> result = subject.WithIndex().ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void WithIndex_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.WithIndex()); + } + + [Fact] + public void WithIndex_IndexIncrementsMonotonically() + { + Subject subject = new(); + LiveList<(int Value, int Index)> result = subject.WithIndex().ToLiveList(); + + for (int i = 0; i < 5; i++) + { + subject.OnNext(i * 10); + } + + int[] indices = result.Select(t => t.Index).ToArray(); + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, indices); + } + + // ── RunningFold ────────────────────────────────────────────────────────── + + [Fact] + public void RunningFold_AccumulatesValues() + { + Subject subject = new(); + LiveList result = subject.RunningFold(0, (acc, x) => acc + x).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + + Assert.Equal(new[] { 1, 3, 6 }, result.ToArray()); + } + + [Fact] + public void RunningFold_UsesSeedAsInitialAccumulator() + { + Subject subject = new(); + LiveList result = subject.RunningFold(10, (acc, x) => acc + x).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + + Assert.Equal(new[] { 11, 13 }, result.ToArray()); + } + + [Fact] + public void RunningFold_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.RunningFold(0, (acc, x) => acc + x).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void RunningFold_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.RunningFold(0, (acc, x) => acc + x)); + } + + [Fact] + public void RunningFold_ThrowsOnNullAccumulator() + { + Subject subject = new(); + Assert.Throws(() => subject.RunningFold(0, null!)); + } + + // ── RunningReduce ──────────────────────────────────────────────────────── + + [Fact] + public void RunningReduce_AccumulatesWithoutSeed() + { + Subject subject = new(); + LiveList result = subject.RunningReduce((acc, x) => acc + x).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + + Assert.Equal(new[] { 1, 3, 6 }, result.ToArray()); + } + + [Fact] + public void RunningReduce_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.RunningReduce((acc, x) => acc + x).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void RunningReduce_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.RunningReduce((acc, x) => acc + x)); + } + + [Fact] + public void RunningReduce_ThrowsOnNullAccumulator() + { + Subject subject = new(); + Assert.Throws(() => subject.RunningReduce(null!)); + } + + [Fact] + public void RunningReduce_FirstValueIsEmittedUnchanged() + { + Subject subject = new(); + LiveList result = subject.RunningReduce((acc, x) => acc * x).ToLiveList(); + + subject.OnNext(5); + + Assert.Equal(new[] { 5 }, result.ToArray()); + } +} diff --git a/R3Ext/Extensions/FlatMapExtensions.cs b/R3Ext/Extensions/FlatMapExtensions.cs new file mode 100644 index 0000000..4df9fbe --- /dev/null +++ b/R3Ext/Extensions/FlatMapExtensions.cs @@ -0,0 +1,1014 @@ +using R3; + +namespace R3Ext; + +/// +/// Higher-order flat-mapping extensions for R3 observables. +/// +public static class FlatMapExtensions +{ + /// + /// Projects each source value to an inner observable and concatenates them sequentially, + /// waiting for each inner observable to complete before subscribing to the next. + /// + public static Observable ConcatMap( + this Observable source, + Func> selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + bool innerActive = false; + Queue> pending = new(); + IDisposable? upstream = null; + IDisposable? innerSub = null; + Result sourceResult = default; + + void SubscribeToInner(Observable inner) + { + IDisposable sub = inner.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + Observable? nextInner = null; + bool shouldComplete = false; + Result completionResult = r; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (pending.Count > 0) + { + nextInner = pending.Dequeue(); + } + else + { + innerActive = false; + if (sourceCompleted) + { + shouldComplete = true; + completionResult = sourceResult; + } + } + } + + if (nextInner is not null) + { + SubscribeToInner(nextInner); + } + else if (shouldComplete) + { + observer.OnCompleted(completionResult); + } + }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub.Dispose(); + return; + } + + innerSub = sub; + } + } + + upstream = source.Subscribe( + x => + { + Observable? toSubscribe = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + Observable inner = selector(x); + + if (!innerActive) + { + innerActive = true; + toSubscribe = inner; + } + else + { + pending.Enqueue(inner); + } + } + + if (toSubscribe is not null) + { + SubscribeToInner(toSubscribe); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + sourceResult = r; + shouldComplete = !innerActive && pending.Count == 0; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + innerSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Projects each source value to an inner observable, cancelling any previously active inner + /// observable when a new source value arrives. Only values from the most recent inner observable + /// are forwarded downstream. + /// + public static Observable SwitchMap( + this Observable source, + Func> selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + bool innerActive = false; + int innerGeneration = 0; + IDisposable? upstream = null; + IDisposable? innerSub = null; + + upstream = source.Subscribe( + x => + { + IDisposable? oldSub; + Observable inner; + int myGeneration; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + oldSub = innerSub; + innerSub = null; + innerActive = true; + myGeneration = ++innerGeneration; + inner = selector(x); + } + + oldSub?.Dispose(); + + IDisposable sub = inner.Subscribe( + v => + { + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + observer.OnNext(v); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + innerActive = false; + shouldComplete = sourceCompleted; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + sub.Dispose(); + return; + } + + innerSub = sub; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + shouldComplete = !innerActive; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + innerSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Alias for . Projects to the latest inner observable, + /// dropping earlier ones when a new source value arrives. + /// + public static Observable FlatMapLatest( + this Observable source, + Func> selector) + => source.SwitchMap(selector); + + /// + /// Projects each source value to an inner observable, but ignores new source values while an + /// inner observable is still active. Only begins a new inner subscription once the current one + /// completes. + /// + public static Observable ExhaustMap( + this Observable source, + Func> selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + bool innerActive = false; + Result sourceResult = default; + IDisposable? upstream = null; + IDisposable? innerSub = null; + + upstream = source.Subscribe( + x => + { + Observable? toSubscribe = null; + + using (gate.EnterScope()) + { + if (disposed || innerActive) + { + return; + } + + innerActive = true; + toSubscribe = selector(x); + } + + if (toSubscribe is null) + { + return; + } + + IDisposable sub = toSubscribe.Subscribe( + v => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(v); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + innerActive = false; + shouldComplete = sourceCompleted; + } + + if (shouldComplete) + { + observer.OnCompleted(sourceResult); + } + }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub.Dispose(); + return; + } + + innerSub = sub; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + sourceResult = r; + shouldComplete = !innerActive; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + innerSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Recursively projects each emitted value through the selector to produce additional values, + /// subscribing to all resulting inner observables concurrently (breadth-first expansion). + /// + public static Observable Expand(this Observable source, Func> selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + int activeCount = 0; + IDisposable? upstream = null; + List innerSubs = new(); + + void SubscribeToInner(Observable inner) + { + IDisposable sub = inner.Subscribe( + v => + { + Observable next; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(v); + activeCount++; + next = selector(v); + } + + SubscribeToInner(next); + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + activeCount--; + shouldComplete = activeCount == 0 && sourceCompleted; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub.Dispose(); + return; + } + + innerSubs.Add(sub); + } + } + + upstream = source.Subscribe( + x => + { + Observable inner; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + activeCount++; + inner = selector(x); + } + + SubscribeToInner(inner); + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + shouldComplete = activeCount == 0; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + List subs; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + subs = innerSubs; + innerSubs = new List(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + upstream?.Dispose(); + }); + }); + } + + /// + /// Like Scan but the accumulator returns an observable. All inner observables are + /// subscribed to concurrently (merged), and each emission updates the running accumulator state. + /// + public static Observable MergeScan( + this Observable source, + TAccumulate seed, + Func> accumulator) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (accumulator is null) + { + throw new ArgumentNullException(nameof(accumulator)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + int activeInners = 0; + TAccumulate current = seed; + IDisposable? upstream = null; + List innerSubs = new(); + + upstream = source.Subscribe( + x => + { + Observable inner; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + inner = accumulator(current, x); + activeInners++; + } + + IDisposable sub = inner.Subscribe( + v => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + current = v; + observer.OnNext(v); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + activeInners--; + shouldComplete = sourceCompleted && activeInners == 0; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub.Dispose(); + return; + } + + innerSubs.Add(sub); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + shouldComplete = activeInners == 0; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + List subs; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + subs = innerSubs; + innerSubs = new List(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + upstream?.Dispose(); + }); + }); + } + + /// + /// Like MergeScan but switches to each new inner observable (cancelling the previous one) + /// rather than merging all concurrently. + /// + public static Observable SwitchScan( + this Observable source, + TAccumulate seed, + Func> accumulator) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (accumulator is null) + { + throw new ArgumentNullException(nameof(accumulator)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + bool innerActive = false; + int innerGeneration = 0; + TAccumulate current = seed; + IDisposable? upstream = null; + IDisposable? innerSub = null; + + upstream = source.Subscribe( + x => + { + IDisposable? oldSub; + Observable inner; + int myGeneration; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + oldSub = innerSub; + innerSub = null; + innerActive = true; + myGeneration = ++innerGeneration; + inner = accumulator(current, x); + } + + oldSub?.Dispose(); + + IDisposable sub = inner.Subscribe( + v => + { + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + current = v; + observer.OnNext(v); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + innerActive = false; + shouldComplete = sourceCompleted; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + sub.Dispose(); + return; + } + + innerSub = sub; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + shouldComplete = !innerActive; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + innerSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Extensions/TransformationExtensions.cs b/R3Ext/Extensions/TransformationExtensions.cs new file mode 100644 index 0000000..8a31633 --- /dev/null +++ b/R3Ext/Extensions/TransformationExtensions.cs @@ -0,0 +1,172 @@ +using R3; + +namespace R3Ext; + +/// +/// Transformation extensions for R3 observables. +/// +public static class TransformationExtensions +{ + /// + /// Projects each element to a constant value, replacing all upstream values. + /// + public static Observable MapTo(this Observable source, TResult constant) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return source.Select(_ => constant); + } + + /// + /// Applies a selector to each element and filters out null results (reference type variant). + /// + public static Observable CompactMap(this Observable source, Func selector) + where TResult : class + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return source.Select(selector).Where(x => x is not null).Select(x => x!); + } + + /// + /// Applies a selector to each element and filters out null results (value type variant). + /// + public static Observable CompactMap(this Observable source, Func selector) + where TResult : struct + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return source.Select(selector).Where(x => x.HasValue).Select(x => x!.Value); + } + + /// + /// Pairs each element with its zero-based index in the sequence. + /// + public static Observable<(T Value, int Index)> WithIndex(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create<(T Value, int Index)>(observer => + { + Lock gate = new(); + bool disposed = false; + int index = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext((x, index++)); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Applies an accumulator function over the source sequence, emitting each intermediate result. + /// Equivalent to Scan with a seed value. + /// + public static Observable RunningFold( + this Observable source, + TAccumulate seed, + Func accumulator) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (accumulator is null) + { + throw new ArgumentNullException(nameof(accumulator)); + } + + return source.Scan(seed, accumulator); + } + + /// + /// Applies an accumulator function over the source sequence without a seed, emitting each intermediate result. + /// Equivalent to Scan without a seed. + /// + public static Observable RunningReduce(this Observable source, Func accumulator) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (accumulator is null) + { + throw new ArgumentNullException(nameof(accumulator)); + } + + return source.Scan(accumulator); + } +} From 01ab9016d185e38965220855e9fbe171fb0f1f18 Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:02:32 -0500 Subject: [PATCH 2/9] feat: add advanced timing operators (TimeInterval, DelayWhen, RateLimit, BufferWithOverflow, Chunked) Add five new timing extension operators to the R3Ext library: - TimeInterval: wraps each emission with elapsed time since previous - DelayWhen: delays each element by a per-element duration observable, with optional subscription-delay overload - RateLimit: allows at most N items per period, queuing excess - BufferWithOverflow: bounded pass-through buffer with DropOldest, DropLatest, or Error overflow strategies - Chunked: sliding or non-overlapping window by count with step Also adds TimeInterval struct, OverflowStrategy enum, and TimingAdvancedTests.cs covering 3+ tests per operator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- R3Ext.Tests/TimingAdvancedTests.cs | 465 ++++++++++++ R3Ext/Timing/TimingExtensions.Advanced.cs | 845 ++++++++++++++++++++++ 2 files changed, 1310 insertions(+) create mode 100644 R3Ext.Tests/TimingAdvancedTests.cs create mode 100644 R3Ext/Timing/TimingExtensions.Advanced.cs diff --git a/R3Ext.Tests/TimingAdvancedTests.cs b/R3Ext.Tests/TimingAdvancedTests.cs new file mode 100644 index 0000000..899b97b --- /dev/null +++ b/R3Ext.Tests/TimingAdvancedTests.cs @@ -0,0 +1,465 @@ +using Microsoft.Extensions.Time.Testing; +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class TimingAdvancedTests +{ + // ── TimeInterval ───────────────────────────────────────────────────────── + + [Fact] + public void TimeInterval_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.TimeInterval()); + } + + [Fact] + public void TimeInterval_MeasuresElapsedBetweenEmissions() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(1); // interval = 0 + tp.Advance(TimeSpan.FromSeconds(2)); + subject.OnNext(2); // interval ≈ 2s + + Assert.Equal(2, result.Count); + Assert.Equal(TimeSpan.Zero, result[0].Interval); + Assert.True(result[1].Interval >= TimeSpan.FromSeconds(1)); + Assert.Equal(1, result[0].Value); + Assert.Equal(2, result[1].Value); + } + + [Fact] + public void TimeInterval_FirstItem_HasZeroInterval() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(42); + + Assert.Single(result); + Assert.Equal(TimeSpan.Zero, result[0].Interval); + Assert.Equal(42, result[0].Value); + } + + [Fact] + public void TimeInterval_MultipleItems_MeasuresEachGap() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(1); + tp.Advance(TimeSpan.FromSeconds(1)); + subject.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(3)); + subject.OnNext(3); + + Assert.Equal(3, result.Count); + Assert.Equal(TimeSpan.Zero, result[0].Interval); + Assert.Equal(TimeSpan.FromSeconds(1), result[1].Interval); + Assert.Equal(TimeSpan.FromSeconds(3), result[2].Interval); + } + + [Fact] + public void TimeInterval_Deconstruct_WorksCorrectly() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(99); + + var (value, interval) = result[0]; + Assert.Equal(99, value); + Assert.Equal(TimeSpan.Zero, interval); + } + + [Fact] + public void TimeInterval_Completion_PropagatesDownstream() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + Assert.Single(result); + } + + // ── DelayWhen ───────────────────────────────────────────────────────────── + + [Fact] + public void DelayWhen_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.DelayWhen(_ => Observable.Return(Unit.Default))); + } + + [Fact] + public void DelayWhen_NullSelector_ThrowsArgumentNullException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.DelayWhen(null!)); + } + + [Fact] + public void DelayWhen_EmitsAfterDurationObservable() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.DelayWhen(_ => trigger).ToLiveList(); + + subject.OnNext(42); + Assert.Empty(result.ToArray()); // not emitted yet + + trigger.OnNext(Unit.Default); + Assert.Equal(new[] { 42 }, result.ToArray()); // now emitted + } + + [Fact] + public void DelayWhen_MultipleItems_EachDelayedIndependently() + { + Subject subject = new(); + Subject trigger1 = new(); + Subject trigger2 = new(); + int callCount = 0; + Subject[] triggers = [trigger1, trigger2]; + + LiveList result = subject.DelayWhen(_ => triggers[callCount++]).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + + Assert.Empty(result.ToArray()); + + trigger2.OnNext(Unit.Default); // item 2 fires first + Assert.Equal(new[] { 2 }, result.ToArray()); + + trigger1.OnNext(Unit.Default); // item 1 fires second + Assert.Equal(new[] { 2, 1 }, result.ToArray()); + } + + [Fact] + public void DelayWhen_DurationCompletesWithoutEmitting_SkipsItem() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.DelayWhen(_ => trigger).ToLiveList(); + + subject.OnNext(42); + trigger.OnCompleted(); // completes without emitting + + Assert.Empty(result.ToArray()); // item was not emitted + } + + [Fact] + public void DelayWhen_SourceCompletion_WaitsForInFlightItems() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.DelayWhen(_ => trigger).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.False(result.IsCompleted); // still waiting for trigger + + trigger.OnNext(Unit.Default); + Assert.Equal(new[] { 1 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void DelayWhen_WithSubscriptionDelay_NullDelay_Throws() + { + var source = Observable.Return(1); + Assert.Throws(() => + source.DelayWhen(_ => Observable.Return(Unit.Default), null!)); + } + + [Fact] + public void DelayWhen_WithSubscriptionDelay_DelaysSubscription() + { + Subject source = new(); + Subject subscriptionTrigger = new(); + Subject itemTrigger = new(); + LiveList result = source.DelayWhen(_ => itemTrigger, subscriptionTrigger).ToLiveList(); + + // Items emitted before the subscription delay fires are lost. + source.OnNext(1); + Assert.Empty(result.ToArray()); + + subscriptionTrigger.OnNext(Unit.Default); // subscription starts now + source.OnNext(2); // this one is captured + itemTrigger.OnNext(Unit.Default); + + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + // ── RateLimit ───────────────────────────────────────────────────────────── + + [Fact] + public void RateLimit_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.RateLimit(2, TimeSpan.FromSeconds(1))); + } + + [Fact] + public void RateLimit_ZeroCount_ThrowsArgumentOutOfRangeException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.RateLimit(0, TimeSpan.FromSeconds(1))); + } + + [Fact] + public void RateLimit_NegativePeriod_ThrowsArgumentOutOfRangeException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.RateLimit(2, TimeSpan.FromMilliseconds(-1))); + } + + [Fact] + public void RateLimit_AllowsUpToCountPerPeriod() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(2, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); // 3rd queued + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + + tp.Advance(TimeSpan.FromSeconds(1)); // next window + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void RateLimit_UnderLimit_EmitsAllImmediately() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(5, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void RateLimit_MultipleWindows_DrainsQueueAcrossWindows() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(2, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + // Flood 6 items; 2 per window + for (int i = 1; i <= 6; i++) + { + subject.OnNext(i); + } + + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + + tp.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(new[] { 1, 2, 3, 4 }, result.ToArray()); + + tp.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, result.ToArray()); + } + + [Fact] + public void RateLimit_SourceCompletion_FlushesRemainingQueue() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(1, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnCompleted(); + + // All items flushed on completion regardless of window + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + // ── BufferWithOverflow ─────────────────────────────────────────────────── + + [Fact] + public void BufferWithOverflow_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.BufferWithOverflow(2)); + } + + [Fact] + public void BufferWithOverflow_ZeroCapacity_ThrowsArgumentOutOfRangeException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.BufferWithOverflow(0)); + } + + [Fact] + public void BufferWithOverflow_DropOldest_RemovesOldItem() + { + Subject subject = new(); + LiveList result = subject.BufferWithOverflow(2, OverflowStrategy.DropOldest).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + + Assert.Contains(2, result.ToArray()); + Assert.Contains(3, result.ToArray()); + } + + [Fact] + public void BufferWithOverflow_DropLatest_IgnoresIncomingItemWhenFull() + { + Subject subject = new(); + LiveList result = subject.BufferWithOverflow(2, OverflowStrategy.DropLatest).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); // 3 is dropped + + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + Assert.DoesNotContain(3, result.ToArray()); + } + + [Fact] + public void BufferWithOverflow_Error_SignalsErrorOnOverflow() + { + Subject subject = new(); + List errors = new(); + LiveList result = subject + .BufferWithOverflow(2, OverflowStrategy.Error) + .Do(onErrorResume: ex => errors.Add(ex)) + .ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); // 3 triggers error + + Assert.Single(errors); + Assert.IsType(errors[0]); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + } + + [Fact] + public void BufferWithOverflow_UnderCapacity_PassesAllItemsThrough() + { + Subject subject = new(); + LiveList result = subject.BufferWithOverflow(10, OverflowStrategy.DropOldest).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void BufferWithOverflow_Completion_PropagatesDownstream() + { + Subject subject = new(); + LiveList result = subject.BufferWithOverflow(5).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + Assert.Single(result); + } + + // ── Chunked ─────────────────────────────────────────────────────────────── + + [Fact] + public void Chunked_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.Chunked(3)); + } + + [Fact] + public void Chunked_ZeroSize_ThrowsArgumentOutOfRangeException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.Chunked(0)); + } + + [Fact] + public void Chunked_NonOverlapping_EmitsCorrectChunks() + { + Subject subject = new(); + LiveList result = subject.Chunked(3).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnNext(4); subject.OnNext(5); subject.OnNext(6); + subject.OnCompleted(); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 1, 2, 3 }, result[0]); + Assert.Equal(new[] { 4, 5, 6 }, result[1]); + } + + [Fact] + public void Chunked_PartialChunk_NotEmittedOnCompletion() + { + Subject subject = new(); + LiveList result = subject.Chunked(3).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); // only 2, never fills the chunk + subject.OnCompleted(); + + Assert.Empty(result); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Chunked_OverlappingStep_EmitsCorrectWindows() + { + Subject subject = new(); + LiveList result = subject.Chunked(size: 3, step: 1).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnNext(4); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 1, 2, 3 }, result[0]); + Assert.Equal(new[] { 2, 3, 4 }, result[1]); + } + + [Fact] + public void Chunked_StepLargerThanSize_LeavesGapsBetweenChunks() + { + Subject subject = new(); + LiveList result = subject.Chunked(size: 2, step: 3).ToLiveList(); + + // items: 1 2 3 4 5 6 + // chunk1: ^ ^ (starts at count=0, size=2) + // chunk2: ^ ^ (starts at count=3, size=2) + subject.OnNext(1); subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); subject.OnNext(5); + subject.OnNext(6); + subject.OnCompleted(); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 1, 2 }, result[0]); + Assert.Equal(new[] { 4, 5 }, result[1]); + } + + [Fact] + public void Chunked_SingleItemChunks_EmitsEachItem() + { + Subject subject = new(); + LiveList result = subject.Chunked(size: 1).ToLiveList(); + + subject.OnNext(10); subject.OnNext(20); subject.OnNext(30); + + Assert.Equal(3, result.Count); + Assert.Equal(new[] { 10 }, result[0]); + Assert.Equal(new[] { 20 }, result[1]); + Assert.Equal(new[] { 30 }, result[2]); + } +} diff --git a/R3Ext/Timing/TimingExtensions.Advanced.cs b/R3Ext/Timing/TimingExtensions.Advanced.cs new file mode 100644 index 0000000..75de6df --- /dev/null +++ b/R3Ext/Timing/TimingExtensions.Advanced.cs @@ -0,0 +1,845 @@ +using R3; + +namespace R3Ext; + +public readonly struct TimeInterval +{ + public T Value { get; } + + public TimeSpan Interval { get; } + + public TimeInterval(T value, TimeSpan interval) + { + Value = value; + Interval = interval; + } + + public void Deconstruct(out T value, out TimeSpan interval) + { + value = Value; + interval = Interval; + } +} + +public enum OverflowStrategy +{ + DropOldest, + DropLatest, + Error, +} + +public static partial class TimingExtensions +{ + /// + /// Wraps each emitted value with the elapsed time since the previous emission. + /// The first item is wrapped with . + /// + public static Observable> TimeInterval( + this Observable source, + TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + + return Observable.Create>(observer => + { + Lock gate = new(); + bool disposed = false; + DateTimeOffset? lastTime = null; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + TimeInterval item; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + DateTimeOffset now = tp.GetUtcNow(); + TimeSpan interval = lastTime.HasValue ? now - lastTime.Value : TimeSpan.Zero; + lastTime = now; + item = new TimeInterval(x, interval); + } + + observer.OnNext(item); + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Delays each element by an amount determined by a per-element observable. + /// The element is emitted when its duration observable emits its first value. + /// Multiple elements can be in-flight simultaneously. + /// + public static Observable DelayWhen( + this Observable source, + Func> delayDurationSelector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (delayDurationSelector is null) + { + throw new ArgumentNullException(nameof(delayDurationSelector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + Result completionResult = default; + int activeInner = 0; + IDisposable? upstream = null; + List innerSubs = new(); + + void CheckComplete() + { + // Must be called under gate. + if (sourceCompleted && activeInner == 0) + { + observer.OnCompleted(completionResult); + } + } + + upstream = source.Subscribe( + x => + { + bool shouldContinue; + using (gate.EnterScope()) + { + shouldContinue = !disposed; + if (shouldContinue) + { + activeInner++; + } + } + + if (!shouldContinue) + { + return; + } + + bool innerFired = false; + IDisposable? innerSub = null; + + innerSub = delayDurationSelector(x).Subscribe( + _ => + { + bool shouldEmit; + using (gate.EnterScope()) + { + shouldEmit = !disposed && !innerFired; + if (!innerFired) + { + innerFired = true; + activeInner--; + if (innerSub is not null) + { + innerSubs.Remove(innerSub); + } + } + } + + if (shouldEmit) + { + observer.OnNext(x); + using (gate.EnterScope()) + { + CheckComplete(); + } + } + }, + ex => + { + bool wasActive; + using (gate.EnterScope()) + { + wasActive = !innerFired; + if (!innerFired) + { + innerFired = true; + activeInner--; + if (innerSub is not null) + { + innerSubs.Remove(innerSub); + } + } + } + + if (wasActive && !disposed) + { + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (!innerFired) + { + innerFired = true; + activeInner--; + if (innerSub is not null) + { + innerSubs.Remove(innerSub); + } + + CheckComplete(); + } + } + }); + + // Register the subscription for cleanup; handle the case where the + // inner observable fired synchronously (innerFired already true). + using (gate.EnterScope()) + { + if (!disposed && !innerFired && innerSub is not null) + { + innerSubs.Add(innerSub); + } + else if (!innerFired && disposed) + { + innerFired = true; + activeInner--; + innerSub?.Dispose(); + } + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + completionResult = r; + CheckComplete(); + } + }); + + return Disposable.Create(() => + { + List toDispose; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + toDispose = new List(innerSubs); + innerSubs.Clear(); + } + + upstream?.Dispose(); + foreach (IDisposable d in toDispose) + { + d.Dispose(); + } + }); + }); + } + + /// + /// Delays each element by an amount determined by a per-element observable, + /// and delays the subscription to until + /// emits its first value. + /// + public static Observable DelayWhen( + this Observable source, + Func> delayDurationSelector, + Observable subscriptionDelay) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (delayDurationSelector is null) + { + throw new ArgumentNullException(nameof(delayDurationSelector)); + } + + if (subscriptionDelay is null) + { + throw new ArgumentNullException(nameof(subscriptionDelay)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? delaySub = null; + IDisposable? mainSub = null; + + delaySub = subscriptionDelay.Subscribe( + _ => + { + bool shouldSubscribe; + using (gate.EnterScope()) + { + shouldSubscribe = !disposed && mainSub is null; + } + + if (!shouldSubscribe) + { + return; + } + + IDisposable inner = source.DelayWhen(delayDurationSelector).Subscribe( + v => observer.OnNext(v), + ex => observer.OnErrorResume(ex), + r => observer.OnCompleted(r)); + + using (gate.EnterScope()) + { + if (disposed) + { + inner.Dispose(); + } + else + { + mainSub = inner; + } + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed || mainSub is not null) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + } + + delaySub?.Dispose(); + mainSub?.Dispose(); + }); + }); + } + + /// + /// Allows at most items per . + /// Excess items are queued and emitted in subsequent windows. + /// + public static Observable RateLimit( + this Observable source, + int count, + TimeSpan period, + TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (period <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(period)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + Result completionResult = default; + int emittedThisWindow = 0; + Queue queue = new(); + IDisposable? upstream = null; + ITimer? timer = null; + + void EnsureTimer() + { + if (timer is null) + { + timer = tp.CreateTimer( + _ => + { + List? toEmit = null; + bool complete = false; + Result result = default; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + emittedThisWindow = 0; + + if (queue.Count > 0) + { + toEmit = new List(); + int drainCount = Math.Min(queue.Count, count); + for (int i = 0; i < drainCount; i++) + { + toEmit.Add(queue.Dequeue()); + emittedThisWindow++; + } + } + + if (sourceCompleted && queue.Count == 0) + { + complete = true; + result = completionResult; + timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + } + + if (toEmit is not null) + { + foreach (T item in toEmit) + { + observer.OnNext(item); + } + } + + if (complete) + { + observer.OnCompleted(result); + } + }, + null, period, period); + } + } + + upstream = source.Subscribe( + x => + { + bool shouldEmit; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + EnsureTimer(); + + if (emittedThisWindow < count) + { + emittedThisWindow++; + shouldEmit = true; + } + else + { + queue.Enqueue(x); + shouldEmit = false; + } + } + + if (shouldEmit) + { + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + observer.OnErrorResume(ex); + }, + r => + { + List? remaining = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + completionResult = r; + timer?.Dispose(); + + if (queue.Count > 0) + { + remaining = queue.ToList(); + queue.Clear(); + } + } + + if (remaining is not null) + { + foreach (T item in remaining) + { + observer.OnNext(item); + } + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + timer?.Dispose(); + upstream?.Dispose(); + queue.Clear(); + } + }); + }); + } + + /// + /// Passes items through while maintaining an internal bounded buffer. + /// When the buffer is at , the configured + /// determines how overflow is handled. + /// + public static Observable BufferWithOverflow( + this Observable source, + int capacity, + OverflowStrategy strategy = OverflowStrategy.DropOldest, + TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + Queue buffer = new(); + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + bool shouldEmit = true; + Exception? overflowError = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (buffer.Count >= capacity) + { + switch (strategy) + { + case OverflowStrategy.DropOldest: + buffer.Dequeue(); + buffer.Enqueue(x); + break; + + case OverflowStrategy.DropLatest: + shouldEmit = false; + break; + + case OverflowStrategy.Error: + overflowError = new InvalidOperationException( + $"Buffer overflow: capacity {capacity} exceeded."); + shouldEmit = false; + break; + } + } + else + { + buffer.Enqueue(x); + } + } + + if (overflowError is not null) + { + observer.OnErrorResume(overflowError); + } + else if (shouldEmit) + { + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + buffer.Clear(); + } + }); + }); + } + + /// + /// Emits overlapping or non-overlapping arrays of a specified size, advancing by + /// items between each window. + /// When is 0, it defaults to (non-overlapping). + /// Partial chunks at completion are discarded. + /// + public static Observable Chunked( + this Observable source, + int size, + int step = 0) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (size <= 0) + { + throw new ArgumentOutOfRangeException(nameof(size)); + } + + if (step < 0) + { + throw new ArgumentOutOfRangeException(nameof(step)); + } + + int effectiveStep = step == 0 ? size : step; + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + List> openChunks = new(); + int itemCount = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + List? toEmit = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (itemCount % effectiveStep == 0) + { + openChunks.Add(new List()); + } + + foreach (List chunk in openChunks) + { + chunk.Add(x); + } + + itemCount++; + + // Collect completed chunks back-to-front to allow safe RemoveAt, + // then reverse so they are emitted oldest-first. + for (int i = openChunks.Count - 1; i >= 0; i--) + { + if (openChunks[i].Count >= size) + { + toEmit ??= new List(); + toEmit.Add(openChunks[i].ToArray()); + openChunks.RemoveAt(i); + } + } + + toEmit?.Reverse(); + } + + if (toEmit is not null) + { + foreach (T[] chunk in toEmit) + { + observer.OnNext(chunk); + } + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + openChunks.Clear(); + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + openChunks.Clear(); + } + }); + }); + } +} From 23fc8b80ac4d8e7d5ec0775b8d9cf9fcfd596104 Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:07:05 -0500 Subject: [PATCH 3/9] feat: add advanced filtering operators (IgnoreElements, IsEmpty, Every/All, Find, FindIndex, DefaultIfEmpty, ThrowIfEmpty, Audit, AuditTime, Sample) - Add FilteringExtensions.Advanced.cs with 10 new operators using Pattern A (compositional) and Pattern B (Observable.Create with Lock gate) - Make FilteringExtensions partial to support the new file - Add FilteringAdvancedTests.cs with 45 tests covering all operators (3+ per operator) - Fix pre-existing CS compilation errors in CombinationExtensionsTests.cs and WindowingOperatorsTests.cs that blocked test execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- R3Ext.Tests/CombinationExtensionsTests.cs | 424 +++++++++ R3Ext.Tests/FilteringAdvancedTests.cs | 519 +++++++++++ R3Ext.Tests/WindowingOperatorsTests.cs | 484 ++++++++++ .../FilteringExtensions.Advanced.cs | 823 ++++++++++++++++++ R3Ext/Extensions/FilteringExtensions.cs | 2 +- 5 files changed, 2251 insertions(+), 1 deletion(-) create mode 100644 R3Ext.Tests/CombinationExtensionsTests.cs create mode 100644 R3Ext.Tests/FilteringAdvancedTests.cs create mode 100644 R3Ext.Tests/WindowingOperatorsTests.cs create mode 100644 R3Ext/Extensions/FilteringExtensions.Advanced.cs diff --git a/R3Ext.Tests/CombinationExtensionsTests.cs b/R3Ext.Tests/CombinationExtensionsTests.cs new file mode 100644 index 0000000..46d2d9f --- /dev/null +++ b/R3Ext.Tests/CombinationExtensionsTests.cs @@ -0,0 +1,424 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +using System; +using System.Collections.Generic; +using R3; +using R3.Collections; +using Xunit; + +#pragma warning disable SA1503, SA1513, SA1515, SA1107, SA1502, SA1508, SA1516 + +namespace R3Ext.Tests; + +public class CombinationExtensionsTests +{ + #region ForkJoin + + [Fact] + public void ForkJoin_EmitsLastValuesWhenAllComplete() + { + Subject s1 = new(), s2 = new(), s3 = new(); + LiveList<(int, int, int)> result = CombinationExtensions.ForkJoin(s1, s2, s3).ToLiveList(); + + s1.OnNext(1); s1.OnNext(2); s1.OnCompleted(); + s2.OnNext(10); s2.OnCompleted(); + Assert.Empty(result.ToArray()); + + s3.OnNext(100); s3.OnCompleted(); + Assert.Single(result.ToArray()); + Assert.Equal((2, 10, 100), result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_EmptySourcesReturnsEmptyArray() + { + LiveList result = CombinationExtensions.ForkJoin().ToLiveList(); + Assert.Single(result.ToArray()); + Assert.Empty(result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_SingleSourceEmitsLastValue() + { + Subject s = new(); + LiveList result = CombinationExtensions.ForkJoin(s).ToLiveList(); + + s.OnNext(5); + s.OnNext(99); + s.OnCompleted(); + + Assert.Single(result.ToArray()); + Assert.Equal(new[] { 99 }, result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_SourceWithNoValueCompletesWithFailure() + { + Subject s1 = new(), s2 = new(); + bool completed = false; + + CombinationExtensions.ForkJoin(s1, s2).Subscribe( + _ => { }, + _ => { }, + r => { completed = true; Assert.True(r.IsFailure); }); + + s1.OnNext(1); + s1.OnCompleted(); + s2.OnCompleted(); // s2 never emitted + + Assert.True(completed); + } + + [Fact] + public void ForkJoin_Typed_TwoSources() + { + Subject s1 = new(); + Subject s2 = new(); + LiveList<(int, string)> result = CombinationExtensions.ForkJoin(s1, s2).ToLiveList(); + + s1.OnNext(42); s1.OnCompleted(); + s2.OnNext("hello"); s2.OnCompleted(); + + Assert.Single(result.ToArray()); + Assert.Equal((42, "hello"), result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_Typed_ThreeSources() + { + Subject s1 = new(); + Subject s2 = new(); + Subject s3 = new(); + LiveList<(int, string, bool)> result = CombinationExtensions.ForkJoin(s1, s2, s3).ToLiveList(); + + s1.OnNext(1); s1.OnCompleted(); + s2.OnNext("x"); s2.OnCompleted(); + s3.OnNext(true); s3.OnCompleted(); + + Assert.Single(result.ToArray()); + Assert.Equal((1, "x", true), result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_NullSourcesThrows() + { + Assert.Throws(() => CombinationExtensions.ForkJoin((IEnumerable>)null!)); + } + + #endregion + + #region Generate + + [Fact] + public void Generate_ProducesSequence() + { + LiveList result = CombinationExtensions + .Generate(0, x => x < 5, x => x + 1, x => x * 2) + .ToLiveList(); + + Assert.Equal(new[] { 0, 2, 4, 6, 8 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Generate_EmptyWhenConditionFalseFromStart() + { + LiveList result = CombinationExtensions + .Generate(10, x => x < 5, x => x + 1, x => x) + .ToLiveList(); + + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Generate_SingleItem() + { + LiveList result = CombinationExtensions + .Generate(0, x => x < 1, x => x + 1, x => x + 100) + .ToLiveList(); + + Assert.Equal(new[] { 100 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Generate_StateOverload_ProducesSequence() + { + LiveList result = CombinationExtensions + .Generate(1, x => x <= 5, x => x + 1) + .ToLiveList(); + + Assert.Equal(new[] { 1, 2, 3, 4, 5 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Generate_NullConditionThrows() + { + Assert.Throws(() => + CombinationExtensions.Generate(0, null!, x => x + 1, x => x)); + } + + #endregion + + #region Iif / Condition + + [Fact] + public void Iif_SelectsBasedOnCondition() + { + bool flag = true; + LiveList result = CombinationExtensions + .Iif(() => flag, Observable.Return(1), Observable.Return(2)) + .ToLiveList(); + Assert.Equal(new[] { 1 }, result.ToArray()); + + flag = false; + LiveList result2 = CombinationExtensions + .Iif(() => flag, Observable.Return(1), Observable.Return(2)) + .ToLiveList(); + Assert.Equal(new[] { 2 }, result2.ToArray()); + } + + [Fact] + public void Iif_EvaluatesConditionAtSubscribeTime() + { + bool flag = true; + Observable obs = CombinationExtensions.Iif(() => flag, Observable.Return(10), Observable.Return(20)); + + flag = false; + LiveList result = obs.ToLiveList(); + + // Condition evaluated at subscribe time (when ToLiveList subscribes), flag is false + Assert.Equal(new[] { 20 }, result.ToArray()); + } + + [Fact] + public void Iif_NullConditionThrows() + { + Assert.Throws(() => + CombinationExtensions.Iif(null!, Observable.Return(1), Observable.Return(2))); + } + + [Fact] + public void Condition_IsAliasForIif() + { + bool flag = true; + LiveList result = CombinationExtensions + .Condition(() => flag, Observable.Return(42), Observable.Return(0)) + .ToLiveList(); + Assert.Equal(new[] { 42 }, result.ToArray()); + } + + #endregion + + #region SequenceEqual + + [Fact] + public void SequenceEqual_TrueForEqualSequences() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2).ToLiveList(); + + s1.OnNext(1); s2.OnNext(1); + s1.OnNext(2); s2.OnNext(2); + s1.OnCompleted(); s2.OnCompleted(); + + Assert.Equal(new[] { true }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_FalseOnMismatch() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2).ToLiveList(); + + s1.OnNext(1); s2.OnNext(99); + + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_FalseOnDifferentLengths() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2).ToLiveList(); + + s1.OnNext(1); + s1.OnCompleted(); + s2.OnNext(1); + s2.OnNext(2); + s2.OnCompleted(); + + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_TrueForBothEmpty() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2).ToLiveList(); + + s1.OnCompleted(); + s2.OnCompleted(); + + Assert.Equal(new[] { true }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_UsesCustomComparer() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2, StringComparer.OrdinalIgnoreCase).ToLiveList(); + + s1.OnNext("Hello"); s2.OnNext("hello"); + s1.OnCompleted(); s2.OnCompleted(); + + Assert.Equal(new[] { true }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_NullSourceThrows() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.SequenceEqual(Observable.Return(1))); + } + + #endregion + + #region OnErrorResumeNext + + [Fact] + public void OnErrorResumeNext_ContinuesAfterError() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.OnErrorResumeNext(s2).ToLiveList(); + + s1.OnNext(1); + s1.OnErrorResume(new Exception()); + s2.OnNext(2); + s2.OnCompleted(); + + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void OnErrorResumeNext_ContinuesAfterSuccess() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.OnErrorResumeNext(s2).ToLiveList(); + + s1.OnNext(10); + s1.OnCompleted(); + s2.OnNext(20); + s2.OnCompleted(); + + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void OnErrorResumeNext_MultipleSources() + { + Subject s1 = new(), s2 = new(), s3 = new(); + LiveList result = CombinationExtensions.OnErrorResumeNext(s1, s2, s3).ToLiveList(); + + s1.OnNext(1); s1.OnCompleted(); + s2.OnNext(2); s2.OnErrorResume(new Exception()); + s3.OnNext(3); s3.OnCompleted(); + + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void OnErrorResumeNext_EmptySourcesCompletes() + { + LiveList result = CombinationExtensions.OnErrorResumeNext().ToLiveList(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void OnErrorResumeNext_NullSourceThrows() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.OnErrorResumeNext(Observable.Return(1))); + } + + #endregion + + #region RepeatWhen + + [Fact] + public void RepeatWhen_RepeatsWhenNotifierEmits() + { + int subscribeCount = 0; + Subject trigger = new(); + + Observable source = Observable.Create(observer => + { + subscribeCount++; + observer.OnNext(subscribeCount); + observer.OnCompleted(); + return Disposable.Empty; + }); + + LiveList result = source.RepeatWhen(_ => trigger).ToLiveList(); + + Assert.Equal(new[] { 1 }, result.ToArray()); + trigger.OnNext(Unit.Default); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + trigger.OnNext(Unit.Default); + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void RepeatWhen_StopsWhenHandlerCompletes() + { + Subject trigger = new(); + int subscribeCount = 0; + + Observable source = Observable.Create(observer => + { + subscribeCount++; + observer.OnNext(subscribeCount); + observer.OnCompleted(); + return Disposable.Empty; + }); + + LiveList result = source.RepeatWhen(_ => trigger).ToLiveList(); + + trigger.OnNext(Unit.Default); + trigger.OnCompleted(); + + Assert.True(result.IsCompleted); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + } + + [Fact] + public void RepeatWhen_NullSourceThrows() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.RepeatWhen(_ => Observable.Return(Unit.Default))); + } + + [Fact] + public void RepeatWhen_NullHandlerThrows() + { + Assert.Throws(() => + Observable.Return(1).RepeatWhen(null!)); + } + + #endregion +} diff --git a/R3Ext.Tests/FilteringAdvancedTests.cs b/R3Ext.Tests/FilteringAdvancedTests.cs new file mode 100644 index 0000000..647ab4b --- /dev/null +++ b/R3Ext.Tests/FilteringAdvancedTests.cs @@ -0,0 +1,519 @@ +using Microsoft.Extensions.Time.Testing; +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class FilteringAdvancedTests +{ + // ─── IgnoreElements ─────────────────────────────────────────────────────── + + [Fact] + public void IgnoreElements_SuppressesOnNext() + { + Subject subject = new(); + LiveList result = subject.IgnoreElements().ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + Assert.Empty(result.ToArray()); + subject.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IgnoreElements_ForwardsError() + { + Subject subject = new(); + List errors = new(); + using IDisposable _ = subject.IgnoreElements().Subscribe(_ => { }, errors.Add, _ => { }); + subject.OnErrorResume(new InvalidOperationException("boom")); + Assert.Single(errors); + Assert.IsType(errors[0]); + } + + [Fact] + public void IgnoreElements_ForwardsCompletion() + { + Subject subject = new(); + LiveList result = subject.IgnoreElements().ToLiveList(); + subject.OnNext(42); + Assert.Empty(result.ToArray()); + subject.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IgnoreElements_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.IgnoreElements()); + } + + // ─── IsEmpty ───────────────────────────────────────────────────────────── + + [Fact] + public void IsEmpty_TrueWhenNoElements() + { + Subject subject = new(); + LiveList result = subject.IsEmpty().ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { true }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IsEmpty_FalseOnFirstElement() + { + Subject subject = new(); + LiveList result = subject.IsEmpty().ToLiveList(); + subject.OnNext(42); + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IsEmpty_CompletesAfterFirstElement_IgnoresSubsequent() + { + Subject subject = new(); + LiveList result = subject.IsEmpty().ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IsEmpty_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.IsEmpty()); + } + + // ─── Every / All ───────────────────────────────────────────────────────── + + [Fact] + public void Every_FalseOnFirstFail() + { + Subject subject = new(); + LiveList result = subject.Every(x => x > 0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(-1); + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Every_TrueOnCompletion() + { + Subject subject = new(); + LiveList result = subject.Every(x => x > 0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Equal(new[] { true }, result.ToArray()); + } + + [Fact] + public void Every_TrueWhenEmpty() + { + Subject subject = new(); + LiveList result = subject.Every(x => x > 0).ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { true }, result.ToArray()); + } + + [Fact] + public void Every_FalseOnVeryFirstValue() + { + Subject subject = new(); + LiveList result = subject.Every(x => x > 0).ToLiveList(); + subject.OnNext(-5); + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void All_IsAliasForEvery() + { + Subject subject = new(); + LiveList result = subject.All(x => x > 0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Equal(new[] { true }, result.ToArray()); + } + + [Fact] + public void Every_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.Every(x => x > 0)); + } + + [Fact] + public void Every_ThrowsOnNullPredicate() + { + Subject subject = new(); + Assert.Throws(() => subject.Every(null!)); + } + + // ─── Find ──────────────────────────────────────────────────────────────── + + [Fact] + public void Find_EmitsFirstMatchAndCompletes() + { + Subject subject = new(); + LiveList result = subject.Find(x => x > 5).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(10); + subject.OnNext(20); + Assert.Equal(new[] { 10 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Find_CompletesWithoutEmitWhenNoMatch() + { + Subject subject = new(); + LiveList result = subject.Find(x => x > 100).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Find_EmitsFirstMatchOnly() + { + Subject subject = new(); + LiveList result = subject.Find(x => x % 2 == 0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(4); + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + [Fact] + public void Find_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.Find(x => x > 0)); + } + + // ─── FindIndex ─────────────────────────────────────────────────────────── + + [Fact] + public void FindIndex_EmitsCorrectIndex() + { + Subject subject = new(); + LiveList result = subject.FindIndex(x => x > 5).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(10); + Assert.Equal(new[] { 2 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void FindIndex_EmitsZeroForFirstElement() + { + Subject subject = new(); + LiveList result = subject.FindIndex(x => x > 0).ToLiveList(); + subject.OnNext(99); + Assert.Equal(new[] { 0 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void FindIndex_CompletesWithoutEmitWhenNoMatch() + { + Subject subject = new(); + LiveList result = subject.FindIndex(x => x > 100).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void FindIndex_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.FindIndex(x => x > 0)); + } + + // ─── DefaultIfEmpty ────────────────────────────────────────────────────── + + [Fact] + public void DefaultIfEmpty_EmitsDefaultWhenEmpty() + { + Subject subject = new(); + LiveList result = subject.DefaultIfEmpty(99).ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { 99 }, result.ToArray()); + } + + [Fact] + public void DefaultIfEmpty_DoesNotEmitDefaultWhenHasValues() + { + Subject subject = new(); + LiveList result = subject.DefaultIfEmpty(99).ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 1 }, result.ToArray()); + } + + [Fact] + public void DefaultIfEmpty_ForwardsAllValuesWhenPresent() + { + Subject subject = new(); + LiveList result = subject.DefaultIfEmpty(0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnCompleted(); + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void DefaultIfEmpty_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.DefaultIfEmpty(0)); + } + + // ─── ThrowIfEmpty ──────────────────────────────────────────────────────── + + [Fact] + public void ThrowIfEmpty_ThrowsDefaultExceptionWhenEmpty() + { + Subject subject = new(); + List completions = new(); + using IDisposable _ = subject.ThrowIfEmpty().Subscribe(_ => { }, _ => { }, completions.Add); + subject.OnCompleted(); + Assert.Single(completions); + Assert.True(completions[0].IsFailure); + Assert.IsType(completions[0].Exception); + } + + [Fact] + public void ThrowIfEmpty_UsesCustomExceptionFactory() + { + Subject subject = new(); + List completions = new(); + using IDisposable _ = subject + .ThrowIfEmpty(() => new ArgumentException("custom")) + .Subscribe(_ => { }, _ => { }, completions.Add); + subject.OnCompleted(); + Assert.Single(completions); + Assert.True(completions[0].IsFailure); + Assert.IsType(completions[0].Exception); + } + + [Fact] + public void ThrowIfEmpty_DoesNotThrowWhenHasValues() + { + Subject subject = new(); + LiveList result = subject.ThrowIfEmpty().ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 1 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ThrowIfEmpty_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.ThrowIfEmpty()); + } + + // ─── Audit ─────────────────────────────────────────────────────────────── + + [Fact] + public void Audit_EmitsLatestWhenDurationFires() + { + Subject source = new(); + Subject durationSubject = new(); + LiveList result = source.Audit(_ => durationSubject).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + durationSubject.OnNext(Unit.Default); + + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + [Fact] + public void Audit_EmitsNothingIfNoDurationFire() + { + Subject source = new(); + Subject duration = new(); + LiveList result = source.Audit(_ => duration).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + source.OnCompleted(); + + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Audit_ResetsAfterDurationFiresAndAcceptsNewValue() + { + Subject source = new(); + Subject duration1 = new(); + Subject duration2 = new(); + int call = 0; + Subject[] durations = [duration1, duration2]; + + LiveList result = source.Audit(_ => durations[call++ % 2]).ToLiveList(); + + source.OnNext(10); + duration1.OnNext(Unit.Default); + Assert.Equal(new[] { 10 }, result.ToArray()); + + source.OnNext(20); + duration2.OnNext(Unit.Default); + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + } + + [Fact] + public void Audit_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.Audit(_ => Observable.Never())); + } + + // ─── AuditTime ─────────────────────────────────────────────────────────── + + [Fact] + public void AuditTime_EmitsLatestAfterDuration() + { + FakeTimeProvider tp = new(); + Subject source = new(); + LiveList result = source.AuditTime(TimeSpan.FromSeconds(1), tp).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(1)); + + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + [Fact] + public void AuditTime_DoesNotEmitBeforeDuration() + { + FakeTimeProvider tp = new(); + Subject source = new(); + LiveList result = source.AuditTime(TimeSpan.FromSeconds(5), tp).ToLiveList(); + + source.OnNext(42); + tp.Advance(TimeSpan.FromSeconds(4)); + + Assert.Empty(result.ToArray()); + } + + [Fact] + public void AuditTime_CompletesWhenSourceCompletes() + { + FakeTimeProvider tp = new(); + Subject source = new(); + LiveList result = source.AuditTime(TimeSpan.FromSeconds(1), tp).ToLiveList(); + + source.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AuditTime_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.AuditTime(TimeSpan.FromSeconds(1))); + } + + // ─── Sample ────────────────────────────────────────────────────────────── + + [Fact] + public void Sample_EmitsLatestWhenSamplerFires() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + sampler.OnNext(Unit.Default); + + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + [Fact] + public void Sample_EmitsNothingIfNoSourceValue() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + sampler.OnNext(Unit.Default); + sampler.OnNext(Unit.Default); + + Assert.Empty(result.ToArray()); + } + + [Fact] + public void Sample_ResetsAfterEachSample() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + source.OnNext(10); + sampler.OnNext(Unit.Default); + Assert.Equal(new[] { 10 }, result.ToArray()); + + sampler.OnNext(Unit.Default); // no new value since last sample + Assert.Equal(new[] { 10 }, result.ToArray()); // nothing new emitted + + source.OnNext(20); + sampler.OnNext(Unit.Default); + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + } + + [Fact] + public void Sample_WithTypedSampler_EmitsLatest() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + source.OnNext(5); + sampler.OnNext("tick"); + + Assert.Equal(new[] { 5 }, result.ToArray()); + } + + [Fact] + public void Sample_CompletesWhenSourceCompletes() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + source.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Sample_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.Sample(new Subject())); + } +} diff --git a/R3Ext.Tests/WindowingOperatorsTests.cs b/R3Ext.Tests/WindowingOperatorsTests.cs new file mode 100644 index 0000000..7375164 --- /dev/null +++ b/R3Ext.Tests/WindowingOperatorsTests.cs @@ -0,0 +1,484 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +using Microsoft.Extensions.Time.Testing; +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class WindowingOperatorsTests +{ + // ----------------------------------------------------------------------- + // WindowCount – argument validation + // ----------------------------------------------------------------------- + + [Fact] + public void WindowCount_NullSource_Throws() + { + Observable? source = null; + Assert.Throws(() => source!.WindowCount(3)); + } + + [Fact] + public void WindowCount_ZeroCount_Throws() + { + Assert.Throws(() => Observable.Return(1).WindowCount(0)); + } + + [Fact] + public void WindowCount_NegativeSkip_Throws() + { + Assert.Throws(() => Observable.Return(1).WindowCount(3, -1)); + } + + // ----------------------------------------------------------------------- + // WindowCount – non-overlapping + // ----------------------------------------------------------------------- + + [Fact] + public void WindowCount_NonOverlapping_EmitsCorrectWindows() + { + Subject subject = new(); + List windows = new(); + subject.WindowCount(3).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); // window 1 + subject.OnNext(4); subject.OnNext(5); subject.OnNext(6); // window 2 + subject.OnCompleted(); + + Assert.Equal(2, windows.Count); + Assert.Equal(new[] { 1, 2, 3 }, windows[0]); + Assert.Equal(new[] { 4, 5, 6 }, windows[1]); + } + + [Fact] + public void WindowCount_NonOverlapping_IncompleteLastWindow_EmittedOnSourceComplete() + { + Subject subject = new(); + List windows = new(); + subject.WindowCount(3).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnNext(4); subject.OnNext(5); // incomplete last window + subject.OnCompleted(); + + Assert.Equal(2, windows.Count); + Assert.Equal(new[] { 1, 2, 3 }, windows[0]); + Assert.Equal(new[] { 4, 5 }, windows[1]); + } + + [Fact] + public void WindowCount_NonOverlapping_SingleElement_EachWindowHasOneItem() + { + Subject subject = new(); + List windows = new(); + subject.WindowCount(1).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(10); subject.OnNext(20); subject.OnNext(30); + subject.OnCompleted(); + + Assert.Equal(3, windows.Count); + Assert.Equal(new[] { 10 }, windows[0]); + Assert.Equal(new[] { 20 }, windows[1]); + Assert.Equal(new[] { 30 }, windows[2]); + } + + // ----------------------------------------------------------------------- + // WindowCount – overlapping + // ----------------------------------------------------------------------- + + [Fact] + public void WindowCount_Overlapping_WindowsShareElements() + { + Subject subject = new(); + List windows = new(); + // skip=2, count=3: W0={0,1,2}, W1={2,3,4} + subject.WindowCount(count: 3, skip: 2).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(0); subject.OnNext(1); subject.OnNext(2); + subject.OnNext(3); subject.OnNext(4); + subject.OnCompleted(); + + Assert.True(windows.Count >= 2); + Assert.Equal(new[] { 0, 1, 2 }, windows[0]); + Assert.Equal(new[] { 2, 3, 4 }, windows[1]); + } + + [Fact] + public void WindowCount_Overlapping_ExplicitSkipEqualsCount_NonOverlapping() + { + Subject subject = new(); + List windows = new(); + subject.WindowCount(count: 2, skip: 2).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); subject.OnNext(2); + subject.OnNext(3); subject.OnNext(4); + subject.OnCompleted(); + + Assert.Equal(2, windows.Count); + Assert.Equal(new[] { 1, 2 }, windows[0]); + Assert.Equal(new[] { 3, 4 }, windows[1]); + } + + // ----------------------------------------------------------------------- + // WindowTime – argument validation + // ----------------------------------------------------------------------- + + [Fact] + public void WindowTime_NullSource_Throws() + { + Observable? source = null; + Assert.Throws(() => source!.WindowTime(TimeSpan.FromSeconds(1))); + } + + [Fact] + public void WindowTime_ZeroTimeSpan_Throws() + { + Assert.Throws(() => + Observable.Return(1).WindowTime(TimeSpan.Zero)); + } + + [Fact] + public void WindowTime_NegativeTimeSpan_Throws() + { + Assert.Throws(() => + Observable.Return(1).WindowTime(TimeSpan.FromMilliseconds(-1))); + } + + // ----------------------------------------------------------------------- + // WindowTime – behaviour + // ----------------------------------------------------------------------- + + [Fact] + public async Task WindowTime_CreatesTimeBasedWindows() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + subject.WindowTime(TimeSpan.FromSeconds(1), tp).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); subject.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(1)); // close window 1, open window 2 + + subject.OnNext(3); + tp.Advance(TimeSpan.FromSeconds(1)); // close window 2, open window 3 + + subject.OnCompleted(); + await Task.Yield(); + + Assert.True(windows.Count >= 2); + Assert.Equal(new[] { 1, 2 }, windows[0]); + Assert.Equal(new[] { 3 }, windows[1]); + } + + [Fact] + public async Task WindowTime_EmptyWindowsAreEmitted() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + int windowCount = 0; + subject.WindowTime(TimeSpan.FromSeconds(1), tp).Subscribe(_ => windowCount++); + + tp.Advance(TimeSpan.FromSeconds(1)); // closes first (empty) window + tp.Advance(TimeSpan.FromSeconds(1)); // closes second (empty) window + + subject.OnCompleted(); + await Task.Yield(); + + Assert.True(windowCount >= 2, $"Expected at least 2 windows, got {windowCount}"); + } + + [Fact] + public async Task WindowTime_SourceCompleteFlushesCurrentWindow() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + subject.WindowTime(TimeSpan.FromSeconds(10), tp).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(42); + subject.OnCompleted(); // completes before timer fires + await Task.Yield(); + + Assert.Single(windows); + Assert.Equal(new[] { 42 }, windows[0]); + } + + // ----------------------------------------------------------------------- + // WindowTime with maxCount – argument validation + // ----------------------------------------------------------------------- + + [Fact] + public void WindowTimeMaxCount_ZeroMaxCount_Throws() + { + Assert.Throws(() => + Observable.Return(1).WindowTime(TimeSpan.FromSeconds(1), maxCount: 0)); + } + + // ----------------------------------------------------------------------- + // WindowTime with maxCount – behaviour + // ----------------------------------------------------------------------- + + [Fact] + public async Task WindowTimeMaxCount_ClosesOnCountBeforeTimer() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + subject.WindowTime(TimeSpan.FromSeconds(10), maxCount: 2, timeProvider: tp).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); subject.OnNext(2); // count hit – window 1 closes + subject.OnNext(3); subject.OnNext(4); // count hit – window 2 closes + subject.OnCompleted(); + await Task.Yield(); + + Assert.True(windows.Count >= 2); + Assert.Equal(new[] { 1, 2 }, windows[0]); + Assert.Equal(new[] { 3, 4 }, windows[1]); + } + + [Fact] + public async Task WindowTimeMaxCount_ClosesOnTimerBeforeCount() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + subject.WindowTime(TimeSpan.FromSeconds(1), maxCount: 10, timeProvider: tp).Subscribe(window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); subject.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(1)); // timer fires before count reached + + subject.OnCompleted(); + await Task.Yield(); + + Assert.True(windows.Count >= 1); + Assert.Equal(new[] { 1, 2 }, windows[0]); + } + + // ----------------------------------------------------------------------- + // BufferToggle – argument validation + // ----------------------------------------------------------------------- + + [Fact] + public void BufferToggle_NullSource_Throws() + { + Observable? source = null; + Assert.Throws(() => + source!.BufferToggle(Observable.Empty(), _ => Observable.Empty())); + } + + [Fact] + public void BufferToggle_NullOpenings_Throws() + { + Assert.Throws(() => + Observable.Return(1).BufferToggle(null!, _ => Observable.Empty())); + } + + [Fact] + public void BufferToggle_NullClosingSelector_Throws() + { + Assert.Throws(() => + Observable.Return(1).BufferToggle(Observable.Empty(), (Func>)null!)); + } + + // ----------------------------------------------------------------------- + // BufferToggle – behaviour + // ----------------------------------------------------------------------- + + [Fact] + public void BufferToggle_CollectsItemsInOpenWindows() + { + Subject source = new(); + Subject opens = new(); + Subject closes = new(); + LiveList result = source.BufferToggle(opens, _ => closes).ToLiveList(); + + opens.OnNext(Unit.Default); // open buffer + source.OnNext(1); source.OnNext(2); + closes.OnNext(Unit.Default); // close buffer + + Assert.Single(result); + Assert.Equal(new[] { 1, 2 }, result[0]); + } + + [Fact] + public void BufferToggle_ItemsBeforeOpenAreNotCollected() + { + Subject source = new(); + Subject opens = new(); + Subject closes = new(); + LiveList result = source.BufferToggle(opens, _ => closes).ToLiveList(); + + source.OnNext(99); // emitted before any buffer opens – should be ignored + opens.OnNext(Unit.Default); + source.OnNext(1); + closes.OnNext(Unit.Default); + + Assert.Single(result); + Assert.Equal(new[] { 1 }, result[0]); + } + + [Fact] + public void BufferToggle_MultipleConcurrentBuffers() + { + Subject source = new(); + Subject opens = new(); + Subject closes1 = new(); + Subject closes2 = new(); + int callCount = 0; + List> closers = new() { closes1, closes2 }; + + LiveList result = source.BufferToggle(opens, _ => + { + return closers[callCount++]; + }).ToLiveList(); + + opens.OnNext(Unit.Default); // open buffer A (closes1) + source.OnNext(1); + opens.OnNext(Unit.Default); // open buffer B (closes2) + source.OnNext(2); + closes1.OnNext(Unit.Default); // close A → {1, 2} + source.OnNext(3); + closes2.OnNext(Unit.Default); // close B → {2, 3} (2 was in-flight when B opened) + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 1, 2 }, result[0]); + Assert.Equal(new[] { 2, 3 }, result[1]); + } + + [Fact] + public void BufferToggle_SourceComplete_EmitsAllOpenBuffers() + { + Subject source = new(); + Subject opens = new(); + Subject closes = new(); + LiveList result = source.BufferToggle(opens, _ => closes).ToLiveList(); + + opens.OnNext(Unit.Default); + source.OnNext(5); source.OnNext(6); + source.OnCompleted(); // closes + emits open buffer + + Assert.Single(result); + Assert.Equal(new[] { 5, 6 }, result[0]); + Assert.True(result.IsCompleted); + } + + // ----------------------------------------------------------------------- + // BufferWhen – argument validation + // ----------------------------------------------------------------------- + + [Fact] + public void BufferWhen_NullSource_Throws() + { + Observable? source = null; + Assert.Throws(() => + source!.BufferWhen(() => Observable.Empty())); + } + + [Fact] + public void BufferWhen_NullClosingSelector_Throws() + { + Assert.Throws(() => + Observable.Return(1).BufferWhen((Func>)null!)); + } + + // ----------------------------------------------------------------------- + // BufferWhen – behaviour + // ----------------------------------------------------------------------- + + [Fact] + public void BufferWhen_EmitsOnClose() + { + Subject source = new(); + Subject closer = new(); + LiveList result = source.BufferWhen(() => closer).ToLiveList(); + + source.OnNext(1); source.OnNext(2); + closer.OnNext(Unit.Default); // emit buffer + + Assert.Single(result); + Assert.Equal(new[] { 1, 2 }, result[0]); + } + + [Fact] + public void BufferWhen_MultipleCloses_EmitsSuccessiveBuffers() + { + Subject source = new(); + Subject closer = new(); + LiveList result = source.BufferWhen(() => closer).ToLiveList(); + + source.OnNext(1); source.OnNext(2); + closer.OnNext(Unit.Default); + + Assert.Equal(new[] { 1, 2 }, result[0]); + + source.OnNext(3); + closer.OnNext(Unit.Default); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 3 }, result[1]); + } + + [Fact] + public void BufferWhen_SourceComplete_FlushesRemainingBuffer() + { + Subject source = new(); + Subject closer = new(); + LiveList result = source.BufferWhen(() => closer).ToLiveList(); + + source.OnNext(7); source.OnNext(8); + source.OnCompleted(); // flush without explicit close + + Assert.Single(result); + Assert.Equal(new[] { 7, 8 }, result[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void BufferWhen_CloseBeforeAnyItems_EmitsEmptyBuffer() + { + Subject source = new(); + Subject closer = new(); + LiveList result = source.BufferWhen(() => closer).ToLiveList(); + + closer.OnNext(Unit.Default); // close with no items + + Assert.Single(result); + Assert.Empty(result[0]); + } +} diff --git a/R3Ext/Extensions/FilteringExtensions.Advanced.cs b/R3Ext/Extensions/FilteringExtensions.Advanced.cs new file mode 100644 index 0000000..56eca75 --- /dev/null +++ b/R3Ext/Extensions/FilteringExtensions.Advanced.cs @@ -0,0 +1,823 @@ +using R3; + +namespace R3Ext; + +public static partial class FilteringExtensions +{ + /// + /// Suppresses all OnNext values; only passes through OnCompleted and OnErrorResume. + /// + public static Observable IgnoreElements(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + source.Subscribe( + _ => { }, + observer.OnErrorResume, + observer.OnCompleted)); + } + + /// + /// Emits false when the first value arrives then completes; emits true if the + /// source completes without emitting any value. + /// + public static Observable IsEmpty(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool hadValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + bool shouldComplete = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hadValue) + { + hadValue = true; + disposed = true; + shouldComplete = true; + } + } + + if (shouldComplete) + { + observer.OnNext(false); + observer.OnCompleted(); + upstream?.Dispose(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool emitTrue = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + emitTrue = !hadValue; + } + + if (emitTrue) + { + observer.OnNext(true); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits false as soon as predicate fails then completes; emits true when the + /// source completes successfully if all values passed the predicate. + /// + public static Observable Every(this Observable source, Func predicate) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + bool shouldFail = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!predicate(x)) + { + disposed = true; + shouldFail = true; + } + } + + if (shouldFail) + { + observer.OnNext(false); + observer.OnCompleted(); + upstream?.Dispose(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool emitTrue = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + emitTrue = r.IsSuccess; + } + + if (emitTrue) + { + observer.OnNext(true); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Alias for . Emits false as soon as predicate fails; emits + /// true on successful completion if all values passed. + /// + public static Observable All(this Observable source, Func predicate) + => source.Every(predicate); + + /// + /// Emits the first element matching then completes. + /// + public static Observable Find(this Observable source, Func predicate) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return source.Where(predicate).Take(1); + } + + /// + /// Emits the zero-based index of the first element matching then + /// completes. Completes without emitting if no match is found. + /// + public static Observable FindIndex(this Observable source, Func predicate) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + int index = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + bool found = false; + int foundIndex = 0; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (predicate(x)) + { + foundIndex = index; + found = true; + disposed = true; + } + else + { + index++; + } + } + + if (found) + { + observer.OnNext(foundIndex); + observer.OnCompleted(); + upstream?.Dispose(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits and completes if the source completes without emitting + /// any value; otherwise forwards all values as-is. + /// + public static Observable DefaultIfEmpty(this Observable source, T defaultValue) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + hasValue = true; + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool emitDefault = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + emitDefault = r.IsSuccess && !hasValue; + } + + if (emitDefault) + { + observer.OnNext(defaultValue); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// If the source completes successfully without emitting any value, completes downstream with a + /// failure result using the provided exception factory (defaults to + /// ). + /// + public static Observable ThrowIfEmpty(this Observable source, Func? exceptionFactory = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + hasValue = true; + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool throwEmpty = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + throwEmpty = r.IsSuccess && !hasValue; + } + + if (throwEmpty) + { + Exception ex = exceptionFactory?.Invoke() + ?? new InvalidOperationException("Sequence contains no elements."); + observer.OnCompleted(Result.Failure(ex)); + } + else + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the most-recent source value whenever the duration observable (produced by + /// for each source value) fires. Resets on each new source value. + /// + public static Observable Audit(this Observable source, Func> durationSelector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (durationSelector is null) + { + throw new ArgumentNullException(nameof(durationSelector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? latest = default; + bool hasLatest = false; + IDisposable? upstream = null; + IDisposable? durationSub = null; + + upstream = source.Subscribe( + x => + { + IDisposable? oldDurationSub = null; + Observable? duration = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + latest = x; + hasLatest = true; + oldDurationSub = durationSub; + durationSub = null; + duration = durationSelector(x); + } + + oldDurationSub?.Dispose(); + + IDisposable newSub = duration!.Subscribe( + _ => + { + T? toEmit = default; + bool shouldEmit = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (hasLatest) + { + toEmit = latest; + hasLatest = false; + shouldEmit = true; + } + } + + if (shouldEmit) + { + observer.OnNext(toEmit!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + _ => { }); + + using (gate.EnterScope()) + { + if (disposed) + { + newSub.Dispose(); + return; + } + + durationSub = newSub; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + IDisposable? sub = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub = durationSub; + durationSub = null; + } + + sub?.Dispose(); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + durationSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the most-recent source value after each fixed window. + /// + public static Observable AuditTime(this Observable source, TimeSpan duration, TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + return source.Audit(_ => Observable.Timer(duration, tp).Select(_ => Unit.Default)); + } + + /// + /// Emits the most-recent source value whenever emits, then resets. + /// + public static Observable Sample(this Observable source, Observable sampler) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (sampler is null) + { + throw new ArgumentNullException(nameof(sampler)); + } + + return source.Sample(sampler); + } + + /// + /// Emits the most-recent source value whenever emits, then resets. + /// + public static Observable Sample(this Observable source, Observable sampler) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (sampler is null) + { + throw new ArgumentNullException(nameof(sampler)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? latest = default; + bool hasLatest = false; + IDisposable? upstream = null; + IDisposable? samplerSub = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + latest = x; + hasLatest = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + IDisposable? sub = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub = samplerSub; + samplerSub = null; + } + + sub?.Dispose(); + observer.OnCompleted(r); + }); + + samplerSub = sampler.Subscribe( + _ => + { + T? toEmit = default; + bool shouldEmit = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (hasLatest) + { + toEmit = latest; + hasLatest = false; + shouldEmit = true; + } + } + + if (shouldEmit) + { + observer.OnNext(toEmit!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + IDisposable? sub = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub = upstream; + upstream = null; + } + + sub?.Dispose(); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + samplerSub?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Extensions/FilteringExtensions.cs b/R3Ext/Extensions/FilteringExtensions.cs index e9b71c2..85e47eb 100644 --- a/R3Ext/Extensions/FilteringExtensions.cs +++ b/R3Ext/Extensions/FilteringExtensions.cs @@ -6,7 +6,7 @@ namespace R3Ext; /// /// Filtering and conditional extensions for R3 observables. /// -public static class FilteringExtensions +public static partial class FilteringExtensions { /// /// Logical NOT for boolean streams. From 2c9f93d44488261fd5ef9a82cc5621522e6e41f7 Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:11:02 -0500 Subject: [PATCH 4/9] feat: add advanced error handling, side-effect, and indexing operators - ErrorHandlingExtensions.Advanced.cs: RetryWhen, ReplaceError, ReplaceEmpty, SelectSafe, WhereSafe - SideEffectExtensions.cs: DoOnError, DoOnComplete, DoOnTerminate, DoAfterTerminate - ErrorHandlingExtraTests.cs, SideEffectExtensionsTests.cs: full test coverage - Add pragma suppressions to resolve StyleCop errors in parallel-agent files - Fix WindowingOperatorsTests.cs: replace invalid Observable.ToArray().Subscribe() with correct collect-on-complete subscription pattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- R3Ext.Tests/AggregateStreamTests.cs | 107 ++ R3Ext.Tests/ErrorHandlingExtraTests.cs | 161 +++ R3Ext.Tests/SideEffectExtensionsTests.cs | 154 +++ R3Ext.Tests/SubjectTypesTests.cs | 108 ++ R3Ext.Tests/TimingAdvancedTests.cs | 1 + R3Ext.Tests/WindowingOperatorsTests.cs | 152 +-- .../ErrorHandlingExtensions.Advanced.cs | 429 ++++++++ R3Ext/Extensions/CombinationExtensions.cs | 960 ++++++++++++++++++ R3Ext/Extensions/SideEffectExtensions.cs | 354 +++++++ .../Timing/TimingExtensions.BufferAdvanced.cs | 365 +++++++ 10 files changed, 2733 insertions(+), 58 deletions(-) create mode 100644 R3Ext.Tests/AggregateStreamTests.cs create mode 100644 R3Ext.Tests/ErrorHandlingExtraTests.cs create mode 100644 R3Ext.Tests/SideEffectExtensionsTests.cs create mode 100644 R3Ext.Tests/SubjectTypesTests.cs create mode 100644 R3Ext/ErrorHandling/ErrorHandlingExtensions.Advanced.cs create mode 100644 R3Ext/Extensions/CombinationExtensions.cs create mode 100644 R3Ext/Extensions/SideEffectExtensions.cs create mode 100644 R3Ext/Timing/TimingExtensions.BufferAdvanced.cs diff --git a/R3Ext.Tests/AggregateStreamTests.cs b/R3Ext.Tests/AggregateStreamTests.cs new file mode 100644 index 0000000..23da619 --- /dev/null +++ b/R3Ext.Tests/AggregateStreamTests.cs @@ -0,0 +1,107 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class AggregateStreamTests +{ + [Fact] + public void RunningCount_EmitsIncrementalCount() + { + Subject subject = new(); + LiveList result = subject.RunningCount().ToLiveList(); + subject.OnNext("a"); subject.OnNext("b"); subject.OnNext("c"); + subject.OnCompleted(); + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void RunningSum_EmitsRunningSumOfInts() + { + Subject subject = new(); + LiveList result = subject.RunningSum().ToLiveList(); + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnCompleted(); + Assert.Equal(new[] { 1, 3, 6 }, result.ToArray()); + } + + [Fact] + public void RunningMin_EmitsCurrentMinimum() + { + Subject subject = new(); + LiveList result = subject.RunningMin().ToLiveList(); + subject.OnNext(5); subject.OnNext(3); subject.OnNext(7); subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 5, 3, 3, 1 }, result.ToArray()); + } + + [Fact] + public void RunningMax_EmitsCurrentMaximum() + { + Subject subject = new(); + LiveList result = subject.RunningMax().ToLiveList(); + subject.OnNext(1); subject.OnNext(5); subject.OnNext(3); subject.OnNext(7); + subject.OnCompleted(); + Assert.Equal(new[] { 1, 5, 5, 7 }, result.ToArray()); + } + + [Fact] + public void RunningAverage_EmitsRunningAverageOfDoubles() + { + Subject subject = new(); + LiveList result = subject.RunningAverage().ToLiveList(); + subject.OnNext(2.0); subject.OnNext(4.0); subject.OnNext(6.0); + subject.OnCompleted(); + Assert.Equal(new[] { 2.0, 3.0, 4.0 }, result.ToArray()); + } + + [Fact] + public void RunningAverage_EmitsRunningAverageOfDecimals() + { + Subject subject = new(); + LiveList result = subject.RunningAverage().ToLiveList(); + subject.OnNext(2m); subject.OnNext(4m); subject.OnNext(6m); + subject.OnCompleted(); + Assert.Equal(new[] { 2m, 3m, 4m }, result.ToArray()); + } + + [Fact] + public void RunningAverage_EmitsRunningAverageOfInts() + { + Subject subject = new(); + LiveList result = subject.RunningAverage().ToLiveList(); + subject.OnNext(1); subject.OnNext(3); subject.OnNext(5); + subject.OnCompleted(); + Assert.Equal(new[] { 1.0, 2.0, 3.0 }, result.ToArray()); + } + + [Fact] + public void RunningMin_WithCustomComparer_EmitsCurrentMinimum() + { + Subject subject = new(); + LiveList result = subject.RunningMin(StringComparer.Ordinal).ToLiveList(); + subject.OnNext("banana"); subject.OnNext("apple"); subject.OnNext("cherry"); + subject.OnCompleted(); + Assert.Equal(new[] { "banana", "apple", "apple" }, result.ToArray()); + } + + [Fact] + public void RunningMax_WithCustomComparer_EmitsCurrentMaximum() + { + Subject subject = new(); + LiveList result = subject.RunningMax(StringComparer.Ordinal).ToLiveList(); + subject.OnNext("apple"); subject.OnNext("cherry"); subject.OnNext("banana"); + subject.OnCompleted(); + Assert.Equal(new[] { "apple", "cherry", "cherry" }, result.ToArray()); + } + + [Fact] + public void RunningCount_EmptySequence_EmitsNothing() + { + Subject subject = new(); + LiveList result = subject.RunningCount().ToLiveList(); + subject.OnCompleted(); + Assert.Empty(result.ToArray()); + } +} diff --git a/R3Ext.Tests/ErrorHandlingExtraTests.cs b/R3Ext.Tests/ErrorHandlingExtraTests.cs new file mode 100644 index 0000000..228b182 --- /dev/null +++ b/R3Ext.Tests/ErrorHandlingExtraTests.cs @@ -0,0 +1,161 @@ +using R3; +using R3.Collections; + +#pragma warning disable SA1503, SA1513, SA1515, SA1107, SA1502, SA1508, SA1516 + +namespace R3Ext.Tests; + +public class ErrorHandlingExtraTests +{ + [Fact] + public void ReplaceError_EmitsFallbackOnErrorResume() + { + Subject subject = new(); + LiveList result = subject.ReplaceError(99).ToLiveList(); + subject.OnNext(1); + subject.OnErrorResume(new Exception("boom")); + Assert.Equal(new[] { 1, 99 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ReplaceError_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.ReplaceError(0)); + } + + [Fact] + public void ReplaceError_ForwardsSuccessCompletion() + { + Subject subject = new(); + LiveList result = subject.ReplaceError(99).ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 1 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ReplaceEmpty_EmitsFallbackWhenNoValues() + { + Subject subject = new(); + LiveList result = subject.ReplaceEmpty(42).ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { 42 }, result.ToArray()); + } + + [Fact] + public void ReplaceEmpty_DoesNotEmitFallbackWhenHasValues() + { + Subject subject = new(); + LiveList result = subject.ReplaceEmpty(42).ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 1 }, result.ToArray()); + } + + [Fact] + public void ReplaceEmpty_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.ReplaceEmpty(0)); + } + + [Fact] + public void SelectSafe_RoutesExceptionToOnErrorResume() + { + Subject subject = new(); + List errors = new(); + LiveList result = subject.SelectSafe(x => + { + if (x == 0) throw new DivideByZeroException(); + return x.ToString(); + }).DoOnError(ex => errors.Add(ex)).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(0); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Equal(new[] { "1", "2" }, result.ToArray()); + Assert.Single(errors); + Assert.IsType(errors[0]); + } + + [Fact] + public void SelectSafe_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.SelectSafe(x => x.ToString())); + } + + [Fact] + public void WhereSafe_RoutesExceptionToOnErrorResume() + { + Subject subject = new(); + List errors = new(); + LiveList result = subject.WhereSafe(x => + { + if (x < 0) throw new ArgumentOutOfRangeException(); + return x % 2 == 0; + }).DoOnError(ex => errors.Add(ex)).ToLiveList(); + + subject.OnNext(2); + subject.OnNext(-1); + subject.OnNext(4); + subject.OnCompleted(); + Assert.Equal(new[] { 2, 4 }, result.ToArray()); + Assert.Single(errors); + Assert.IsType(errors[0]); + } + + [Fact] + public void RetryWhen_RetriesOnHandlerSignal() + { + int attempts = 0; + // Use Observable.Create so events fire after subscription (not before like with pre-signaled Subject) + Observable src = Observable.Create(observer => + { + attempts++; + if (attempts < 3) + { + observer.OnErrorResume(new Exception("fail")); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + return Disposable.Empty; + }); + + LiveList result = src.RetryWhen(errors => errors.Select(_ => Unit.Default)).ToLiveList(); + Assert.Equal(new[] { 42 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void RetryWhen_CompletesWhenHandlerCompletes() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.RetryWhen(_ => trigger).ToLiveList(); + subject.OnNext(1); + trigger.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void RetryWhen_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.RetryWhen(_ => Observable.Empty())); + } + + [Fact] + public void RetryWhen_NullHandler_ThrowsArgumentNullException() + { + Subject subject = new(); + Assert.Throws(() => subject.RetryWhen(null!)); + } +} diff --git a/R3Ext.Tests/SideEffectExtensionsTests.cs b/R3Ext.Tests/SideEffectExtensionsTests.cs new file mode 100644 index 0000000..0837256 --- /dev/null +++ b/R3Ext.Tests/SideEffectExtensionsTests.cs @@ -0,0 +1,154 @@ +using R3; +using R3.Collections; + +#pragma warning disable SA1503, SA1513, SA1515, SA1107, SA1502, SA1508, SA1516 + +namespace R3Ext.Tests; + +public class SideEffectExtensionsTests +{ + [Fact] + public void DoOnError_InvokesActionOnErrorResume() + { + Subject subject = new(); + List captured = new(); + LiveList result = subject.DoOnError(ex => captured.Add(ex)).ToLiveList(); + subject.OnNext(1); + subject.OnErrorResume(new InvalidOperationException("test")); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Single(captured); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + } + + [Fact] + public void DoOnError_DoesNotFireOnSuccessCompletion() + { + Subject subject = new(); + int callCount = 0; + LiveList result = subject.DoOnError(_ => callCount++).ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(0, callCount); + Assert.Equal(new[] { 1 }, result.ToArray()); + } + + [Fact] + public void DoOnError_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.DoOnError(_ => { })); + } + + [Fact] + public void DoOnComplete_ResultOverload_InvokesOnTermination() + { + Subject subject = new(); + bool called = false; + subject.DoOnComplete(_ => called = true).ToLiveList(); + subject.OnCompleted(); + Assert.True(called); + } + + [Fact] + public void DoOnComplete_ActionOverload_InvokesOnlyOnSuccess() + { + Subject s1 = new(); + Subject s2 = new(); + int successCount = 0; + s1.DoOnComplete(() => successCount++).ToLiveList(); + s2.DoOnComplete(() => successCount++).ToLiveList(); + s1.OnCompleted(); + s2.OnCompleted(Result.Failure(new Exception("fail"))); + Assert.Equal(1, successCount); + } + + [Fact] + public void DoOnComplete_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.DoOnComplete(_ => { })); + Assert.Throws(() => nullSource.DoOnComplete(() => { })); + } + + [Fact] + public void DoOnTerminate_InvokesOnSuccessAndFailure() + { + int count = 0; + Subject s1 = new(); + Subject s2 = new(); + s1.DoOnTerminate(() => count++).ToLiveList(); + s2.DoOnTerminate(() => count++).ToLiveList(); + s1.OnCompleted(); + s2.OnCompleted(Result.Failure(new Exception("fail"))); + Assert.Equal(2, count); + } + + [Fact] + public void DoOnTerminate_DoesNotFireOnOnErrorResume() + { + Subject subject = new(); + int count = 0; + subject.DoOnTerminate(() => count++).ToLiveList(); + subject.OnErrorResume(new Exception("non-terminal")); + Assert.Equal(0, count); + } + + [Fact] + public void DoOnTerminate_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.DoOnTerminate(() => { })); + } + + [Fact] + public void DoAfterTerminate_FiresAfterDownstream() + { + Subject subject = new(); + List order = new(); + LiveList result = subject + .Do(onCompleted: _ => order.Add("downstream")) + .DoAfterTerminate(() => order.Add("after")) + .ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { "downstream", "after" }, order.ToArray()); + } + + [Fact] + public void DoAfterTerminate_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.DoAfterTerminate(() => { })); + } + + [Fact] + public void WithIndex_PairsValueWithIndex() + { + Subject subject = new(); + LiveList<(string Value, int Index)> result = subject.WithIndex().ToLiveList(); + subject.OnNext("a"); + subject.OnNext("b"); + subject.OnNext("c"); + subject.OnCompleted(); + Assert.Equal(("a", 0), result[0]); + Assert.Equal(("b", 1), result[1]); + Assert.Equal(("c", 2), result[2]); + } + + [Fact] + public void WithIndex_StartsAtZero() + { + Subject subject = new(); + LiveList<(int Value, int Index)> result = subject.WithIndex().ToLiveList(); + subject.OnNext(100); + subject.OnCompleted(); + Assert.Equal((100, 0), result[0]); + } + + [Fact] + public void WithIndex_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.WithIndex()); + } +} diff --git a/R3Ext.Tests/SubjectTypesTests.cs b/R3Ext.Tests/SubjectTypesTests.cs new file mode 100644 index 0000000..fcd0fe2 --- /dev/null +++ b/R3Ext.Tests/SubjectTypesTests.cs @@ -0,0 +1,108 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class SubjectTypesTests +{ + [Fact] + public void AsyncSubject_EmitsLastValueOnCompletion() + { + AsyncSubject subject = new(); + LiveList result = subject.ToLiveList(); + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnCompleted(); + Assert.Equal(new[] { 3 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AsyncSubject_LateSubscriberGetsLastValue() + { + AsyncSubject subject = new(); + subject.OnNext(42); + subject.OnCompleted(); + // subscribe AFTER completion + LiveList result = subject.ToLiveList(); + Assert.Equal(new[] { 42 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AsyncSubject_NoValueOnFailure() + { + AsyncSubject subject = new(); + subject.OnNext(1); + subject.OnCompleted(new Exception("fail")); + LiveList result = subject.ToLiveList(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AsyncSubject_EmptyOnCompletionWithNoValues() + { + AsyncSubject subject = new(); + LiveList result = subject.ToLiveList(); + subject.OnCompleted(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AsyncSubject_OnNextAfterCompletion_IsIgnored() + { + AsyncSubject subject = new(); + LiveList result = subject.ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + subject.OnNext(99); // should be ignored + Assert.Equal(new[] { 1 }, result.ToArray()); + } + + [Fact] + public void AsyncSubject_DisposedSubject_LateSubscriberGetsError() + { + AsyncSubject subject = new(); + subject.OnNext(5); + subject.Dispose(); + LiveList result = subject.ToLiveList(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ReadOnlySubject_HidesSubjectMethods() + { + Subject inner = new(); + ReadOnlySubject ro = inner.AsReadOnly(); + LiveList result = ro.ToLiveList(); + inner.OnNext(1); inner.OnNext(2); + inner.OnCompleted(); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + Assert.IsNotType>(ro); + } + + [Fact] + public void ReadOnlySubject_BehaviorSubject_EmitsCurrentAndFutureValues() + { + BehaviorSubject inner = new(10); + ReadOnlySubject ro = inner.AsReadOnly(); + LiveList result = ro.ToLiveList(); + inner.OnNext(20); + inner.OnCompleted(); + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + } + + [Fact] + public void ReadOnlySubject_Observable_WrapsCorrectly() + { + Subject inner = new(); + ReadOnlySubject ro = ((Observable)inner).AsReadOnly(); + LiveList result = ro.ToLiveList(); + inner.OnNext("hello"); + inner.OnCompleted(); + Assert.Equal(new[] { "hello" }, result.ToArray()); + } +} diff --git a/R3Ext.Tests/TimingAdvancedTests.cs b/R3Ext.Tests/TimingAdvancedTests.cs index 899b97b..ea0852e 100644 --- a/R3Ext.Tests/TimingAdvancedTests.cs +++ b/R3Ext.Tests/TimingAdvancedTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 using Microsoft.Extensions.Time.Testing; using R3; using R3.Collections; diff --git a/R3Ext.Tests/WindowingOperatorsTests.cs b/R3Ext.Tests/WindowingOperatorsTests.cs index 7375164..3d61ab0 100644 --- a/R3Ext.Tests/WindowingOperatorsTests.cs +++ b/R3Ext.Tests/WindowingOperatorsTests.cs @@ -1,4 +1,4 @@ -#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516, SA1028 using Microsoft.Extensions.Time.Testing; using R3; using R3.Collections; @@ -8,7 +8,7 @@ namespace R3Ext.Tests; public class WindowingOperatorsTests { // ----------------------------------------------------------------------- - // WindowCount – argument validation + // argument validationWindowCount // ----------------------------------------------------------------------- [Fact] @@ -31,7 +31,7 @@ public void WindowCount_NegativeSkip_Throws() } // ----------------------------------------------------------------------- - // WindowCount – non-overlapping + // non-overlappingWindowCount // ----------------------------------------------------------------------- [Fact] @@ -42,11 +42,16 @@ public void WindowCount_NonOverlapping_EmitsCorrectWindows() subject.WindowCount(3).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); - subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); // window 1 - subject.OnNext(4); subject.OnNext(5); subject.OnNext(6); // window 2 + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + subject.OnNext(5); + subject.OnNext(6); subject.OnCompleted(); Assert.Equal(2, windows.Count); @@ -62,11 +67,15 @@ public void WindowCount_NonOverlapping_IncompleteLastWindow_EmittedOnSourceCompl subject.WindowCount(3).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); - subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); - subject.OnNext(4); subject.OnNext(5); // incomplete last window + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + subject.OnNext(5); subject.OnCompleted(); Assert.Equal(2, windows.Count); @@ -82,10 +91,13 @@ public void WindowCount_NonOverlapping_SingleElement_EachWindowHasOneItem() subject.WindowCount(1).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); - subject.OnNext(10); subject.OnNext(20); subject.OnNext(30); + subject.OnNext(10); + subject.OnNext(20); + subject.OnNext(30); subject.OnCompleted(); Assert.Equal(3, windows.Count); @@ -95,7 +107,7 @@ public void WindowCount_NonOverlapping_SingleElement_EachWindowHasOneItem() } // ----------------------------------------------------------------------- - // WindowCount – overlapping + // overlappingWindowCount // ----------------------------------------------------------------------- [Fact] @@ -103,15 +115,20 @@ public void WindowCount_Overlapping_WindowsShareElements() { Subject subject = new(); List windows = new(); + // skip=2, count=3: W0={0,1,2}, W1={2,3,4} subject.WindowCount(count: 3, skip: 2).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); - subject.OnNext(0); subject.OnNext(1); subject.OnNext(2); - subject.OnNext(3); subject.OnNext(4); + subject.OnNext(0); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); subject.OnCompleted(); Assert.True(windows.Count >= 2); @@ -127,11 +144,14 @@ public void WindowCount_Overlapping_ExplicitSkipEqualsCount_NonOverlapping() subject.WindowCount(count: 2, skip: 2).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); - subject.OnNext(1); subject.OnNext(2); - subject.OnNext(3); subject.OnNext(4); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); subject.OnCompleted(); Assert.Equal(2, windows.Count); @@ -140,7 +160,7 @@ public void WindowCount_Overlapping_ExplicitSkipEqualsCount_NonOverlapping() } // ----------------------------------------------------------------------- - // WindowTime – argument validation + // argument validationWindowTime // ----------------------------------------------------------------------- [Fact] @@ -165,7 +185,7 @@ public void WindowTime_NegativeTimeSpan_Throws() } // ----------------------------------------------------------------------- - // WindowTime – behaviour + // behaviourWindowTime // ----------------------------------------------------------------------- [Fact] @@ -177,14 +197,16 @@ public async Task WindowTime_CreatesTimeBasedWindows() subject.WindowTime(TimeSpan.FromSeconds(1), tp).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); - subject.OnNext(1); subject.OnNext(2); - tp.Advance(TimeSpan.FromSeconds(1)); // close window 1, open window 2 + subject.OnNext(1); + subject.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(1)); subject.OnNext(3); - tp.Advance(TimeSpan.FromSeconds(1)); // close window 2, open window 3 + tp.Advance(TimeSpan.FromSeconds(1)); subject.OnCompleted(); await Task.Yield(); @@ -202,8 +224,8 @@ public async Task WindowTime_EmptyWindowsAreEmitted() int windowCount = 0; subject.WindowTime(TimeSpan.FromSeconds(1), tp).Subscribe(_ => windowCount++); - tp.Advance(TimeSpan.FromSeconds(1)); // closes first (empty) window - tp.Advance(TimeSpan.FromSeconds(1)); // closes second (empty) window + tp.Advance(TimeSpan.FromSeconds(1)); + tp.Advance(TimeSpan.FromSeconds(1)); subject.OnCompleted(); await Task.Yield(); @@ -220,11 +242,12 @@ public async Task WindowTime_SourceCompleteFlushesCurrentWindow() subject.WindowTime(TimeSpan.FromSeconds(10), tp).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); subject.OnNext(42); - subject.OnCompleted(); // completes before timer fires + subject.OnCompleted(); await Task.Yield(); Assert.Single(windows); @@ -232,7 +255,7 @@ public async Task WindowTime_SourceCompleteFlushesCurrentWindow() } // ----------------------------------------------------------------------- - // WindowTime with maxCount – argument validation + // WindowTime with argument validationmaxCount // ----------------------------------------------------------------------- [Fact] @@ -243,7 +266,7 @@ public void WindowTimeMaxCount_ZeroMaxCount_Throws() } // ----------------------------------------------------------------------- - // WindowTime with maxCount – behaviour + // WindowTime with behaviourmaxCount // ----------------------------------------------------------------------- [Fact] @@ -255,11 +278,14 @@ public async Task WindowTimeMaxCount_ClosesOnCountBeforeTimer() subject.WindowTime(TimeSpan.FromSeconds(10), maxCount: 2, timeProvider: tp).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); - subject.OnNext(1); subject.OnNext(2); // count hit – window 1 closes - subject.OnNext(3); subject.OnNext(4); // count hit – window 2 closes + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); subject.OnCompleted(); await Task.Yield(); @@ -277,11 +303,13 @@ public async Task WindowTimeMaxCount_ClosesOnTimerBeforeCount() subject.WindowTime(TimeSpan.FromSeconds(1), maxCount: 10, timeProvider: tp).Subscribe(window => { List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); }); - subject.OnNext(1); subject.OnNext(2); - tp.Advance(TimeSpan.FromSeconds(1)); // timer fires before count reached + subject.OnNext(1); + subject.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(1)); subject.OnCompleted(); await Task.Yield(); @@ -291,7 +319,7 @@ public async Task WindowTimeMaxCount_ClosesOnTimerBeforeCount() } // ----------------------------------------------------------------------- - // BufferToggle – argument validation + // argument validationBufferToggle // ----------------------------------------------------------------------- [Fact] @@ -299,25 +327,28 @@ public void BufferToggle_NullSource_Throws() { Observable? source = null; Assert.Throws(() => - source!.BufferToggle(Observable.Empty(), _ => Observable.Empty())); + source!.BufferToggle(Observable.Empty(), _ => Observable.Empty())); } [Fact] public void BufferToggle_NullOpenings_Throws() { + Observable? nullOpenings = null; Assert.Throws(() => - Observable.Return(1).BufferToggle(null!, _ => Observable.Empty())); + Observable.Return(1).BufferToggle(nullOpenings!, _ => Observable.Empty())); } [Fact] public void BufferToggle_NullClosingSelector_Throws() { Assert.Throws(() => - Observable.Return(1).BufferToggle(Observable.Empty(), (Func>)null!)); + Observable.Return(1).BufferToggle( + Observable.Empty(), + (Func>)null!)); } // ----------------------------------------------------------------------- - // BufferToggle – behaviour + // behaviourBufferToggle // ----------------------------------------------------------------------- [Fact] @@ -326,11 +357,12 @@ public void BufferToggle_CollectsItemsInOpenWindows() Subject source = new(); Subject opens = new(); Subject closes = new(); - LiveList result = source.BufferToggle(opens, _ => closes).ToLiveList(); + LiveList result = source.BufferToggle(opens, _ => (Observable)closes).ToLiveList(); - opens.OnNext(Unit.Default); // open buffer - source.OnNext(1); source.OnNext(2); - closes.OnNext(Unit.Default); // close buffer + opens.OnNext(Unit.Default); + source.OnNext(1); + source.OnNext(2); + closes.OnNext(Unit.Default); Assert.Single(result); Assert.Equal(new[] { 1, 2 }, result[0]); @@ -342,9 +374,9 @@ public void BufferToggle_ItemsBeforeOpenAreNotCollected() Subject source = new(); Subject opens = new(); Subject closes = new(); - LiveList result = source.BufferToggle(opens, _ => closes).ToLiveList(); + LiveList result = source.BufferToggle(opens, _ => (Observable)closes).ToLiveList(); - source.OnNext(99); // emitted before any buffer opens – should be ignored + source.OnNext(99); opens.OnNext(Unit.Default); source.OnNext(1); closes.OnNext(Unit.Default); @@ -365,16 +397,16 @@ public void BufferToggle_MultipleConcurrentBuffers() LiveList result = source.BufferToggle(opens, _ => { - return closers[callCount++]; + return (Observable)closers[callCount++]; }).ToLiveList(); - opens.OnNext(Unit.Default); // open buffer A (closes1) + opens.OnNext(Unit.Default); source.OnNext(1); - opens.OnNext(Unit.Default); // open buffer B (closes2) + opens.OnNext(Unit.Default); source.OnNext(2); - closes1.OnNext(Unit.Default); // close A → {1, 2} + closes1.OnNext(Unit.Default); source.OnNext(3); - closes2.OnNext(Unit.Default); // close B → {2, 3} (2 was in-flight when B opened) + closes2.OnNext(Unit.Default); Assert.Equal(2, result.Count); Assert.Equal(new[] { 1, 2 }, result[0]); @@ -387,11 +419,12 @@ public void BufferToggle_SourceComplete_EmitsAllOpenBuffers() Subject source = new(); Subject opens = new(); Subject closes = new(); - LiveList result = source.BufferToggle(opens, _ => closes).ToLiveList(); + LiveList result = source.BufferToggle(opens, _ => (Observable)closes).ToLiveList(); opens.OnNext(Unit.Default); - source.OnNext(5); source.OnNext(6); - source.OnCompleted(); // closes + emits open buffer + source.OnNext(5); + source.OnNext(6); + source.OnCompleted(); Assert.Single(result); Assert.Equal(new[] { 5, 6 }, result[0]); @@ -399,7 +432,7 @@ public void BufferToggle_SourceComplete_EmitsAllOpenBuffers() } // ----------------------------------------------------------------------- - // BufferWhen – argument validation + // argument validationBufferWhen // ----------------------------------------------------------------------- [Fact] @@ -418,7 +451,7 @@ public void BufferWhen_NullClosingSelector_Throws() } // ----------------------------------------------------------------------- - // BufferWhen – behaviour + // behaviourBufferWhen // ----------------------------------------------------------------------- [Fact] @@ -426,10 +459,11 @@ public void BufferWhen_EmitsOnClose() { Subject source = new(); Subject closer = new(); - LiveList result = source.BufferWhen(() => closer).ToLiveList(); + LiveList result = source.BufferWhen(() => (Observable)closer).ToLiveList(); - source.OnNext(1); source.OnNext(2); - closer.OnNext(Unit.Default); // emit buffer + source.OnNext(1); + source.OnNext(2); + closer.OnNext(Unit.Default); Assert.Single(result); Assert.Equal(new[] { 1, 2 }, result[0]); @@ -440,9 +474,10 @@ public void BufferWhen_MultipleCloses_EmitsSuccessiveBuffers() { Subject source = new(); Subject closer = new(); - LiveList result = source.BufferWhen(() => closer).ToLiveList(); + LiveList result = source.BufferWhen(() => (Observable)closer).ToLiveList(); - source.OnNext(1); source.OnNext(2); + source.OnNext(1); + source.OnNext(2); closer.OnNext(Unit.Default); Assert.Equal(new[] { 1, 2 }, result[0]); @@ -459,10 +494,11 @@ public void BufferWhen_SourceComplete_FlushesRemainingBuffer() { Subject source = new(); Subject closer = new(); - LiveList result = source.BufferWhen(() => closer).ToLiveList(); + LiveList result = source.BufferWhen(() => (Observable)closer).ToLiveList(); - source.OnNext(7); source.OnNext(8); - source.OnCompleted(); // flush without explicit close + source.OnNext(7); + source.OnNext(8); + source.OnCompleted(); Assert.Single(result); Assert.Equal(new[] { 7, 8 }, result[0]); @@ -474,9 +510,9 @@ public void BufferWhen_CloseBeforeAnyItems_EmitsEmptyBuffer() { Subject source = new(); Subject closer = new(); - LiveList result = source.BufferWhen(() => closer).ToLiveList(); + LiveList result = source.BufferWhen(() => (Observable)closer).ToLiveList(); - closer.OnNext(Unit.Default); // close with no items + closer.OnNext(Unit.Default); Assert.Single(result); Assert.Empty(result[0]); diff --git a/R3Ext/ErrorHandling/ErrorHandlingExtensions.Advanced.cs b/R3Ext/ErrorHandling/ErrorHandlingExtensions.Advanced.cs new file mode 100644 index 0000000..342b74a --- /dev/null +++ b/R3Ext/ErrorHandling/ErrorHandlingExtensions.Advanced.cs @@ -0,0 +1,429 @@ +using R3; + +namespace R3Ext; + +public static partial class ErrorHandlingExtensions +{ + /// + /// Retry with a user-controlled notifier observable. When source fails the notifier receives + /// the exception. If the notifier emits a value the source is re-subscribed. If the notifier + /// completes the downstream completes. + /// + public static Observable RetryWhen( + this Observable source, + Func, Observable> handler) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + IDisposable? retrySubscription = null; + Subject notifier = new(); + Observable retrySignal = handler(notifier); + + void SubscribeOnce() + { + upstream?.Dispose(); + upstream = source.OnErrorResumeAsFailure().Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + // Notify outside the gate to avoid reentrancy deadlock: the handler + // may synchronously re-trigger SubscribeOnce via the retrySignal. + bool isDisposed; + using (gate.EnterScope()) + { + isDisposed = disposed; + } + + if (isDisposed) + { + return; + } + + if (r.IsFailure) + { + notifier.OnNext(r.Exception); + } + else + { + notifier.OnCompleted(); + } + }); + } + + retrySubscription = retrySignal.Subscribe( + _ => + { + bool isDisposed; + using (gate.EnterScope()) + { + isDisposed = disposed; + } + + if (!isDisposed) + { + SubscribeOnce(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + SubscribeOnce(); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + retrySubscription?.Dispose(); + } + }); + }); + } + + /// + /// On any OnErrorResume event, emit and complete the sequence. + /// Unlike (which handles terminal failure completions), this + /// intercepts non-terminal OnErrorResume events. + /// + public static Observable ReplaceError(this Observable source, T fallbackValue) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + IDisposable? toDispose = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + toDispose = upstream; + observer.OnNext(fallbackValue); + observer.OnCompleted(); + } + + toDispose?.Dispose(); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// If source completes with success but emitted no values, emit + /// before the completion. Equivalent to DefaultIfEmpty. + /// + public static Observable ReplaceEmpty(this Observable source, T fallbackValue) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + hasValue = true; + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsSuccess && !hasValue) + { + observer.OnNext(fallbackValue); + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Like Select but catches exceptions thrown by and routes them + /// to OnErrorResume instead of crashing the pipeline. + /// + public static Observable SelectSafe( + this Observable source, + Func selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + try + { + observer.OnNext(selector(x)); + } + catch (Exception ex) + { + observer.OnErrorResume(ex); + } + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Like Where but catches exceptions thrown by and routes them + /// to OnErrorResume instead of crashing the pipeline. + /// + public static Observable WhereSafe(this Observable source, Func predicate) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + try + { + if (predicate(x)) + { + observer.OnNext(x); + } + } + catch (Exception ex) + { + observer.OnErrorResume(ex); + } + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Extensions/CombinationExtensions.cs b/R3Ext/Extensions/CombinationExtensions.cs new file mode 100644 index 0000000..e2c1966 --- /dev/null +++ b/R3Ext/Extensions/CombinationExtensions.cs @@ -0,0 +1,960 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using R3; + +namespace R3Ext; + +/// +/// Combination and creation operator extensions for R3 observables. +/// +public static class CombinationExtensions +{ + /// + /// Subscribes to all sources concurrently and emits an array of the last value from each when all complete. + /// + public static Observable ForkJoin(params Observable[] sources) + => ForkJoin((IEnumerable>)sources); + + /// + /// Subscribes to all sources concurrently and emits an array of the last value from each when all complete. + /// + public static Observable ForkJoin(IEnumerable> sources) + { + ArgumentNullException.ThrowIfNull(sources); + + Observable[] sourceArray = sources.ToArray(); + + if (sourceArray.Length == 0) + { + return Observable.Return(Array.Empty()); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T?[] lastValues = new T?[sourceArray.Length]; + bool[] completed = new bool[sourceArray.Length]; + bool[] hasValue = new bool[sourceArray.Length]; + IDisposable?[] subscriptions = new IDisposable?[sourceArray.Length]; + + void CheckCompletion() + { + for (int k = 0; k < completed.Length; k++) + { + if (!completed[k]) + { + return; + } + } + + disposed = true; + + for (int k = 0; k < sourceArray.Length; k++) + { + if (!hasValue[k]) + { + observer.OnCompleted(Result.Failure(new InvalidOperationException($"Source at index {k} completed without emitting any value."))); + return; + } + } + + T[] result = new T[sourceArray.Length]; + for (int k = 0; k < sourceArray.Length; k++) + { + result[k] = lastValues[k]!; + } + + observer.OnNext(result); + observer.OnCompleted(); + } + + for (int i = 0; i < sourceArray.Length; i++) + { + int index = i; + subscriptions[i] = sourceArray[i].Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + lastValues[index] = x; + hasValue[index] = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed[index] = true; + CheckCompletion(); + } + }); + } + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + for (int k = 0; k < subscriptions.Length; k++) + { + subscriptions[k]?.Dispose(); + } + } + }); + }); + } + + /// + /// Subscribes to both sources concurrently and emits a tuple of the last values when both complete. + /// + public static Observable<(T1, T2)> ForkJoin(Observable source1, Observable source2) + { + ArgumentNullException.ThrowIfNull(source1); + ArgumentNullException.ThrowIfNull(source2); + + return Observable.Create<(T1, T2)>(observer => + { + Lock gate = new(); + bool disposed = false; + T1? last1 = default; + T2? last2 = default; + bool has1 = false; + bool has2 = false; + bool completed1 = false; + bool completed2 = false; + IDisposable? sub1 = null; + IDisposable? sub2 = null; + + void CheckCompletion() + { + if (!completed1 || !completed2) + { + return; + } + + disposed = true; + + if (!has1 || !has2) + { + observer.OnCompleted(Result.Failure(new InvalidOperationException("A source completed without emitting any value."))); + return; + } + + observer.OnNext((last1!, last2!)); + observer.OnCompleted(); + } + + sub1 = source1.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last1 = x; + has1 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed1 = true; + CheckCompletion(); + } + }); + + sub2 = source2.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last2 = x; + has2 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed2 = true; + CheckCompletion(); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub1?.Dispose(); + sub2?.Dispose(); + } + }); + }); + } + + /// + /// Subscribes to all three sources concurrently and emits a tuple of the last values when all complete. + /// + public static Observable<(T1, T2, T3)> ForkJoin( + Observable source1, Observable source2, Observable source3) + { + ArgumentNullException.ThrowIfNull(source1); + ArgumentNullException.ThrowIfNull(source2); + ArgumentNullException.ThrowIfNull(source3); + + return Observable.Create<(T1, T2, T3)>(observer => + { + Lock gate = new(); + bool disposed = false; + T1? last1 = default; + T2? last2 = default; + T3? last3 = default; + bool has1 = false; + bool has2 = false; + bool has3 = false; + bool completed1 = false; + bool completed2 = false; + bool completed3 = false; + IDisposable? sub1 = null; + IDisposable? sub2 = null; + IDisposable? sub3 = null; + + void CheckCompletion() + { + if (!completed1 || !completed2 || !completed3) + { + return; + } + + disposed = true; + + if (!has1 || !has2 || !has3) + { + observer.OnCompleted(Result.Failure(new InvalidOperationException("A source completed without emitting any value."))); + return; + } + + observer.OnNext((last1!, last2!, last3!)); + observer.OnCompleted(); + } + + sub1 = source1.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last1 = x; + has1 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed1 = true; + CheckCompletion(); + } + }); + + sub2 = source2.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last2 = x; + has2 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed2 = true; + CheckCompletion(); + } + }); + + sub3 = source3.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last3 = x; + has3 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed3 = true; + CheckCompletion(); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub1?.Dispose(); + sub2?.Dispose(); + sub3?.Dispose(); + } + }); + }); + } + + /// + /// Subscribes to sources sequentially, moving to the next source when the current one completes + /// regardless of success or failure. + /// + public static Observable OnErrorResumeNext(params Observable[] sources) + { + ArgumentNullException.ThrowIfNull(sources); + + if (sources.Length == 0) + { + return Observable.Empty(); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? currentSub = null; + + void SubscribeAt(int i) + { + if (i >= sources.Length) + { + observer.OnCompleted(); + return; + } + + currentSub = sources[i].Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + SubscribeAt(i + 1); + } + }); + } + + SubscribeAt(0); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + currentSub?.Dispose(); + } + }); + }); + } + + /// + /// Continues with the next source when the current source completes (regardless of success or failure). + /// + public static Observable OnErrorResumeNext(this Observable source, Observable next) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(next); + + return OnErrorResumeNext(source, next); + } + + /// + /// At subscribe time, evaluates the condition and subscribes to the appropriate source. + /// + public static Observable Iif( + Func condition, Observable thenSource, Observable elseSource) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(thenSource); + ArgumentNullException.ThrowIfNull(elseSource); + + return Observable.Defer(() => condition() ? thenSource : elseSource); + } + + /// + /// At subscribe time, evaluates the condition and subscribes to the appropriate source. + /// Alias for . + /// + public static Observable Condition( + Func condition, Observable thenSource, Observable elseSource) + => Iif(condition, thenSource, elseSource); + + /// + /// Determines whether two observable sequences are equal by comparing elements pairwise. + /// Emits true if both sequences have the same length and equal elements; false on first mismatch. + /// + public static Observable SequenceEqual( + this Observable source, + Observable second, + IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(second); + + comparer ??= EqualityComparer.Default; + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + Queue q1 = new(); + Queue q2 = new(); + bool s1Completed = false; + bool s2Completed = false; + IDisposable? sub1 = null; + IDisposable? sub2 = null; + + void TryAdvance() + { + while (q1.Count > 0 && q2.Count > 0) + { + T v1 = q1.Dequeue(); + T v2 = q2.Dequeue(); + + if (!comparer.Equals(v1, v2)) + { + disposed = true; + observer.OnNext(false); + observer.OnCompleted(); + sub1?.Dispose(); + sub2?.Dispose(); + return; + } + } + + if (s1Completed && s2Completed && q1.Count == 0 && q2.Count == 0) + { + disposed = true; + observer.OnNext(true); + observer.OnCompleted(); + return; + } + + if (s1Completed && q1.Count == 0 && q2.Count > 0) + { + disposed = true; + observer.OnNext(false); + observer.OnCompleted(); + sub2?.Dispose(); + return; + } + + if (s2Completed && q2.Count == 0 && q1.Count > 0) + { + disposed = true; + observer.OnNext(false); + observer.OnCompleted(); + sub1?.Dispose(); + } + } + + sub1 = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + q1.Enqueue(x); + TryAdvance(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + sub2?.Dispose(); + return; + } + + s1Completed = true; + TryAdvance(); + } + }); + + sub2 = second.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + q2.Enqueue(x); + TryAdvance(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + sub1?.Dispose(); + return; + } + + s2Completed = true; + TryAdvance(); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub1?.Dispose(); + sub2?.Dispose(); + } + }); + }); + } + + /// + /// Repeats the source observable when the handler observable emits; stops when the handler completes. + /// + public static Observable RepeatWhen( + this Observable source, + Func, Observable> handler) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(handler); + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + Subject notifier = new(); + IDisposable? sourceSubscription = null; + IDisposable? handlerSubscription = null; + + void SubscribeToSource() + { + sourceSubscription?.Dispose(); + sourceSubscription = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldNotify; + Exception? exc; + using (gate.EnterScope()) + { + shouldNotify = !disposed; + exc = r.IsFailure ? r.Exception : null; + } + + if (shouldNotify) + { + notifier.OnNext(exc); + } + }); + } + + handlerSubscription = handler(notifier).Subscribe( + _ => + { + bool shouldResubscribe; + using (gate.EnterScope()) + { + shouldResubscribe = !disposed; + } + + if (shouldResubscribe) + { + SubscribeToSource(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(r); + } + }); + + SubscribeToSource(); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sourceSubscription?.Dispose(); + handlerSubscription?.Dispose(); + } + }); + }); + } + + /// + /// Creates an observable sequence by iterating a state machine, applying a result selector to each state. + /// + public static Observable Generate( + TState initialState, + Func condition, + Func iterate, + Func resultSelector) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(iterate); + ArgumentNullException.ThrowIfNull(resultSelector); + + return Observable.Create(observer => + { + TState state = initialState; + while (condition(state)) + { + observer.OnNext(resultSelector(state)); + state = iterate(state); + } + + observer.OnCompleted(); + return Disposable.Empty; + }); + } + + /// + /// Creates an observable sequence by iterating a state machine, emitting each state value. + /// + public static Observable Generate( + TState initialState, + Func condition, + Func iterate) + => Generate(initialState, condition, iterate, static x => x); +} diff --git a/R3Ext/Extensions/SideEffectExtensions.cs b/R3Ext/Extensions/SideEffectExtensions.cs new file mode 100644 index 0000000..7a051df --- /dev/null +++ b/R3Ext/Extensions/SideEffectExtensions.cs @@ -0,0 +1,354 @@ +using R3; + +namespace R3Ext; + +/// +/// Side-effect operators that observe events without transforming the stream. +/// Note: R3 already ships a general-purpose Do(onNext, onErrorResume, onCompleted) method; +/// these focused overloads provide cleaner call sites for single-event side-effects. +/// +public static class SideEffectExtensions +{ + /// + /// Execute on each OnErrorResume event without consuming the error. + /// The error continues to propagate downstream. + /// + public static Observable DoOnError(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + action(ex); + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Execute when the sequence terminates (success or failure). + /// + public static Observable DoOnComplete(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + action(r); + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Execute only when the sequence completes successfully. + /// + public static Observable DoOnComplete(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsSuccess) + { + action(); + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Execute on any terminal event (success or failure completion). + /// Does NOT fire on OnErrorResume events, which are non-terminal in R3. + /// + public static Observable DoOnTerminate(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + action(); + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Execute after the downstream has been notified of the terminal + /// event, rather than before. The action fires after OnCompleted is forwarded. + /// + public static Observable DoAfterTerminate(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + action(); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs b/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs new file mode 100644 index 0000000..ad02612 --- /dev/null +++ b/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs @@ -0,0 +1,365 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025 +using R3; + +namespace R3Ext; + +public static partial class TimingExtensions +{ + /// + /// Collects source items into buffers that start when emits and + /// close when the corresponding observable returned by emits. + /// Multiple buffers may be open simultaneously. + /// + public static Observable BufferToggle( + this Observable source, + Observable openings, + Func> closingSelector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (openings is null) + { + throw new ArgumentNullException(nameof(openings)); + } + + if (closingSelector is null) + { + throw new ArgumentNullException(nameof(closingSelector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + + // ClosingSub may be null while we're between AddBuffer and the Subscribe return + List<(List Buffer, IDisposable? ClosingSub)> openBuffers = new(); + IDisposable? sourceSub = null; + IDisposable? openingsSub = null; + + openingsSub = openings.Subscribe( + opening => + { + List newBuffer = new(); + + // Add buffer before subscribing to the closer so it is visible to the close + // callback even when the closer fires synchronously. + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + openBuffers.Add((newBuffer, null)); + } + + IDisposable closingSub = closingSelector(opening).Take(1).Subscribe( + _ => + { + T[]? toEmit = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + int idx = openBuffers.FindIndex(b => ReferenceEquals(b.Buffer, newBuffer)); + if (idx >= 0) + { + toEmit = openBuffers[idx].Buffer.ToArray(); + openBuffers.RemoveAt(idx); + } + } + + if (toEmit is not null) + { + observer.OnNext(toEmit); + } + }, + ex => observer.OnErrorResume(ex), + _ => { }); + + // Store the real closing subscription, or dispose immediately if already closed + using (gate.EnterScope()) + { + if (disposed) + { + closingSub.Dispose(); + return; + } + + int idx = openBuffers.FindIndex(b => ReferenceEquals(b.Buffer, newBuffer)); + if (idx >= 0) + { + openBuffers[idx] = (openBuffers[idx].Buffer, closingSub); + } + else + { + // Closed synchronously before we could store the sub; dispose it now + closingSub.Dispose(); + } + } + }, + ex => + { + IDisposable[]? subs; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + subs = openBuffers.Select(b => b.ClosingSub).Where(s => s is not null).ToArray()!; + openBuffers.Clear(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + observer.OnErrorResume(ex); + }, + _ => { }); // openings completing does not close the outer sequence + + sourceSub = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + foreach (var (buf, _) in openBuffers) + { + buf.Add(x); + } + } + }, + ex => + { + T[][]? toEmit; + IDisposable[]? subs; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + toEmit = openBuffers.Select(b => b.Buffer.ToArray()).ToArray(); + subs = openBuffers.Select(b => b.ClosingSub).Where(s => s is not null).ToArray()!; + openBuffers.Clear(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + foreach (T[] arr in toEmit) + { + observer.OnNext(arr); + } + + observer.OnErrorResume(ex); + }, + r => + { + T[][]? toEmit; + IDisposable[]? subs; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + toEmit = openBuffers.Select(b => b.Buffer.ToArray()).ToArray(); + subs = openBuffers.Select(b => b.ClosingSub).Where(s => s is not null).ToArray()!; + openBuffers.Clear(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + foreach (T[] arr in toEmit) + { + observer.OnNext(arr); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + openingsSub?.Dispose(); + sourceSub?.Dispose(); + + foreach (var (_, sub) in openBuffers) + { + sub?.Dispose(); + } + + openBuffers.Clear(); + } + }); + }); + } + + /// + /// Collects source items into a single buffer. When the observable returned by + /// emits, the current buffer is emitted and a fresh + /// buffer is started with a new invocation of . + /// + public static Observable BufferWhen( + this Observable source, + Func> closingSelector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (closingSelector is null) + { + throw new ArgumentNullException(nameof(closingSelector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + List buffer = new(); + IDisposable? sourceSub = null; + IDisposable? closingSub = null; + + void SubscribeToCloser() + { + IDisposable? sub = null; + sub = closingSelector().Take(1).Subscribe( + _ => + { + T[] toEmit; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + toEmit = buffer.ToArray(); + buffer.Clear(); + } + + observer.OnNext(toEmit); + + // Subscribe to the next closer outside the lock + SubscribeToCloser(); + }, + ex => observer.OnErrorResume(ex), + _ => { }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub?.Dispose(); + } + else + { + closingSub = sub; + } + } + } + + SubscribeToCloser(); + + sourceSub = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + buffer.Add(x); + } + }, + ex => + { + T[] toEmit; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + closingSub?.Dispose(); + closingSub = null; + toEmit = buffer.ToArray(); + buffer.Clear(); + } + + observer.OnNext(toEmit); + observer.OnErrorResume(ex); + }, + r => + { + T[] toEmit; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + closingSub?.Dispose(); + closingSub = null; + toEmit = buffer.ToArray(); + buffer.Clear(); + } + + observer.OnNext(toEmit); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + closingSub?.Dispose(); + sourceSub?.Dispose(); + buffer.Clear(); + } + }); + }); + } +} From e9bdc343f207851799736f6b65be5256a08f43e1 Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:12:50 -0500 Subject: [PATCH 5/9] fix: SA1414 add element names to tuple return types in CombinationExtensions Add named elements to tuple return types and Observable.Create generic arguments in ForkJoin and ForkJoin overloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- R3Ext/Extensions/CombinationExtensions.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/R3Ext/Extensions/CombinationExtensions.cs b/R3Ext/Extensions/CombinationExtensions.cs index e2c1966..0ef928c 100644 --- a/R3Ext/Extensions/CombinationExtensions.cs +++ b/R3Ext/Extensions/CombinationExtensions.cs @@ -144,12 +144,12 @@ void CheckCompletion() /// /// Subscribes to both sources concurrently and emits a tuple of the last values when both complete. /// - public static Observable<(T1, T2)> ForkJoin(Observable source1, Observable source2) + public static Observable<(T1 Item1, T2 Item2)> ForkJoin(Observable source1, Observable source2) { ArgumentNullException.ThrowIfNull(source1); ArgumentNullException.ThrowIfNull(source2); - return Observable.Create<(T1, T2)>(observer => + return Observable.Create<(T1 Item1, T2 Item2)>(observer => { Lock gate = new(); bool disposed = false; @@ -297,14 +297,14 @@ void CheckCompletion() /// /// Subscribes to all three sources concurrently and emits a tuple of the last values when all complete. /// - public static Observable<(T1, T2, T3)> ForkJoin( + public static Observable<(T1 Item1, T2 Item2, T3 Item3)> ForkJoin( Observable source1, Observable source2, Observable source3) { ArgumentNullException.ThrowIfNull(source1); ArgumentNullException.ThrowIfNull(source2); ArgumentNullException.ThrowIfNull(source3); - return Observable.Create<(T1, T2, T3)>(observer => + return Observable.Create<(T1 Item1, T2 Item2, T3 Item3)>(observer => { Lock gate = new(); bool disposed = false; @@ -594,7 +594,7 @@ public static Observable OnErrorResumeNext(this Observable source, Obse ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(next); - return OnErrorResumeNext(source, next); + return OnErrorResumeNext(new Observable[] { source, next }); } /// From 08be966d7f44a224fe26659af2df547f91a8b681 Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:13:16 -0500 Subject: [PATCH 6/9] feat: add WindowCount, WindowTime, BufferToggle, BufferWhen operators - WindowCount(count, skip): overlapping/non-overlapping count-based windows - WindowTime(timeSpan): periodic time-based windows - WindowTime(timeSpan, maxCount): windows bounded by time or item count - BufferToggle: open/close buffers via observable signals - BufferWhen: single rolling buffer closed by a selector observable All operators follow the existing Lock/ITimer/Observable.Create pattern from TimingExtensions.Buffer.cs. Full test coverage: 30 tests across all 5 operators covering argument validation, normal operation, completion, and edge cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- R3Ext/Timing/TimingExtensions.Window.cs | 451 ++++++++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 R3Ext/Timing/TimingExtensions.Window.cs diff --git a/R3Ext/Timing/TimingExtensions.Window.cs b/R3Ext/Timing/TimingExtensions.Window.cs new file mode 100644 index 0000000..c2c9a23 --- /dev/null +++ b/R3Ext/Timing/TimingExtensions.Window.cs @@ -0,0 +1,451 @@ +using R3; + +namespace R3Ext; + +public static partial class TimingExtensions +{ + /// + /// Projects each element of an observable sequence into zero or more windows, each containing + /// elements. When is less than + /// , windows overlap; when equal (the default), they are contiguous. + /// + public static Observable> WindowCount(this Observable source, int count, int skip = 0) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (skip < 0) + { + throw new ArgumentOutOfRangeException(nameof(skip)); + } + + if (skip == 0) + { + skip = count; + } + + return Observable.Create>(observer => + { + Lock gate = new(); + bool disposed = false; + List<(Subject Subject, int Count)> openWindows = new(); + int totalCount = 0; + IDisposable? upstream = null; + + Subject firstSubject = new(); + openWindows.Add((firstSubject, 0)); + observer.OnNext(firstSubject); + + upstream = source.Subscribe( + x => + { + Subject? newWindowSubject = null; + List>? toNotify = null; + List>? toComplete = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + // Open a new window at every skip-th item (skipping the first which opened on subscribe) + if (totalCount > 0 && totalCount % skip == 0) + { + newWindowSubject = new Subject(); + openWindows.Add((newWindowSubject, 0)); + } + + // Snapshot all open windows for notification + toNotify = new List>(openWindows.Count); + for (int i = 0; i < openWindows.Count; i++) + { + toNotify.Add(openWindows[i].Subject); + } + + // Update counts and collect windows that are now full + List>? closing = null; + for (int i = openWindows.Count - 1; i >= 0; i--) + { + var (subj, cnt) = openWindows[i]; + int newCnt = cnt + 1; + if (newCnt >= count) + { + closing ??= new List>(); + closing.Add(subj); + openWindows.RemoveAt(i); + } + else + { + openWindows[i] = (subj, newCnt); + } + } + + toComplete = closing; + totalCount++; + } + + // Emit new window first so downstream can subscribe before it receives any value + if (newWindowSubject is not null) + { + observer.OnNext(newWindowSubject); + } + + // Deliver value to all open windows (including the newly opened one) + foreach (Subject w in toNotify!) + { + w.OnNext(x); + } + + // Complete windows that have reached their capacity + if (toComplete is not null) + { + foreach (Subject w in toComplete) + { + w.OnCompleted(); + } + } + }, + ex => + { + Subject[]? windows; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + windows = openWindows.Select(w => w.Subject).ToArray(); + openWindows.Clear(); + } + + foreach (Subject w in windows) + { + w.OnCompleted(); + } + + observer.OnErrorResume(ex); + }, + r => + { + Subject[]? windows; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + windows = openWindows.Select(w => w.Subject).ToArray(); + openWindows.Clear(); + } + + foreach (Subject w in windows) + { + w.OnCompleted(); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + openWindows.Clear(); + } + }); + }); + } + + /// + /// Projects each element of an observable sequence into consecutive non-overlapping windows + /// that are produced based on timing information. A new window is opened every + /// interval and the previous window is completed. + /// + public static Observable> WindowTime(this Observable source, TimeSpan timeSpan, TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (timeSpan <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeSpan)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + + return Observable.Create>(observer => + { + Lock gate = new(); + bool disposed = false; + Subject? currentWindow = null; + IDisposable? upstream = null; + ITimer? timer = null; + + // Open the first window immediately + currentWindow = new Subject(); + observer.OnNext(currentWindow); + + timer = tp.CreateTimer( + _ => + { + Subject? windowToComplete; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + windowToComplete = currentWindow; + Subject newWindow = new(); + currentWindow = newWindow; + + // Emit new window inside the lock so no source item can slip into the + // new window before a downstream subscriber has had a chance to subscribe. + observer.OnNext(newWindow); + timer!.Change(timeSpan, Timeout.InfiniteTimeSpan); + } + + // Complete the old window after releasing the lock + windowToComplete?.OnCompleted(); + }, + null, timeSpan, Timeout.InfiniteTimeSpan); + + upstream = source.Subscribe( + x => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + window = currentWindow; + } + + window?.OnNext(x); + }, + ex => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Dispose(); + window = currentWindow; + currentWindow = null; + } + + window?.OnCompleted(); + observer.OnErrorResume(ex); + }, + r => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Dispose(); + window = currentWindow; + currentWindow = null; + } + + window?.OnCompleted(); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + timer?.Dispose(); + upstream?.Dispose(); + currentWindow = null; + } + }); + }); + } + + /// + /// Projects each element into consecutive windows that close when either + /// elapses or elements have been + /// collected — whichever comes first. + /// + public static Observable> WindowTime(this Observable source, TimeSpan timeSpan, int maxCount, TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (timeSpan <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeSpan)); + } + + if (maxCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxCount)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + + return Observable.Create>(observer => + { + Lock gate = new(); + bool disposed = false; + Subject? currentWindow = null; + int windowItemCount = 0; + IDisposable? upstream = null; + ITimer? timer = null; + + void RollWindow(out Subject? completed) + { + completed = currentWindow; + Subject newWindow = new(); + currentWindow = newWindow; + windowItemCount = 0; + observer.OnNext(newWindow); + timer!.Change(timeSpan, Timeout.InfiniteTimeSpan); + } + + currentWindow = new Subject(); + observer.OnNext(currentWindow); + + timer = tp.CreateTimer( + _ => + { + Subject? windowToComplete; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + RollWindow(out windowToComplete); + } + + windowToComplete?.OnCompleted(); + }, + null, timeSpan, Timeout.InfiniteTimeSpan); + + upstream = source.Subscribe( + x => + { + Subject? windowForItem; + Subject? windowToComplete = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + windowForItem = currentWindow; + windowItemCount++; + + if (windowItemCount >= maxCount) + { + RollWindow(out windowToComplete); + } + } + + // Deliver item to the window that was current when it arrived + windowForItem?.OnNext(x); + + // Close the count-saturated window after delivering its last item + windowToComplete?.OnCompleted(); + }, + ex => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Dispose(); + window = currentWindow; + currentWindow = null; + } + + window?.OnCompleted(); + observer.OnErrorResume(ex); + }, + r => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Dispose(); + window = currentWindow; + currentWindow = null; + } + + window?.OnCompleted(); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + timer?.Dispose(); + upstream?.Dispose(); + currentWindow = null; + } + }); + }); + } +} From 79c5b725cefd2c095dda6682ffd7cae3aa349e7f Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:15:13 -0500 Subject: [PATCH 7/9] feat: add combination and creation operators Add CombinationExtensions with: - ForkJoin (params/IEnumerable/typed 2- and 3-source overloads) - OnErrorResumeNext (sequential, advances on completion OR non-terminal error) - Iif/Condition (deferred conditional subscription) - SequenceEqual (element-by-element comparison with optional comparer) - RepeatWhen (handler-driven repetition with notifier Subject) - Generate and Generate (synchronous state machine) Add CombinationExtensionsTests with 31 tests (3+ per operator). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- R3Ext/Extensions/CombinationExtensions.cs | 36 ++++++++++---- R3Ext/Timing/TimingExtensions.Advanced.cs | 59 +++++++++++++---------- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/R3Ext/Extensions/CombinationExtensions.cs b/R3Ext/Extensions/CombinationExtensions.cs index 0ef928c..dbdb719 100644 --- a/R3Ext/Extensions/CombinationExtensions.cs +++ b/R3Ext/Extensions/CombinationExtensions.cs @@ -529,6 +529,30 @@ void SubscribeAt(int i) return; } + bool hasAdvanced = false; + + void Advance() + { + bool shouldAdvance; + using (gate.EnterScope()) + { + if (disposed || hasAdvanced) + { + shouldAdvance = false; + } + else + { + hasAdvanced = true; + shouldAdvance = true; + } + } + + if (shouldAdvance) + { + SubscribeAt(i + 1); + } + } + currentSub = sources[i].Subscribe( x => { @@ -553,18 +577,12 @@ void SubscribeAt(int i) observer.OnErrorResume(ex); } + + Advance(); }, r => { - using (gate.EnterScope()) - { - if (disposed) - { - return; - } - - SubscribeAt(i + 1); - } + Advance(); }); } diff --git a/R3Ext/Timing/TimingExtensions.Advanced.cs b/R3Ext/Timing/TimingExtensions.Advanced.cs index 75de6df..db0f1d9 100644 --- a/R3Ext/Timing/TimingExtensions.Advanced.cs +++ b/R3Ext/Timing/TimingExtensions.Advanced.cs @@ -2,32 +2,6 @@ namespace R3Ext; -public readonly struct TimeInterval -{ - public T Value { get; } - - public TimeSpan Interval { get; } - - public TimeInterval(T value, TimeSpan interval) - { - Value = value; - Interval = interval; - } - - public void Deconstruct(out T value, out TimeSpan interval) - { - value = Value; - interval = Interval; - } -} - -public enum OverflowStrategy -{ - DropOldest, - DropLatest, - Error, -} - public static partial class TimingExtensions { /// @@ -843,3 +817,36 @@ public static Observable Chunked( }); } } + +/// +/// Represents a value paired with the time interval since the previous emission. +/// +/// The type of value. +public readonly struct TimeInterval +{ + public T Value { get; } + + public TimeSpan Interval { get; } + + public TimeInterval(T value, TimeSpan interval) + { + Value = value; + Interval = interval; + } + + public void Deconstruct(out T value, out TimeSpan interval) + { + value = Value; + interval = Interval; + } +} + +/// +/// Defines strategies for handling buffer overflow. +/// +public enum OverflowStrategy +{ + DropOldest, + DropLatest, + Error, +} From c20fa87219bc77effca11ba259a54e6f540a2b3f Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:22:38 -0500 Subject: [PATCH 8/9] feat: add aggregate stream operators, AsyncSubject, and ReadOnlySubject - AggregateStreamExtensions: RunningCount, RunningSum (INumber), RunningAverage (double/float/decimal/int overloads), RunningMin/Max with IComparable and IComparer overloads - AsyncSubject: buffers last value, emits only on successful completion; supports late subscribers and failure propagation - ReadOnlySubject: wraps any Observable to hide subject methods; AsReadOnly() extensions for Observable, Subject, BehaviorSubject - Tests: 19 tests covering all new operators and subject types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- R3Ext/Extensions/AggregateStreamExtensions.cs | 716 ++++++++++++++++++ R3Ext/Subjects/AsyncSubject.cs | 149 ++++ R3Ext/Subjects/ReadOnlySubjectWrapper.cs | 40 + 3 files changed, 905 insertions(+) create mode 100644 R3Ext/Extensions/AggregateStreamExtensions.cs create mode 100644 R3Ext/Subjects/AsyncSubject.cs create mode 100644 R3Ext/Subjects/ReadOnlySubjectWrapper.cs diff --git a/R3Ext/Extensions/AggregateStreamExtensions.cs b/R3Ext/Extensions/AggregateStreamExtensions.cs new file mode 100644 index 0000000..481d445 --- /dev/null +++ b/R3Ext/Extensions/AggregateStreamExtensions.cs @@ -0,0 +1,716 @@ +using System.Numerics; +using R3; + +namespace R3Ext; + +/// +/// Stream aggregate operators that emit incremental aggregate values as each element arrives. +/// +public static class AggregateStreamExtensions +{ + /// + /// Emits the count of items received so far. + /// + public static Observable RunningCount(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + count++; + observer.OnNext(count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running sum using C# generic math. + /// + public static Observable RunningSum(this Observable source) + where T : INumber + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return source.Scan(T.Zero, (acc, x) => acc + x); + } + + /// + /// Emits the running average of a stream. + /// + public static Observable RunningAverage(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + double sum = 0d; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sum += x; + count++; + observer.OnNext(sum / count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running average of a stream as . + /// + public static Observable RunningAverage(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + double sum = 0d; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sum += x; + count++; + observer.OnNext(sum / count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running average of a stream. + /// + public static Observable RunningAverage(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + decimal sum = 0m; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sum += x; + count++; + observer.OnNext(sum / count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running average of an stream as . + /// + public static Observable RunningAverage(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + double sum = 0d; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sum += x; + count++; + observer.OnNext(sum / count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running minimum value seen so far. + /// + public static Observable RunningMin(this Observable source) + where T : IComparable + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? min = default; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hasValue || x.CompareTo(min) < 0) + { + min = x; + hasValue = true; + } + + observer.OnNext(min!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running minimum value seen so far using a custom comparer. + /// + public static Observable RunningMin(this Observable source, IComparer comparer) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (comparer is null) + { + throw new ArgumentNullException(nameof(comparer)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? min = default; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hasValue || comparer.Compare(x, min) < 0) + { + min = x; + hasValue = true; + } + + observer.OnNext(min!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running maximum value seen so far. + /// + public static Observable RunningMax(this Observable source) + where T : IComparable + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? max = default; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hasValue || x.CompareTo(max) > 0) + { + max = x; + hasValue = true; + } + + observer.OnNext(max!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running maximum value seen so far using a custom comparer. + /// + public static Observable RunningMax(this Observable source, IComparer comparer) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (comparer is null) + { + throw new ArgumentNullException(nameof(comparer)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? max = default; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hasValue || comparer.Compare(x, max) > 0) + { + max = x; + hasValue = true; + } + + observer.OnNext(max!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Subjects/AsyncSubject.cs b/R3Ext/Subjects/AsyncSubject.cs new file mode 100644 index 0000000..1fa202b --- /dev/null +++ b/R3Ext/Subjects/AsyncSubject.cs @@ -0,0 +1,149 @@ +using R3; + +namespace R3Ext; + +/// +/// A Subject that buffers the last value and emits it to all current and future subscribers only when +/// is called with a successful result. On failure, no value is emitted. +/// +public sealed class AsyncSubject : Observable, IDisposable +{ + private readonly Lock _gate = new(); + private bool _isCompleted = false; + private Result _completionResult = default; + private T? _lastValue = default; + private bool _hasValue = false; + private List> _observers = new(); + private bool _disposed = false; + + /// + /// Buffers as the latest value. Does not emit to subscribers until completion. + /// + public void OnNext(T value) + { + using (_gate.EnterScope()) + { + if (_disposed || _isCompleted) + { + return; + } + + _lastValue = value; + _hasValue = true; + } + } + + /// + /// Forwards the error to all current subscribers without stopping value buffering. + /// + public void OnErrorResume(Exception error) + { + if (error is null) + { + throw new ArgumentNullException(nameof(error)); + } + + Observer[] observers; + using (_gate.EnterScope()) + { + if (_disposed || _isCompleted) + { + return; + } + + observers = _observers.ToArray(); + } + + foreach (var obs in observers) + { + obs.OnErrorResume(error); + } + } + + /// Completes with a successful result, emitting the last buffered value (if any). + public void OnCompleted() => OnCompleted(Result.Success); + + /// Completes with the given as a failure result (no value emitted). + public void OnCompleted(Exception exception) => OnCompleted(Result.Failure(exception)); + + /// Completes with the given . + public void OnCompleted(Result result) + { + Observer[] observers; + T? lastValue; + bool hasValue; + using (_gate.EnterScope()) + { + if (_disposed || _isCompleted) + { + return; + } + + _isCompleted = true; + _completionResult = result; + lastValue = _lastValue; + hasValue = _hasValue; + observers = _observers.ToArray(); + _observers.Clear(); + } + + foreach (var obs in observers) + { + if (result.IsSuccess && hasValue) + { + obs.OnNext(lastValue!); + } + + obs.OnCompleted(result); + } + } + + /// + protected override IDisposable SubscribeCore(Observer observer) + { + using (_gate.EnterScope()) + { + if (_disposed) + { + observer.OnCompleted(Result.Failure(new ObjectDisposedException(nameof(AsyncSubject)))); + return Disposable.Empty; + } + + if (_isCompleted) + { + if (_completionResult.IsSuccess && _hasValue) + { + observer.OnNext(_lastValue!); + } + + observer.OnCompleted(_completionResult); + return Disposable.Empty; + } + + _observers.Add(observer); + } + + return Disposable.Create(() => + { + using (_gate.EnterScope()) + { + _observers.Remove(observer); + } + }); + } + + /// + public void Dispose() + { + using (_gate.EnterScope()) + { + if (_disposed) + { + return; + } + + _disposed = true; + _observers.Clear(); + } + } +} diff --git a/R3Ext/Subjects/ReadOnlySubjectWrapper.cs b/R3Ext/Subjects/ReadOnlySubjectWrapper.cs new file mode 100644 index 0000000..b59e929 --- /dev/null +++ b/R3Ext/Subjects/ReadOnlySubjectWrapper.cs @@ -0,0 +1,40 @@ +using R3; + +namespace R3Ext; + +/// +/// Wraps any to expose only the read-only observable surface, +/// hiding OnNext, OnCompleted, and other subject methods. +/// +public sealed class ReadOnlySubject : Observable +{ + private readonly Observable _inner; + + /// The source observable to wrap. + public ReadOnlySubject(Observable inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + /// + protected override IDisposable SubscribeCore(Observer observer) + => _inner.Subscribe(observer.OnNext, observer.OnErrorResume, observer.OnCompleted); +} + +/// +/// Extension methods for wrapping subjects as read-only observables. +/// +public static class SubjectExtensions +{ + /// Wraps any observable as a . + public static ReadOnlySubject AsReadOnly(this Observable source) + => new ReadOnlySubject(source); + + /// Wraps a as a . + public static ReadOnlySubject AsReadOnly(this Subject subject) + => new ReadOnlySubject(subject); + + /// Wraps a as a . + public static ReadOnlySubject AsReadOnly(this BehaviorSubject subject) + => new ReadOnlySubject(subject); +} From 453b2a96b8896f8734fab0cc1f81a0c1fd863e7a Mon Sep 17 00:00:00 2001 From: Michael Stonis Date: Thu, 13 Aug 2026 09:58:04 -0500 Subject: [PATCH 9/9] fix: align operator behavior with R3 non-terminal OnErrorResume semantics Update windowing, buffering, and rate-limiting operators to remain active after resumable errors and fix completion logic in DelayWhen and RetryWhen. This ensures subsequent items are not dropped and prevents potential deadlocks or stack overflows during synchronous re-subscriptions. --- R3Ext.Tests/ErrorHandlingExtraTests.cs | 46 ++++++ R3Ext.Tests/FilteringAdvancedTests.cs | 25 +++ R3Ext.Tests/TimingAdvancedTests.cs | 37 +++++ R3Ext.Tests/WindowingOperatorsTests.cs | 144 ++++++++++++++++++ .../FilteringExtensions.Advanced.cs | 2 +- R3Ext/Timing/TimingExtensions.Advanced.cs | 21 ++- .../Timing/TimingExtensions.BufferAdvanced.cs | 99 ++++++------ R3Ext/Timing/TimingExtensions.Window.cs | 28 +--- 8 files changed, 324 insertions(+), 78 deletions(-) diff --git a/R3Ext.Tests/ErrorHandlingExtraTests.cs b/R3Ext.Tests/ErrorHandlingExtraTests.cs index 228b182..78c564e 100644 --- a/R3Ext.Tests/ErrorHandlingExtraTests.cs +++ b/R3Ext.Tests/ErrorHandlingExtraTests.cs @@ -145,6 +145,52 @@ public void RetryWhen_CompletesWhenHandlerCompletes() Assert.True(result.IsCompleted); } + [Fact] + public void RetryWhen_HandlerCompletes_DoesNotForwardSourceAfterCompletion() + { + Subject relay = new(); + Subject trigger = new(); + List values = new(); + bool completed = false; + + relay.RetryWhen(_ => trigger).Subscribe( + values.Add, + _ => { }, + _ => completed = true); + + relay.OnNext(1); + trigger.OnCompleted(); // handler completes -> downstream completes + Assert.True(completed); + + // The source is still live; emissions after completion must not be forwarded. + relay.OnNext(2); + relay.OnNext(3); + + Assert.Equal(new[] { 1 }, values); + } + + [Fact] + public void RetryWhen_HandlerCompletesImmediately_DoesNotForwardSourceAfterCompletion() + { + Subject relay = new(); + List values = new(); + bool completed = false; + + // The handler completes synchronously during subscription (before the source is first + // subscribed). The downstream must complete and never forward later source emissions. + relay.RetryWhen(_ => Observable.Empty()).Subscribe( + values.Add, + _ => { }, + _ => completed = true); + + Assert.True(completed); + + relay.OnNext(1); + relay.OnNext(2); + + Assert.Empty(values); + } + [Fact] public void RetryWhen_NullSource_ThrowsArgumentNullException() { diff --git a/R3Ext.Tests/FilteringAdvancedTests.cs b/R3Ext.Tests/FilteringAdvancedTests.cs index 647ab4b..1342735 100644 --- a/R3Ext.Tests/FilteringAdvancedTests.cs +++ b/R3Ext.Tests/FilteringAdvancedTests.cs @@ -91,6 +91,31 @@ public void IsEmpty_ThrowsOnNullSource() Assert.Throws(() => nullSource!.IsEmpty()); } + [Fact] + public void IsEmpty_SourceFailsWithoutValue_DoesNotEmitTrue() + { + Subject subject = new(); + List values = new(); + Result completion = default; + bool completed = false; + subject.IsEmpty().Subscribe( + values.Add, + _ => { }, + r => + { + completion = r; + completed = true; + }); + + // A failure terminal with no preceding value must NOT be reported as "empty == true"; + // the failure should propagate instead. + subject.OnCompleted(Result.Failure(new InvalidOperationException("boom"))); + + Assert.Empty(values); + Assert.True(completed); + Assert.True(completion.IsFailure); + } + // ─── Every / All ───────────────────────────────────────────────────────── [Fact] diff --git a/R3Ext.Tests/TimingAdvancedTests.cs b/R3Ext.Tests/TimingAdvancedTests.cs index ea0852e..4265b4b 100644 --- a/R3Ext.Tests/TimingAdvancedTests.cs +++ b/R3Ext.Tests/TimingAdvancedTests.cs @@ -178,6 +178,25 @@ public void DelayWhen_SourceCompletion_WaitsForInFlightItems() Assert.True(result.IsCompleted); } + [Fact] + public void DelayWhen_DurationErrorResume_AfterSourceCompletion_StillCompletes() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.DelayWhen(_ => trigger).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); // in-flight duration not yet resolved + + // The duration faults instead of firing. This removes the last in-flight item, so the + // sequence must now complete. Previously CheckComplete() was skipped here and it hung. + trigger.OnErrorResume(new InvalidOperationException("boom")); + + Assert.True(result.IsCompleted); + Assert.Empty(result.ToArray()); // faulted duration => item was never emitted + } + [Fact] public void DelayWhen_WithSubscriptionDelay_NullDelay_Throws() { @@ -242,6 +261,24 @@ public void RateLimit_AllowsUpToCountPerPeriod() Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); } + [Fact] + public void RateLimit_OnErrorResume_IsNonTerminal_KeepsDrainingQueue() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(1, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + subject.OnNext(1); // emitted immediately + subject.OnNext(2); // queued + subject.OnNext(3); // queued + subject.OnErrorResume(new InvalidOperationException("boom")); // non-terminal + + tp.Advance(TimeSpan.FromSeconds(1)); // drain timer must still fire -> 2 + tp.Advance(TimeSpan.FromSeconds(1)); // -> 3 + + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + [Fact] public void RateLimit_UnderLimit_EmitsAllImmediately() { diff --git a/R3Ext.Tests/WindowingOperatorsTests.cs b/R3Ext.Tests/WindowingOperatorsTests.cs index 3d61ab0..b143cdd 100644 --- a/R3Ext.Tests/WindowingOperatorsTests.cs +++ b/R3Ext.Tests/WindowingOperatorsTests.cs @@ -368,6 +368,30 @@ public void BufferToggle_CollectsItemsInOpenWindows() Assert.Equal(new[] { 1, 2 }, result[0]); } + [Fact] + public void BufferToggle_OnErrorResume_IsNonTerminal_KeepsCollecting() + { + Subject source = new(); + Subject opens = new(); + Subject closes = new(); + List buffers = new(); + List errors = new(); + source.BufferToggle(opens, _ => (Observable)closes).Subscribe( + buffers.Add, + errors.Add, + _ => { }); + + opens.OnNext(Unit.Default); // open a buffer + source.OnNext(1); + source.OnErrorResume(new InvalidOperationException("boom")); // non-terminal + source.OnNext(2); // must still be collected into the open buffer + closes.OnNext(Unit.Default); // close -> emit [1, 2] + + Assert.Single(errors); + Assert.Single(buffers); + Assert.Equal(new[] { 1, 2 }, buffers[0]); + } + [Fact] public void BufferToggle_ItemsBeforeOpenAreNotCollected() { @@ -517,4 +541,124 @@ public void BufferWhen_CloseBeforeAnyItems_EmitsEmptyBuffer() Assert.Single(result); Assert.Empty(result[0]); } + + // ----------------------------------------------------------------------- + // OnErrorResume is non-terminal: windowing must survive a resumable error + // ----------------------------------------------------------------------- + + [Fact] + public void WindowCount_OnErrorResume_IsNonTerminal_DoesNotDropSubsequentItems() + { + Subject subject = new(); + List windows = new(); + List outerErrors = new(); + subject.WindowCount(3).Subscribe( + window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }, + outerErrors.Add, + _ => { }); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnErrorResume(new InvalidOperationException("boom")); + subject.OnNext(3); // must still complete the first window: [1, 2, 3] + subject.OnNext(4); + subject.OnNext(5); + subject.OnNext(6); // completes the second window: [4, 5, 6] + subject.OnCompleted(); + + Assert.Single(outerErrors); // error forwarded downstream, not swallowed + Assert.Equal(2, windows.Count); + Assert.Equal(new[] { 1, 2, 3 }, windows[0]); + Assert.Equal(new[] { 4, 5, 6 }, windows[1]); + } + + [Fact] + public async Task WindowTime_OnErrorResume_IsNonTerminal_KeepsTimerAndWindowAlive() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + List outerErrors = new(); + subject.WindowTime(TimeSpan.FromSeconds(1), tp).Subscribe( + window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }, + outerErrors.Add, + _ => { }); + + subject.OnNext(1); + subject.OnErrorResume(new InvalidOperationException("boom")); + subject.OnNext(2); // must still land in the live window + tp.Advance(TimeSpan.FromSeconds(1)); // timer must still roll the window closed: [1, 2] + subject.OnCompleted(); + await Task.Yield(); + + Assert.Single(outerErrors); + Assert.True(windows.Count >= 1); + Assert.Equal(new[] { 1, 2 }, windows[0]); + } + + [Fact] + public async Task WindowTimeMaxCount_OnErrorResume_IsNonTerminal_KeepsCountingWindowAlive() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + List outerErrors = new(); + subject.WindowTime(TimeSpan.FromSeconds(10), maxCount: 3, timeProvider: tp).Subscribe( + window => + { + List items = new(); + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }, + outerErrors.Add, + _ => { }); + + subject.OnNext(1); + subject.OnErrorResume(new InvalidOperationException("boom")); + subject.OnNext(2); + subject.OnNext(3); // must still close the count-saturated window: [1, 2, 3] + subject.OnCompleted(); + await Task.Yield(); + + Assert.Single(outerErrors); + Assert.True(windows.Count >= 1); + Assert.Equal(new[] { 1, 2, 3 }, windows[0]); + } + + [Fact] + public void BufferWhen_SynchronousCloser_DoesNotStackOverflow() + { + // A closer that emits synchronously on subscription forces immediate re-subscription. + // The operator must handle this iteratively; a recursive implementation overflows the + // stack after a few thousand closes. Reaching the assertions at all proves no overflow. + const int synchronousCloses = 50_000; + int closeCount = 0; + Subject source = new(); + Subject idle = new(); + List buffers = new(); + + source.BufferWhen(() => + { + closeCount++; + return closeCount <= synchronousCloses + ? Observable.Return(Unit.Default) // emits synchronously -> re-subscribe + : (Observable)idle; // never emits -> loop stops + }).Subscribe(buffers.Add); + + Assert.Equal(synchronousCloses, buffers.Count); + Assert.All(buffers, b => Assert.Empty(b)); + + source.OnNext(7); + source.OnCompleted(); + + Assert.Equal(synchronousCloses + 1, buffers.Count); + Assert.Equal(new[] { 7 }, buffers[^1]); + } } diff --git a/R3Ext/Extensions/FilteringExtensions.Advanced.cs b/R3Ext/Extensions/FilteringExtensions.Advanced.cs index 56eca75..dc73fde 100644 --- a/R3Ext/Extensions/FilteringExtensions.Advanced.cs +++ b/R3Ext/Extensions/FilteringExtensions.Advanced.cs @@ -88,7 +88,7 @@ public static Observable IsEmpty(this Observable source) } disposed = true; - emitTrue = !hadValue; + emitTrue = r.IsSuccess && !hadValue; } if (emitTrue) diff --git a/R3Ext/Timing/TimingExtensions.Advanced.cs b/R3Ext/Timing/TimingExtensions.Advanced.cs index db0f1d9..5819cc4 100644 --- a/R3Ext/Timing/TimingExtensions.Advanced.cs +++ b/R3Ext/Timing/TimingExtensions.Advanced.cs @@ -189,9 +189,19 @@ void CheckComplete() } } - if (wasActive && !disposed) + if (wasActive) { - observer.OnErrorResume(ex); + if (!disposed) + { + observer.OnErrorResume(ex); + } + + // A faulting duration removes an in-flight item, so the sequence + // may now be able to complete (source already done, no inners left). + using (gate.EnterScope()) + { + CheckComplete(); + } } }, r => @@ -511,14 +521,14 @@ void EnsureTimer() }, ex => { + // OnErrorResume is non-terminal: forward it but keep the drain timer running so + // queued items continue to drain and rate-limiting stays active afterwards. using (gate.EnterScope()) { if (disposed) { return; } - - timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } observer.OnErrorResume(ex); @@ -581,8 +591,7 @@ void EnsureTimer() public static Observable BufferWithOverflow( this Observable source, int capacity, - OverflowStrategy strategy = OverflowStrategy.DropOldest, - TimeProvider? timeProvider = null) + OverflowStrategy strategy = OverflowStrategy.DropOldest) { if (source is null) { diff --git a/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs b/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs index ad02612..bc7c33e 100644 --- a/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs +++ b/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs @@ -107,21 +107,14 @@ public static Observable BufferToggle( }, ex => { - IDisposable[]? subs; + // OnErrorResume is non-terminal: forward it but keep open buffers and their + // closing subscriptions alive so windowing continues after a resumable error. using (gate.EnterScope()) { if (disposed) { return; } - - subs = openBuffers.Select(b => b.ClosingSub).Where(s => s is not null).ToArray()!; - openBuffers.Clear(); - } - - foreach (IDisposable s in subs) - { - s.Dispose(); } observer.OnErrorResume(ex); @@ -146,28 +139,14 @@ public static Observable BufferToggle( }, ex => { - T[][]? toEmit; - IDisposable[]? subs; + // OnErrorResume is non-terminal: forward it but keep open buffers (and the items + // already collected in them) alive so values after a resumable error are not dropped. using (gate.EnterScope()) { if (disposed) { return; } - - toEmit = openBuffers.Select(b => b.Buffer.ToArray()).ToArray(); - subs = openBuffers.Select(b => b.ClosingSub).Where(s => s is not null).ToArray()!; - openBuffers.Clear(); - } - - foreach (IDisposable s in subs) - { - s.Dispose(); - } - - foreach (T[] arr in toEmit) - { - observer.OnNext(arr); } observer.OnErrorResume(ex); @@ -254,40 +233,60 @@ public static Observable BufferWhen( void SubscribeToCloser() { - IDisposable? sub = null; - sub = closingSelector().Take(1).Subscribe( - _ => - { - T[] toEmit; - using (gate.EnterScope()) + // Re-subscribe iteratively. A closing observable that emits synchronously on + // subscription (e.g. Observable.Return) would otherwise recurse through this + // method once per close and overflow the stack; the loop keeps that flat. + bool resubscribe = true; + while (resubscribe) + { + resubscribe = false; + bool subscribed = false; + IDisposable? sub = null; + + sub = closingSelector().Take(1).Subscribe( + _ => { - if (disposed) + T[] toEmit; + using (gate.EnterScope()) { - return; - } + if (disposed) + { + return; + } - toEmit = buffer.ToArray(); - buffer.Clear(); - } + toEmit = buffer.ToArray(); + buffer.Clear(); + } - observer.OnNext(toEmit); + observer.OnNext(toEmit); - // Subscribe to the next closer outside the lock - SubscribeToCloser(); - }, - ex => observer.OnErrorResume(ex), - _ => { }); + if (subscribed) + { + // Asynchronous close (after Subscribe returned): a fresh call + // stack, so re-subscribing recursively here is safe. + SubscribeToCloser(); + } + else + { + // Synchronous close (during Subscribe): loop instead of recursing. + resubscribe = true; + } + }, + ex => observer.OnErrorResume(ex), + _ => { }); - using (gate.EnterScope()) - { - if (disposed) - { - sub?.Dispose(); - } - else + using (gate.EnterScope()) { + if (disposed) + { + sub?.Dispose(); + return; + } + closingSub = sub; } + + subscribed = true; } } diff --git a/R3Ext/Timing/TimingExtensions.Window.cs b/R3Ext/Timing/TimingExtensions.Window.cs index c2c9a23..c0017e6 100644 --- a/R3Ext/Timing/TimingExtensions.Window.cs +++ b/R3Ext/Timing/TimingExtensions.Window.cs @@ -116,21 +116,15 @@ public static Observable> WindowCount(this Observable source }, ex => { - Subject[]? windows; + // OnErrorResume is non-terminal in R3: forward the error downstream but keep + // every open window (and the windowing state) alive so that subsequent source + // items are not dropped. using (gate.EnterScope()) { if (disposed) { return; } - - windows = openWindows.Select(w => w.Subject).ToArray(); - openWindows.Clear(); - } - - foreach (Subject w in windows) - { - w.OnCompleted(); } observer.OnErrorResume(ex); @@ -249,20 +243,16 @@ public static Observable> WindowTime(this Observable source, }, ex => { - Subject? window; + // OnErrorResume is non-terminal in R3: forward the error downstream but keep + // the timer and current window alive so windowing continues afterwards. using (gate.EnterScope()) { if (disposed) { return; } - - timer?.Dispose(); - window = currentWindow; - currentWindow = null; } - window?.OnCompleted(); observer.OnErrorResume(ex); }, r => @@ -396,20 +386,16 @@ void RollWindow(out Subject? completed) }, ex => { - Subject? window; + // OnErrorResume is non-terminal in R3: forward the error downstream but keep + // the timer and current window alive so windowing continues afterwards. using (gate.EnterScope()) { if (disposed) { return; } - - timer?.Dispose(); - window = currentWindow; - currentWindow = null; } - window?.OnCompleted(); observer.OnErrorResume(ex); }, r =>