From d9f23986fa3072a380be3c46e344791ae9a34ba0 Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:34:38 -0400 Subject: [PATCH] test(xunit): CurrentErrorItemCount default-zero + no-over-read (#49) contract tests - CurrentErrorItemCount_defaults_to_zero on all three base contract classes (the new 0.18 counter, parallel to the #248 skipped-count default-zero tests). - No-over-read contract tests (#49) on all three bases: a stage must stop pulling from its source once MaximumItemCount is reached (<= M+1 reads) or the run is cancelled, and a pre-cancelled token must read nothing. An internal PullCounter wraps the source. Extractors opt in via a new virtual CreateSutOverSource factory (no-op by default), wired for the TestExtractor doubles. - CHANGELOG: document the #248 counter tests and #49. +12 tests. Full multi-TFM build clean; 295 xunit tests pass on net10.0 and net48. Closes #49 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 ++ .../ExtractorBaseContractTests.cs | 111 ++++++++++++++++++ .../LoaderBaseContractTests.cs | 85 ++++++++++++++ .../PublicAPI.Unshipped.txt | 13 ++ src/Wolfgang.Etl.TestKit.Xunit/PullCounter.cs | 54 +++++++++ .../TransformerBaseContractTests.cs | 88 ++++++++++++++ .../TestExtractorContractTests.cs | 4 + 7 files changed, 364 insertions(+) create mode 100644 src/Wolfgang.Etl.TestKit.Xunit/PullCounter.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a3992884..02566035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,15 @@ public API only — no breaking change. - `DisposableStageContractTests` — an opt-in xUnit contract-test base verifying a stage throws `ObjectDisposedException` after `Dispose()`/`DisposeAsync()` (the 0.17 use-after-dispose guard) and that disposing twice is a harmless no-op. +- Counter contract tests on `ExtractorBaseContractTests`, `LoaderBaseContractTests`, and + `TransformerBaseContractTests`: `CurrentItemCount` / `CurrentSkippedItemCount` / + `CurrentErrorItemCount` default-to-zero and skip-count-tracking assertions, inherited free by + every downstream contract-test class (#248). +- "No over-read" contract tests (#49) on all three base classes: a stage must stop pulling from + its source once `MaximumItemCount` is reached (≤ M+1 reads) or the run is cancelled, and a + pre-cancelled token must read nothing. Extractors opt in by overriding the new + `CreateSutOverSource` factory (a no-op by default for extractors whose source is not an + injectable sequence). ### Changed diff --git a/src/Wolfgang.Etl.TestKit.Xunit/ExtractorBaseContractTests.cs b/src/Wolfgang.Etl.TestKit.Xunit/ExtractorBaseContractTests.cs index 88c5cd02..78741968 100644 --- a/src/Wolfgang.Etl.TestKit.Xunit/ExtractorBaseContractTests.cs +++ b/src/Wolfgang.Etl.TestKit.Xunit/ExtractorBaseContractTests.cs @@ -813,4 +813,115 @@ public async Task ExtractAsync_CurrentSkippedItemCount_reflects_the_number_of_it Assert.Equal(2, sut.CurrentSkippedItemCount); } + + /// + /// Verifies that CurrentErrorItemCount is zero on a freshly created extractor, before + /// any item has failed (Abstractions 0.18.0 error hook). + /// + [Fact] + public void CurrentErrorItemCount_defaults_to_zero() + { + var sut = CreateSut(); + + Assert.Equal(0, sut.CurrentErrorItemCount); + } + + + + // ------------------------------------------------------------------ + // No over-read (issue #49) + // ------------------------------------------------------------------ + + /// + /// Override to enable the "no over-read" tests for an extractor that reads from an + /// injectable in-memory sequence. Return an extractor that draws its items from + /// , or (the default) to skip those tests — + /// appropriate for an extractor whose source is a connection or handle that cannot be a + /// caller-supplied sequence. + /// + /// The sequence the returned extractor must read from. + protected virtual TSut? CreateSutOverSource(IEnumerable source) => default; + + /// + /// Verifies that once MaximumItemCount is reached the extractor stops pulling from its + /// source rather than draining it — at most M+1 reads (the +1 discovers the limit). Skipped + /// unless is overridden. + /// + [Fact] + public async Task ExtractAsync_does_not_over_read_past_MaximumItemCount_Async() + { + var counter = new PullCounter(); + var sut = CreateSutOverSource(counter.CountSync(CreateExpectedItems())); + if (sut is null) + { + return; + } + + sut.MaximumItemCount = 3; + + await sut.ExtractAsync().ToListAsync().ConfigureAwait(false); + + Assert.True(counter.Count <= 4, $"Expected at most 4 upstream reads, saw {counter.Count}."); + } + + // Cancel() runs synchronously to cancel mid-enumeration; CancelAsync is net8.0+ only and + // this base targets net462+. +#pragma warning disable CA1849, VSTHRD103 + /// + /// Verifies that cancelling mid-enumeration stops the extractor pulling from its source at + /// the next check. Skipped unless is overridden. + /// + [Fact] + public async Task ExtractAsync_stops_reading_on_cancellation_Async() + { + using var cts = new CancellationTokenSource(); + var counter = new PullCounter(); + var sut = CreateSutOverSource(counter.CountSync(CreateExpectedItems())); + if (sut is null) + { + return; + } + + var seen = 0; + + try + { + await foreach (var _ in sut.ExtractAsync(cts.Token).ConfigureAwait(false)) + { + if (++seen == 3) + { + cts.Cancel(); + } + } + } + catch (OperationCanceledException) + { + } + + Assert.True(counter.Count <= 4, $"Expected at most 4 upstream reads, saw {counter.Count}."); + } +#pragma warning restore CA1849, VSTHRD103 + + /// + /// Verifies that a pre-cancelled token short-circuits the extractor before it pulls any item + /// from its source. Skipped unless is overridden. + /// + [Fact] + public async Task ExtractAsync_with_a_pre_cancelled_token_reads_nothing_Async() + { + var token = new CancellationToken(canceled: true); + var counter = new PullCounter(); + var sut = CreateSutOverSource(counter.CountSync(CreateExpectedItems())); + if (sut is null) + { + return; + } + + await Assert.ThrowsAnyAsync + ( + () => sut.ExtractAsync(token).ToListAsync(token).AsTask() + ).ConfigureAwait(false); + + Assert.Equal(0, counter.Count); + } } diff --git a/src/Wolfgang.Etl.TestKit.Xunit/LoaderBaseContractTests.cs b/src/Wolfgang.Etl.TestKit.Xunit/LoaderBaseContractTests.cs index cd74215c..92811bba 100644 --- a/src/Wolfgang.Etl.TestKit.Xunit/LoaderBaseContractTests.cs +++ b/src/Wolfgang.Etl.TestKit.Xunit/LoaderBaseContractTests.cs @@ -877,4 +877,89 @@ public async Task LoadAsync_CurrentSkippedItemCount_reflects_the_number_of_items Assert.Equal(2, sut.CurrentSkippedItemCount); } + + /// + /// Verifies that CurrentErrorItemCount is zero on a freshly created loader, before any + /// item has failed (Abstractions 0.18.0 error hook). + /// + [Fact] + public void CurrentErrorItemCount_defaults_to_zero() + { + var sut = CreateSut(); + + Assert.Equal(0, sut.CurrentErrorItemCount); + } + + + + // ------------------------------------------------------------------ + // No over-read (issue #49) + // ------------------------------------------------------------------ + + /// + /// Verifies that once MaximumItemCount is reached the loader stops pulling from its + /// source rather than draining it — at most M+1 reads (the +1 discovers the limit). + /// + [Fact] + public async Task LoadAsync_does_not_over_read_past_MaximumItemCount_Async() + { + var sut = CreateSut(); + sut.MaximumItemCount = 3; + var counter = new PullCounter(); + + await sut.LoadAsync(counter.CountAsync(CreateInputItemsAsync())).ConfigureAwait(false); + + Assert.True(counter.Count <= 4, $"Expected at most 4 upstream reads, saw {counter.Count}."); + } + + // Cancel() runs synchronously to cancel mid-enumeration; CancelAsync is net8.0+ only and + // this base targets net462+. +#pragma warning disable CA1849, VSTHRD103 + /// + /// Verifies that cancelling mid-run stops the loader pulling from its source at the next + /// check, rather than draining the already-available items. + /// + [Fact] + public async Task LoadAsync_stops_reading_on_cancellation_Async() + { + var sut = CreateSut(); + using var cts = new CancellationTokenSource(); + var counter = new PullCounter(); + var source = counter.CountAsync + ( + CreateInputItemsAsync(), + onPull: () => { if (counter.Count == 3) { cts.Cancel(); } }, + token: cts.Token + ); + + try + { + await sut.LoadAsync(source, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + + Assert.True(counter.Count <= 4, $"Expected at most 4 upstream reads, saw {counter.Count}."); + } +#pragma warning restore CA1849, VSTHRD103 + + /// + /// Verifies that a pre-cancelled token short-circuits the loader before it pulls any item + /// from its source. + /// + [Fact] + public async Task LoadAsync_with_a_pre_cancelled_token_reads_nothing_Async() + { + var sut = CreateSut(); + var token = new CancellationToken(canceled: true); + var counter = new PullCounter(); + + await Assert.ThrowsAnyAsync + ( + () => sut.LoadAsync(counter.CountAsync(CreateInputItemsAsync(), token: token), token) + ).ConfigureAwait(false); + + Assert.Equal(0, counter.Count); + } } diff --git a/src/Wolfgang.Etl.TestKit.Xunit/PublicAPI.Unshipped.txt b/src/Wolfgang.Etl.TestKit.Xunit/PublicAPI.Unshipped.txt index 453fc4f1..0cf45167 100644 --- a/src/Wolfgang.Etl.TestKit.Xunit/PublicAPI.Unshipped.txt +++ b/src/Wolfgang.Etl.TestKit.Xunit/PublicAPI.Unshipped.txt @@ -32,3 +32,16 @@ Wolfgang.Etl.TestKit.Xunit.LoaderBaseContractTests.LoadA Wolfgang.Etl.TestKit.Xunit.TransformerBaseContractTests.CurrentItemCount_defaults_to_zero() -> void Wolfgang.Etl.TestKit.Xunit.TransformerBaseContractTests.CurrentSkippedItemCount_defaults_to_zero() -> void Wolfgang.Etl.TestKit.Xunit.TransformerBaseContractTests.TransformAsync_CurrentSkippedItemCount_reflects_the_number_of_items_skipped_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.ExtractorBaseContractTests.CurrentErrorItemCount_defaults_to_zero() -> void +virtual Wolfgang.Etl.TestKit.Xunit.ExtractorBaseContractTests.CreateSutOverSource(System.Collections.Generic.IEnumerable! source) -> TSut? +Wolfgang.Etl.TestKit.Xunit.ExtractorBaseContractTests.ExtractAsync_does_not_over_read_past_MaximumItemCount_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.ExtractorBaseContractTests.ExtractAsync_stops_reading_on_cancellation_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.ExtractorBaseContractTests.ExtractAsync_with_a_pre_cancelled_token_reads_nothing_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.LoaderBaseContractTests.CurrentErrorItemCount_defaults_to_zero() -> void +Wolfgang.Etl.TestKit.Xunit.LoaderBaseContractTests.LoadAsync_does_not_over_read_past_MaximumItemCount_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.LoaderBaseContractTests.LoadAsync_stops_reading_on_cancellation_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.LoaderBaseContractTests.LoadAsync_with_a_pre_cancelled_token_reads_nothing_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.TransformerBaseContractTests.CurrentErrorItemCount_defaults_to_zero() -> void +Wolfgang.Etl.TestKit.Xunit.TransformerBaseContractTests.TransformAsync_does_not_over_read_past_MaximumItemCount_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.TransformerBaseContractTests.TransformAsync_stops_reading_on_cancellation_Async() -> System.Threading.Tasks.Task! +Wolfgang.Etl.TestKit.Xunit.TransformerBaseContractTests.TransformAsync_with_a_pre_cancelled_token_reads_nothing_Async() -> System.Threading.Tasks.Task! diff --git a/src/Wolfgang.Etl.TestKit.Xunit/PullCounter.cs b/src/Wolfgang.Etl.TestKit.Xunit/PullCounter.cs new file mode 100644 index 00000000..92a5e576 --- /dev/null +++ b/src/Wolfgang.Etl.TestKit.Xunit/PullCounter.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Wolfgang.Etl.TestKit.Xunit; + +/// +/// Counts how many items a stage pulls from a wrapped source, so the "no over-read" contract +/// tests (issue #49) can assert that a stage stops reading upstream once +/// MaximumItemCount is reached or the run is cancelled, rather than draining the whole +/// source. Internal test-harness helper. +/// +internal sealed class PullCounter +{ + private int _count; + + /// The number of items pulled from the wrapped source so far. + public int Count => Volatile.Read(ref _count); + + /// + /// Wraps an async source, incrementing as each item is pulled and + /// awaiting after each increment (used to trigger cancellation + /// after a chosen number of pulls). + /// + public async IAsyncEnumerable CountAsync + ( + IAsyncEnumerable source, + Action? onPull = null, + [EnumeratorCancellation] CancellationToken token = default + ) + { + await foreach (var item in source.WithCancellation(token).ConfigureAwait(false)) + { + _ = Interlocked.Increment(ref _count); + onPull?.Invoke(); + yield return item; + } + } + + /// + /// Wraps a synchronous source (an extractor's in-memory sequence), incrementing + /// as each item is pulled. + /// + public IEnumerable CountSync(IEnumerable source) + { + foreach (var item in source) + { + _ = Interlocked.Increment(ref _count); + yield return item; + } + } +} diff --git a/src/Wolfgang.Etl.TestKit.Xunit/TransformerBaseContractTests.cs b/src/Wolfgang.Etl.TestKit.Xunit/TransformerBaseContractTests.cs index e864980b..ef36884d 100644 --- a/src/Wolfgang.Etl.TestKit.Xunit/TransformerBaseContractTests.cs +++ b/src/Wolfgang.Etl.TestKit.Xunit/TransformerBaseContractTests.cs @@ -912,4 +912,92 @@ public async Task TransformAsync_CurrentSkippedItemCount_reflects_the_number_of_ Assert.Equal(2, sut.CurrentSkippedItemCount); } + + /// + /// Verifies that CurrentErrorItemCount is zero on a freshly created transformer, before + /// any item has failed (Abstractions 0.18.0 error hook). + /// + [Fact] + public void CurrentErrorItemCount_defaults_to_zero() + { + var sut = CreateSut(); + + Assert.Equal(0, sut.CurrentErrorItemCount); + } + + + + // ------------------------------------------------------------------ + // No over-read (issue #49) + // ------------------------------------------------------------------ + + /// + /// Verifies that once MaximumItemCount is reached the transformer stops pulling from + /// its source rather than draining it — at most M+1 reads (the +1 discovers the limit). + /// + [Fact] + public async Task TransformAsync_does_not_over_read_past_MaximumItemCount_Async() + { + var sut = CreateSut(); + sut.MaximumItemCount = 3; + var counter = new PullCounter(); + + await sut.TransformAsync(counter.CountAsync(CreateInputItemsAsync())).ToListAsync().ConfigureAwait(false); + + Assert.True(counter.Count <= 4, $"Expected at most 4 upstream reads, saw {counter.Count}."); + } + + // Cancel() runs synchronously to cancel mid-enumeration; CancelAsync is net8.0+ only and + // this base targets net462+. +#pragma warning disable CA1849, VSTHRD103 + /// + /// Verifies that cancelling mid-run stops the transformer pulling from its source at the + /// next check, rather than draining the already-available items. + /// + [Fact] + public async Task TransformAsync_stops_reading_on_cancellation_Async() + { + var sut = CreateSut(); + using var cts = new CancellationTokenSource(); + var counter = new PullCounter(); + var seen = 0; + + try + { + var source = counter.CountAsync(CreateInputItemsAsync(), token: cts.Token); + + await foreach (var _ in sut.TransformAsync(source, cts.Token).ConfigureAwait(false)) + { + if (++seen == 3) + { + cts.Cancel(); + } + } + } + catch (OperationCanceledException) + { + } + + Assert.True(counter.Count <= 4, $"Expected at most 4 upstream reads, saw {counter.Count}."); + } +#pragma warning restore CA1849, VSTHRD103 + + /// + /// Verifies that a pre-cancelled token short-circuits the transformer before it pulls any + /// item from its source. + /// + [Fact] + public async Task TransformAsync_with_a_pre_cancelled_token_reads_nothing_Async() + { + var sut = CreateSut(); + var token = new CancellationToken(canceled: true); + var counter = new PullCounter(); + + await Assert.ThrowsAnyAsync + ( + () => sut.TransformAsync(counter.CountAsync(CreateInputItemsAsync(), token: token), token).ToListAsync(token).AsTask() + ).ConfigureAwait(false); + + Assert.Equal(0, counter.Count); + } } diff --git a/tests/Wolfgang.Etl.TestKit.Xunit.Tests.Unit/TestExtractorContractTests.cs b/tests/Wolfgang.Etl.TestKit.Xunit.Tests.Unit/TestExtractorContractTests.cs index de16e6cb..1f01fa44 100644 --- a/tests/Wolfgang.Etl.TestKit.Xunit.Tests.Unit/TestExtractorContractTests.cs +++ b/tests/Wolfgang.Etl.TestKit.Xunit.Tests.Unit/TestExtractorContractTests.cs @@ -27,6 +27,10 @@ protected override IReadOnlyList CreateExpectedItems() => protected override TestExtractor CreateSutWithTimer(IProgressTimer timer) => new TestExtractorWithTimer(Enumerable.Range(1, 5).ToList(), timer); + /// + protected override TestExtractor CreateSutOverSource(IEnumerable source) => + new TestExtractor(source); + // Exposes the protected timer constructor of TestExtractor for contract testing.