diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7f53fa..a399288 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,7 +46,9 @@ public API only — no breaking change.
- Built against `Wolfgang.Etl.Abstractions` 0.17.0 → **0.18.0**.
- The doubles' `CreateProgressReport()` now surfaces the base's `StartedAt`/`Elapsed` timing
- (Abstractions 0.14.0) in the `Report`, so `ItemsPerSecond` is computed for reported progress.
+ (Abstractions 0.14.0) in the `Report`, so `ItemsPerSecond` is computed for reported progress;
+ the extractor doubles also set `TotalItemCount` from a materialized collection source so
+ `PercentComplete`/`EstimatedRemaining` compute.
## [0.10.1] - 2026-07-24
diff --git a/src/Wolfgang.Etl.TestKit/FaultyExtractor.cs b/src/Wolfgang.Etl.TestKit/FaultyExtractor.cs
index e759082..b07fc55 100644
--- a/src/Wolfgang.Etl.TestKit/FaultyExtractor.cs
+++ b/src/Wolfgang.Etl.TestKit/FaultyExtractor.cs
@@ -353,7 +353,15 @@ protected override void Dispose(bool disposing)
///
protected override Report CreateProgressReport() =>
- new(CurrentItemCount) { StartedAt = StartedAt, Elapsed = Elapsed };
+ new(CurrentItemCount)
+ {
+ StartedAt = StartedAt,
+ Elapsed = Elapsed,
+
+ // When the source is a materialized collection its size is a cheap, known
+ // total, so PercentComplete / EstimatedRemaining can be computed.
+ TotalItemCount = (_items as ICollection)?.Count,
+ };
diff --git a/src/Wolfgang.Etl.TestKit/TestExtractor.cs b/src/Wolfgang.Etl.TestKit/TestExtractor.cs
index 1bc1b54..d36a166 100644
--- a/src/Wolfgang.Etl.TestKit/TestExtractor.cs
+++ b/src/Wolfgang.Etl.TestKit/TestExtractor.cs
@@ -515,7 +515,16 @@ protected override void Dispose(bool disposing)
///
protected override Report CreateProgressReport() =>
- new(CurrentItemCount) { StartedAt = StartedAt, Elapsed = Elapsed };
+ new(CurrentItemCount)
+ {
+ StartedAt = StartedAt,
+ Elapsed = Elapsed,
+
+ // When the source is a materialized collection its size is a cheap, known
+ // total, so PercentComplete / EstimatedRemaining can be computed. An
+ // enumerator- or factory-backed source has no known total (stays null).
+ TotalItemCount = (_enumerable as ICollection)?.Count,
+ };
diff --git a/tests/Wolfgang.Etl.TestKit.Tests.Unit/DoubleReportTimingTests.cs b/tests/Wolfgang.Etl.TestKit.Tests.Unit/DoubleReportTimingTests.cs
index 8a129c8..1e783d7 100644
--- a/tests/Wolfgang.Etl.TestKit.Tests.Unit/DoubleReportTimingTests.cs
+++ b/tests/Wolfgang.Etl.TestKit.Tests.Unit/DoubleReportTimingTests.cs
@@ -50,6 +50,28 @@ public async Task CreateProgressReport_after_a_run_computes_a_non_negative_throu
Assert.True(report.ItemsPerSecond >= 0);
}
+ [Fact]
+ public async Task CreateProgressReport_for_a_collection_source_surfaces_TotalItemCount()
+ {
+ var sut = new ExposedTestExtractor(new List { 1, 2, 3, 4, 5 });
+
+ await sut.ExtractAsync().ToListAsync();
+ var report = sut.GetProgressReport();
+
+ Assert.Equal(5, report.TotalItemCount);
+ Assert.Equal(100d, report.PercentComplete!.Value);
+ }
+
+ [Fact]
+ public void CreateProgressReport_for_an_enumerator_source_has_no_TotalItemCount()
+ {
+ var sut = new ExposedTestExtractor(Enumerable.Range(1, 3).GetEnumerator());
+
+ var report = sut.GetProgressReport();
+
+ Assert.Null(report.TotalItemCount);
+ }
+
private sealed class ExposedTestExtractor : TestExtractor
where T : notnull
{
@@ -58,6 +80,11 @@ public ExposedTestExtractor(IEnumerable items)
{
}
+ public ExposedTestExtractor(IEnumerator enumerator)
+ : base(enumerator)
+ {
+ }
+
public Report GetProgressReport() => CreateProgressReport();
}
}