diff --git a/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs b/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs index b5a8e0f9..9e49f2f4 100644 --- a/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs +++ b/src/Wolfgang.Etl.Abstractions/ExtractorBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; @@ -22,6 +23,43 @@ public abstract class ExtractorBase { private int _currentItemCount; private int _currentSkippedItemCount; + private long _startTimestamp; + private DateTimeOffset _startedAtUtc; + + + + /// + /// The UTC time at which the first item was processed (extracted or skipped), or + /// null if extraction has not produced any items yet. Captured automatically + /// the first time or + /// is called, so derived classes can + /// surface it on their progress report (see ). + /// + protected DateTimeOffset? StartedAt => + Volatile.Read(ref _startTimestamp) == 0 ? null : _startedAtUtc; + + + + /// + /// The monotonic wall-clock time elapsed since the first item was processed, or + /// if extraction has not produced any items yet. + /// Read this when building a progress report (see ) to + /// snapshot how long extraction has been running. + /// + protected TimeSpan Elapsed + { + get + { + var start = Volatile.Read(ref _startTimestamp); + if (start == 0) + { + return TimeSpan.Zero; + } + + var ticks = Stopwatch.GetTimestamp() - start; + return TimeSpan.FromSeconds(ticks / (double)Stopwatch.Frequency); + } + } @@ -304,6 +342,7 @@ private void ReportProgress(object? state) Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")] protected void IncrementCurrentItemCount() { + EnsureStarted(); _ = Interlocked.Increment(ref _currentItemCount); } @@ -320,6 +359,27 @@ protected void IncrementCurrentItemCount() Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")] protected void IncrementCurrentSkippedItemCount() { + EnsureStarted(); _ = Interlocked.Increment(ref _currentSkippedItemCount); } + + + + // Captures the start timestamp (monotonic) and wall-clock StartedAt the first + // time any item is processed. Idempotent and thread-safe: the first caller to + // win the CompareExchange records the start; later calls are a cheap volatile read. + private void EnsureStarted() + { + if (Volatile.Read(ref _startTimestamp) != 0) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var timestamp = Stopwatch.GetTimestamp(); + if (Interlocked.CompareExchange(ref _startTimestamp, timestamp, 0) == 0) + { + _startedAtUtc = now; + } + } } diff --git a/src/Wolfgang.Etl.Abstractions/IsExternalInit.cs b/src/Wolfgang.Etl.Abstractions/IsExternalInit.cs new file mode 100644 index 00000000..64874912 --- /dev/null +++ b/src/Wolfgang.Etl.Abstractions/IsExternalInit.cs @@ -0,0 +1,19 @@ +#if !NET5_0_OR_GREATER + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace System.Runtime.CompilerServices; + +/// +/// Polyfill for the compiler-required IsExternalInit marker type, which enables +/// -only property setters on target frameworks (.NET Framework, +/// .NET Standard 2.0) whose reference assemblies do not ship it. Built-in on .NET 5.0+. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +[ExcludeFromCodeCoverage] +internal static class IsExternalInit +{ +} + +#endif diff --git a/src/Wolfgang.Etl.Abstractions/LoaderBase.cs b/src/Wolfgang.Etl.Abstractions/LoaderBase.cs index 23a61df3..bdd568ae 100644 --- a/src/Wolfgang.Etl.Abstractions/LoaderBase.cs +++ b/src/Wolfgang.Etl.Abstractions/LoaderBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -22,6 +23,43 @@ public abstract class LoaderBase { private int _currentItemCount; private int _currentSkippedItemCount; + private long _startTimestamp; + private DateTimeOffset _startedAtUtc; + + + + /// + /// The UTC time at which the first item was processed (loaded or skipped), or + /// null if loading has not produced any items yet. Captured automatically + /// the first time or + /// is called, so derived classes can + /// surface it on their progress report (see ). + /// + protected DateTimeOffset? StartedAt => + Volatile.Read(ref _startTimestamp) == 0 ? null : _startedAtUtc; + + + + /// + /// The monotonic wall-clock time elapsed since the first item was processed, or + /// if loading has not produced any items yet. + /// Read this when building a progress report (see ) to + /// snapshot how long loading has been running. + /// + protected TimeSpan Elapsed + { + get + { + var start = Volatile.Read(ref _startTimestamp); + if (start == 0) + { + return TimeSpan.Zero; + } + + var ticks = Stopwatch.GetTimestamp() - start; + return TimeSpan.FromSeconds(ticks / (double)Stopwatch.Frequency); + } + } @@ -307,6 +345,7 @@ private void ReportProgress(object? state) Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")] protected void IncrementCurrentItemCount() { + EnsureStarted(); _ = Interlocked.Increment(ref _currentItemCount); } @@ -323,6 +362,27 @@ protected void IncrementCurrentItemCount() Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")] protected void IncrementCurrentSkippedItemCount() { + EnsureStarted(); _ = Interlocked.Increment(ref _currentSkippedItemCount); } + + + + // Captures the start timestamp (monotonic) and wall-clock StartedAt the first + // time any item is processed. Idempotent and thread-safe: the first caller to + // win the CompareExchange records the start; later calls are a cheap volatile read. + private void EnsureStarted() + { + if (Volatile.Read(ref _startTimestamp) != 0) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var timestamp = Stopwatch.GetTimestamp(); + if (Interlocked.CompareExchange(ref _startTimestamp, timestamp, 0) == 0) + { + _startedAtUtc = now; + } + } } diff --git a/src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt b/src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt index 3a8084b3..dba653a8 100644 --- a/src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt +++ b/src/Wolfgang.Etl.Abstractions/PublicAPI.Shipped.txt @@ -2,6 +2,7 @@ Wolfgang.Etl.Abstractions.ExtractorBase Wolfgang.Etl.Abstractions.ExtractorBase.CurrentItemCount.get -> int Wolfgang.Etl.Abstractions.ExtractorBase.CurrentSkippedItemCount.get -> int +Wolfgang.Etl.Abstractions.ExtractorBase.Elapsed.get -> System.TimeSpan Wolfgang.Etl.Abstractions.ExtractorBase.ExtractorBase() -> void Wolfgang.Etl.Abstractions.ExtractorBase.IncrementCurrentItemCount() -> void Wolfgang.Etl.Abstractions.ExtractorBase.IncrementCurrentSkippedItemCount() -> void @@ -11,6 +12,7 @@ Wolfgang.Etl.Abstractions.ExtractorBase.ReportingInterval.ge Wolfgang.Etl.Abstractions.ExtractorBase.ReportingInterval.set -> void Wolfgang.Etl.Abstractions.ExtractorBase.SkipItemCount.get -> int Wolfgang.Etl.Abstractions.ExtractorBase.SkipItemCount.set -> void +Wolfgang.Etl.Abstractions.ExtractorBase.StartedAt.get -> System.DateTimeOffset? Wolfgang.Etl.Abstractions.IExtractAsync Wolfgang.Etl.Abstractions.IExtractAsync.ExtractAsync() -> System.Collections.Generic.IAsyncEnumerable! Wolfgang.Etl.Abstractions.IExtractStage @@ -71,6 +73,7 @@ Wolfgang.Etl.Abstractions.ITransformWithProgressAsync Wolfgang.Etl.Abstractions.LoaderBase.CurrentItemCount.get -> int Wolfgang.Etl.Abstractions.LoaderBase.CurrentSkippedItemCount.get -> int +Wolfgang.Etl.Abstractions.LoaderBase.Elapsed.get -> System.TimeSpan Wolfgang.Etl.Abstractions.LoaderBase.IncrementCurrentItemCount() -> void Wolfgang.Etl.Abstractions.LoaderBase.IncrementCurrentSkippedItemCount() -> void Wolfgang.Etl.Abstractions.LoaderBase.LoaderBase() -> void @@ -80,13 +83,24 @@ Wolfgang.Etl.Abstractions.LoaderBase.ReportingInterval. Wolfgang.Etl.Abstractions.LoaderBase.ReportingInterval.set -> void Wolfgang.Etl.Abstractions.LoaderBase.SkipItemCount.get -> int Wolfgang.Etl.Abstractions.LoaderBase.SkipItemCount.set -> void +Wolfgang.Etl.Abstractions.LoaderBase.StartedAt.get -> System.DateTimeOffset? Wolfgang.Etl.Abstractions.Pipeline Wolfgang.Etl.Abstractions.Report Wolfgang.Etl.Abstractions.Report.CurrentItemCount.get -> int +Wolfgang.Etl.Abstractions.Report.Elapsed.get -> System.TimeSpan +Wolfgang.Etl.Abstractions.Report.Elapsed.init -> void +Wolfgang.Etl.Abstractions.Report.EstimatedRemaining.get -> System.TimeSpan? +Wolfgang.Etl.Abstractions.Report.ItemsPerSecond.get -> double +Wolfgang.Etl.Abstractions.Report.PercentComplete.get -> double? Wolfgang.Etl.Abstractions.Report.Report(int currentItemCount) -> void +Wolfgang.Etl.Abstractions.Report.StartedAt.get -> System.DateTimeOffset? +Wolfgang.Etl.Abstractions.Report.StartedAt.init -> void +Wolfgang.Etl.Abstractions.Report.TotalItemCount.get -> int? +Wolfgang.Etl.Abstractions.Report.TotalItemCount.init -> void Wolfgang.Etl.Abstractions.TransformerBase Wolfgang.Etl.Abstractions.TransformerBase.CurrentItemCount.get -> int Wolfgang.Etl.Abstractions.TransformerBase.CurrentSkippedItemCount.get -> int +Wolfgang.Etl.Abstractions.TransformerBase.Elapsed.get -> System.TimeSpan Wolfgang.Etl.Abstractions.TransformerBase.IncrementCurrentItemCount() -> void Wolfgang.Etl.Abstractions.TransformerBase.IncrementCurrentSkippedItemCount() -> void Wolfgang.Etl.Abstractions.TransformerBase.MaximumItemCount.get -> int @@ -95,6 +109,7 @@ Wolfgang.Etl.Abstractions.TransformerBase.Repo Wolfgang.Etl.Abstractions.TransformerBase.ReportingInterval.set -> void Wolfgang.Etl.Abstractions.TransformerBase.SkipItemCount.get -> int Wolfgang.Etl.Abstractions.TransformerBase.SkipItemCount.set -> void +Wolfgang.Etl.Abstractions.TransformerBase.StartedAt.get -> System.DateTimeOffset? Wolfgang.Etl.Abstractions.TransformerBase.TransformerBase() -> void abstract Wolfgang.Etl.Abstractions.ExtractorBase.CreateProgressReport() -> TProgress abstract Wolfgang.Etl.Abstractions.ExtractorBase.ExtractWorkerAsync(System.Threading.CancellationToken token) -> System.Collections.Generic.IAsyncEnumerable! diff --git a/src/Wolfgang.Etl.Abstractions/Report.cs b/src/Wolfgang.Etl.Abstractions/Report.cs index 5cc78fbb..b46334c5 100644 --- a/src/Wolfgang.Etl.Abstractions/Report.cs +++ b/src/Wolfgang.Etl.Abstractions/Report.cs @@ -3,11 +3,23 @@ namespace Wolfgang.Etl.Abstractions; /// -/// Provides a report of the current item count in an ETL process. +/// Provides a point-in-time snapshot of the progress of an ETL process — how many +/// items have been processed, how long it has been running, and (when the total is +/// known) how far along it is and how much longer it is expected to take. /// /// +/// /// This class can be used as a base class for other progress reports and expanded -/// with additional information such as total count, count remaining, etc. +/// with additional information specific to a particular extractor, transformer, or loader. +/// +/// +/// All values are snapshot values captured at the moment the report is +/// constructed. does not advance after construction, mirroring +/// . The throughput and completion estimates +/// (, , +/// ) are derived from those snapshot values, so a +/// given report is internally consistent. +/// /// public record Report { @@ -32,4 +44,107 @@ public Report(int currentItemCount) /// The number of items that have been processed so far in the ETL process. /// public int CurrentItemCount { get; } + + + + /// + /// The wall-clock time (UTC) at which processing started, or null if it + /// has not started yet (no items processed) or the producer does not track it. + /// + public DateTimeOffset? StartedAt { get; init; } + + + + /// + /// The wall-clock time that had elapsed since processing started, captured at the + /// moment this report was constructed. when timing is + /// not tracked or processing has not started. + /// + public TimeSpan Elapsed { get; init; } + + + + /// + /// The total number of items expected to be processed, when known (for example a + /// file line count or a SQL COUNT(*)). null for unknown-size or + /// infinite sources. Must be greater than or equal to 0 when set. + /// + /// The specified value is less than 0. + public int? TotalItemCount + { + get; + init + { + if (value is < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "Total item count cannot be less than 0."); + } + + field = value; + } + } + + + + /// + /// The processing throughput, in items per second, derived from + /// and . Returns 0 until at + /// least some time has elapsed. + /// + public double ItemsPerSecond => + Elapsed.TotalSeconds > 0 + ? CurrentItemCount / Elapsed.TotalSeconds + : 0d; + + + + /// + /// The fraction of work completed, as a percentage in the range [0, 100], when + /// is known; otherwise null. Clamped to 100 + /// if exceeds . + /// + public double? PercentComplete + { + get + { + if (TotalItemCount is not { } total) + { + return null; + } + + return total == 0 + ? 100d + : Math.Min(100d, 100d * CurrentItemCount / total); + } + } + + + + /// + /// The estimated time remaining until completion, when both + /// is known and throughput can be measured; + /// otherwise null. Returns once the current + /// count has reached the total. + /// + public TimeSpan? EstimatedRemaining + { + get + { + if (TotalItemCount is not { } total) + { + return null; + } + + var remaining = total - CurrentItemCount; + if (remaining <= 0) + { + return TimeSpan.Zero; + } + + var rate = ItemsPerSecond; + return rate > 0 + ? TimeSpan.FromSeconds(remaining / rate) + : (TimeSpan?)null; + } + } } diff --git a/src/Wolfgang.Etl.Abstractions/TransformerBase.cs b/src/Wolfgang.Etl.Abstractions/TransformerBase.cs index fa7826dd..53d5afb2 100644 --- a/src/Wolfgang.Etl.Abstractions/TransformerBase.cs +++ b/src/Wolfgang.Etl.Abstractions/TransformerBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; @@ -24,6 +25,43 @@ public abstract class TransformerBase { private int _currentItemCount; private int _currentSkippedItemCount; + private long _startTimestamp; + private DateTimeOffset _startedAtUtc; + + + + /// + /// The UTC time at which the first item was processed (transformed or skipped), or + /// null if transformation has not produced any items yet. Captured automatically + /// the first time or + /// is called, so derived classes can + /// surface it on their progress report (see ). + /// + protected DateTimeOffset? StartedAt => + Volatile.Read(ref _startTimestamp) == 0 ? null : _startedAtUtc; + + + + /// + /// The monotonic wall-clock time elapsed since the first item was processed, or + /// if transformation has not produced any items yet. + /// Read this when building a progress report (see ) to + /// snapshot how long transformation has been running. + /// + protected TimeSpan Elapsed + { + get + { + var start = Volatile.Read(ref _startTimestamp); + if (start == 0) + { + return TimeSpan.Zero; + } + + var ticks = Stopwatch.GetTimestamp() - start; + return TimeSpan.FromSeconds(ticks / (double)Stopwatch.Frequency); + } + } @@ -311,6 +349,7 @@ private void ReportProgress(object? state) Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")] protected void IncrementCurrentItemCount() { + EnsureStarted(); _ = Interlocked.Increment(ref _currentItemCount); } @@ -327,6 +366,27 @@ protected void IncrementCurrentItemCount() Justification = "Interlocked.Increment return value intentionally discarded; only the side-effect matters.")] protected void IncrementCurrentSkippedItemCount() { + EnsureStarted(); _ = Interlocked.Increment(ref _currentSkippedItemCount); } + + + + // Captures the start timestamp (monotonic) and wall-clock StartedAt the first + // time any item is processed. Idempotent and thread-safe: the first caller to + // win the CompareExchange records the start; later calls are a cheap volatile read. + private void EnsureStarted() + { + if (Volatile.Read(ref _startTimestamp) != 0) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var timestamp = Stopwatch.GetTimestamp(); + if (Interlocked.CompareExchange(ref _startTimestamp, timestamp, 0) == 0) + { + _startedAtUtc = now; + } + } } diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/BaseClassTimingTests.cs b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/BaseClassTimingTests.cs new file mode 100644 index 00000000..7a93228e --- /dev/null +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/BaseClassTimingTests.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Wolfgang.Etl.Abstractions.Tests.Unit.Models; + +namespace Wolfgang.Etl.Abstractions.Tests.Unit.BaseClassTests; + +/// +/// Verifies the timing instrumentation that +/// (and its loader/transformer siblings) capture automatically on the first processed item. +/// +public class BaseClassTimingTests +{ + // Minimal extractor that exposes the protected StartedAt / Elapsed timing + // signals so the base-class instrumentation can be asserted directly. + private sealed class TimedExtractor : ExtractorBase + { + private readonly int _count; + + + + public TimedExtractor(int count) + { + _count = count; + } + + + + public System.DateTimeOffset? StartedAtForTest => StartedAt; + + public System.TimeSpan ElapsedForTest => Elapsed; + + + + protected override async IAsyncEnumerable ExtractWorkerAsync + ( + [EnumeratorCancellation] CancellationToken token + ) + { + for (var i = 0; i < _count; i++) + { + IncrementCurrentItemCount(); + yield return i; + } + + await Task.CompletedTask; + } + + + + protected override EtlProgress CreateProgressReport() + { + return new EtlProgress(CurrentItemCount); + } + } + + + + [Fact] + public void StartedAt_is_null_before_any_item_is_processed() + { + var sut = new TimedExtractor(3); + + Assert.Null(sut.StartedAtForTest); + Assert.Equal(System.TimeSpan.Zero, sut.ElapsedForTest); + } + + + + [Fact] + public async Task StartedAt_is_captured_once_extraction_has_produced_items() + { + var sut = new TimedExtractor(5); + + await foreach (var _ in sut.ExtractAsync()) + { + } + + Assert.NotNull(sut.StartedAtForTest); + Assert.True(sut.ElapsedForTest >= System.TimeSpan.Zero); + } + + + + private static async IAsyncEnumerable Range(int count) + { + for (var i = 0; i < count; i++) + { + yield return i; + } + + await Task.CompletedTask; + } + + + private sealed class TimedLoader : LoaderBase + { + public System.DateTimeOffset? StartedAtForTest => StartedAt; + + public System.TimeSpan ElapsedForTest => Elapsed; + + protected override async Task LoadWorkerAsync(IAsyncEnumerable items, CancellationToken token) + { + await foreach (var _ in items.WithCancellation(token)) + { + IncrementCurrentItemCount(); + } + } + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + } + + + private sealed class TimedTransformer : TransformerBase + { + public System.DateTimeOffset? StartedAtForTest => StartedAt; + + public System.TimeSpan ElapsedForTest => Elapsed; + + protected override async IAsyncEnumerable TransformWorkerAsync(IAsyncEnumerable items, [EnumeratorCancellation] CancellationToken token) + { + await foreach (var item in items.WithCancellation(token)) + { + IncrementCurrentItemCount(); + yield return item; + } + } + + protected override EtlProgress CreateProgressReport() => new(CurrentItemCount); + } + + + [Fact] + public void LoaderBase_StartedAt_is_null_before_any_item_is_processed() + { + var sut = new TimedLoader(); + + Assert.Null(sut.StartedAtForTest); + Assert.Equal(System.TimeSpan.Zero, sut.ElapsedForTest); + } + + + [Fact] + public async Task LoaderBase_StartedAt_is_captured_once_loading_has_processed_items() + { + var sut = new TimedLoader(); + + await sut.LoadAsync(Range(5)); + + Assert.NotNull(sut.StartedAtForTest); + Assert.True(sut.ElapsedForTest >= System.TimeSpan.Zero); + } + + + [Fact] + public void TransformerBase_StartedAt_is_null_before_any_item_is_processed() + { + var sut = new TimedTransformer(); + + Assert.Null(sut.StartedAtForTest); + Assert.Equal(System.TimeSpan.Zero, sut.ElapsedForTest); + } + + + [Fact] + public async Task TransformerBase_StartedAt_is_captured_once_transformation_has_processed_items() + { + var sut = new TimedTransformer(); + + await foreach (var _ in sut.TransformAsync(Range(5))) + { + } + + Assert.NotNull(sut.StartedAtForTest); + Assert.True(sut.ElapsedForTest >= System.TimeSpan.Zero); + } +} diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/ReportTests.cs b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/ReportTests.cs index ca73f6d2..ffb2609b 100644 --- a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/ReportTests.cs +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/ReportTests.cs @@ -63,4 +63,140 @@ public void Two_Report_instances_with_different_values_are_not_equal() var b = new Report(10); Assert.NotEqual(a, b); } + + + + [Fact] + public void New_report_has_default_timing_and_throughput_values() + { + var sut = new Report(10); + + Assert.Null(sut.StartedAt); + Assert.Equal(TimeSpan.Zero, sut.Elapsed); + Assert.Null(sut.TotalItemCount); + Assert.Equal(0d, sut.ItemsPerSecond); + Assert.Null(sut.PercentComplete); + Assert.Null(sut.EstimatedRemaining); + } + + + + [Fact] + public void TotalItemCount_when_set_to_a_negative_value_throws_ArgumentOutOfRangeException() + { + Assert.Throws(() => new Report(0) { TotalItemCount = -1 }); + } + + + + [Fact] + public void ItemsPerSecond_when_time_has_elapsed_is_count_divided_by_seconds() + { + var sut = new Report(10) { Elapsed = TimeSpan.FromSeconds(2) }; + + Assert.Equal(5d, sut.ItemsPerSecond); + } + + + + [Fact] + public void ItemsPerSecond_when_no_time_has_elapsed_is_zero() + { + var sut = new Report(10) { Elapsed = TimeSpan.Zero }; + + Assert.Equal(0d, sut.ItemsPerSecond); + } + + + + [Fact] + public void PercentComplete_when_total_is_known_is_the_fraction_done() + { + var sut = new Report(50) { TotalItemCount = 200 }; + + Assert.Equal(25d, sut.PercentComplete); + } + + + + [Fact] + public void PercentComplete_when_count_exceeds_total_is_clamped_to_100() + { + var sut = new Report(150) { TotalItemCount = 100 }; + + Assert.Equal(100d, sut.PercentComplete); + } + + + + [Fact] + public void PercentComplete_when_total_is_zero_is_100() + { + var sut = new Report(0) { TotalItemCount = 0 }; + + Assert.Equal(100d, sut.PercentComplete); + } + + + + [Fact] + public void PercentComplete_when_total_is_unknown_is_null() + { + var sut = new Report(50); + + Assert.Null(sut.PercentComplete); + } + + + + [Fact] + public void EstimatedRemaining_when_total_and_throughput_are_known_projects_remaining_time() + { + // 50 of 100 done in 10s => 5 items/s => 50 remaining => 10s left. + var sut = new Report(50) { TotalItemCount = 100, Elapsed = TimeSpan.FromSeconds(10) }; + + Assert.Equal(TimeSpan.FromSeconds(10), sut.EstimatedRemaining); + } + + + + [Fact] + public void EstimatedRemaining_when_count_has_reached_total_is_zero() + { + var sut = new Report(100) { TotalItemCount = 100, Elapsed = TimeSpan.FromSeconds(10) }; + + Assert.Equal(TimeSpan.Zero, sut.EstimatedRemaining); + } + + + + [Fact] + public void EstimatedRemaining_when_total_is_unknown_is_null() + { + var sut = new Report(50) { Elapsed = TimeSpan.FromSeconds(10) }; + + Assert.Null(sut.EstimatedRemaining); + } + + + + [Fact] + public void EstimatedRemaining_when_throughput_is_zero_is_null() + { + var sut = new Report(50) { TotalItemCount = 100, Elapsed = TimeSpan.Zero }; + + Assert.Null(sut.EstimatedRemaining); + } + + + + [Fact] + public void StartedAt_and_Elapsed_round_trip_through_init() + { + var started = DateTimeOffset.UtcNow; + var sut = new Report(1) { StartedAt = started, Elapsed = TimeSpan.FromSeconds(3) }; + + Assert.Equal(started, sut.StartedAt); + Assert.Equal(TimeSpan.FromSeconds(3), sut.Elapsed); + } }