diff --git a/CSF.Screenplay/ReportModel/PerformableReport.cs b/CSF.Screenplay/ReportModel/PerformableReport.cs index c60411c3..6091a65e 100644 --- a/CSF.Screenplay/ReportModel/PerformableReport.cs +++ b/CSF.Screenplay/ReportModel/PerformableReport.cs @@ -35,6 +35,24 @@ public class PerformableReport : ReportableModelBase /// public string PerformancePhase { get; set; } + /// + /// Gets or sets the relative time at which this performable ended/finished. + /// + /// + /// + /// This property is expressed as an amount of time since the Screenplay began. The beginning of the Screenplay is recorded in the + /// report metadata, at . + /// + /// + /// Recall that it is quite normal for performances and thus reportable actions to occur in parallel. + /// Do not be alarmed if it appears that unrelated performances are interleaved with regard to their timings. + /// + /// + /// + /// + /// + public TimeSpan Ended { get; set; } + /// /// Gets or sets a string representation of the result which was emitted by the corresponding performable. /// diff --git a/CSF.Screenplay/ReportModel/PerformanceReport.cs b/CSF.Screenplay/ReportModel/PerformanceReport.cs index 90525c39..203a0614 100644 --- a/CSF.Screenplay/ReportModel/PerformanceReport.cs +++ b/CSF.Screenplay/ReportModel/PerformanceReport.cs @@ -52,5 +52,23 @@ public List Reportables get => reportables; set => reportables = value ?? throw new ArgumentNullException(nameof(value)); } + + /// + /// Gets or sets the time at which this performance was begun. + /// + /// + /// + /// This property is expressed as an amount of time since the Screenplay began. The beginning of the Screenplay is recorded in the + /// report metadata, at . + /// + /// + /// Recall that it is quite normal for performances and thus reportable actions to occur in parallel. + /// Do not be alarmed if it appears that unrelated performances are interleaved with regard to their timings. + /// + /// + /// + /// + /// + public TimeSpan Started { get; set; } } } \ No newline at end of file diff --git a/CSF.Screenplay/ReportModel/ReportMetadata.cs b/CSF.Screenplay/ReportModel/ReportMetadata.cs index f41b7ad2..eebbe0ea 100644 --- a/CSF.Screenplay/ReportModel/ReportMetadata.cs +++ b/CSF.Screenplay/ReportModel/ReportMetadata.cs @@ -10,9 +10,14 @@ public class ReportMetadata const string reportFormatVersion = "2.0.0"; /// - /// Gets or sets the UTC timestamp at which the report was generated. + /// Gets or sets the date & time at which the Screenplay began. /// - public DateTime Timestamp { get; set; } = DateTime.UtcNow; + /// + /// + /// Other time-related values within the Screenplay report are expressed relative to this time. + /// + /// + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; /// /// Gets or sets a version number for the format of report that has been produced. diff --git a/CSF.Screenplay/ReportModel/ReportableModelBase.cs b/CSF.Screenplay/ReportModel/ReportableModelBase.cs index 4526b55e..c1bb234c 100644 --- a/CSF.Screenplay/ReportModel/ReportableModelBase.cs +++ b/CSF.Screenplay/ReportModel/ReportableModelBase.cs @@ -1,3 +1,4 @@ +using System; using System.Text.Json.Serialization; namespace CSF.Screenplay.ReportModel @@ -32,5 +33,28 @@ public abstract class ReportableModelBase /// /// public string ActorName { get; set; } + + /// + /// Gets or sets the relative time at which this reportable event occurred. + /// + /// + /// + /// For many types of reportable items (derived from this type), only the start time is recorded, because it is expected that the + /// activity upon which is being reported takes a trivial amount of time. + /// For instances, an end time is also recorded, as these are expected to take an appreciable amount of time. + /// + /// + /// This property is expressed as an amount of time since the Screenplay began. The beginning of the Screenplay is recorded in the + /// report metadata, at . + /// + /// + /// Recall that it is quite normal for performances and thus reportable actions to occur in parallel. + /// Do not be alarmed if it appears that unrelated performances are interleaved with regard to their timings. + /// + /// + /// + /// + /// + public TimeSpan Started { get; set; } } } \ No newline at end of file diff --git a/CSF.Screenplay/Reporting/IProvidesReportTiming.cs b/CSF.Screenplay/Reporting/IProvidesReportTiming.cs new file mode 100644 index 00000000..e95fcb91 --- /dev/null +++ b/CSF.Screenplay/Reporting/IProvidesReportTiming.cs @@ -0,0 +1,25 @@ +using System; + +namespace CSF.Screenplay.Reporting +{ + +/// +/// An object which acts as a stopwatch, intended for used in providing timing data for reports. +/// +public interface IMeasuresTime : IDisposable +{ + /// + /// Begins the timer, recording/tracking time. + /// + /// The current date & time, at the point when timing began. + /// If this method is used more than once upon the same object instance. + DateTimeOffset BeginTiming(); + + /// + /// Gets the amount of time (wall clock time) which has elapsed since was executed. + /// + /// A timespan which is the time elapsed since timing began. + /// If this method is used before has been executed. + TimeSpan GetCurrentTime(); +} +} diff --git a/CSF.Screenplay/Reporting/JsonScreenplayReporter.cs b/CSF.Screenplay/Reporting/JsonScreenplayReporter.cs index 89bfb953..0580da48 100644 --- a/CSF.Screenplay/Reporting/JsonScreenplayReporter.cs +++ b/CSF.Screenplay/Reporting/JsonScreenplayReporter.cs @@ -41,6 +41,7 @@ namespace CSF.Screenplay.Reporting public sealed class JsonScreenplayReporter : IReporter { readonly ScreenplayReportBuilder builder; + readonly IMeasuresTime reportTimer; readonly Utf8JsonWriter jsonWriter; readonly object syncRoot = new object(); IHasPerformanceEvents subscribed; @@ -134,7 +135,7 @@ void OnPerformanceBegun(object sender, PerformanceEventArgs e) void OnScreenplayStarted(object sender, EventArgs e) { jsonWriter.WriteStartObject(); - var metadata = new ReportMetadata(); + var metadata = new ReportMetadata() { Timestamp = reportTimer.BeginTiming() }; jsonWriter.WritePropertyName(nameof(ScreenplayReport.Metadata)); JsonSerializer.Serialize(jsonWriter, metadata); jsonWriter.WriteStartArray(nameof(ScreenplayReport.Performances)); @@ -171,15 +172,19 @@ static string GetPhaseString(PerformancePhase phase) /// Initializes a new instance of for a specified file path. /// /// The stream to which the JSON report shall be written. - /// The Screenplay report builder + /// A factory for a Screenplay report builder + /// A timing service for reports /// If is . - public JsonScreenplayReporter(Stream writeStream, ScreenplayReportBuilder builder) + public JsonScreenplayReporter(Stream writeStream, Func builderFactory, IMeasuresTime reportTimer) { if (writeStream is null) throw new ArgumentNullException(nameof(writeStream)); + if(builderFactory is null) + throw new ArgumentNullException(nameof(builderFactory)); jsonWriter = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = false }); - this.builder = builder ?? throw new ArgumentNullException(nameof(builder)); + this.reportTimer = reportTimer ?? throw new ArgumentNullException(nameof(reportTimer)); + builder = builderFactory(reportTimer); } } } \ No newline at end of file diff --git a/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs b/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs index 44b9398b..7ebe38d7 100644 --- a/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs +++ b/CSF.Screenplay/Reporting/PerformanceReportBuilder.cs @@ -24,6 +24,7 @@ public class PerformanceReportBuilder readonly IGetsValueFormatter valueFormatterProvider; readonly IFormatsReportFragment formatter; readonly IGetsContentType contentTypeProvider; + readonly IMeasuresTime reportTimer; /// /// Gets a value indicating whether or not this builder has a 'current' performable that it is building. @@ -84,6 +85,7 @@ public void ActorCreated(Actor actor) { ActorName = actor.Name, Report = string.Format(ReportStrings.ActorCreatedFormat, actor.Name), + Started = reportTimer.GetCurrentTime(), }); } @@ -102,6 +104,7 @@ public void ActorGainedAbility(Actor actor, object ability) { ActorName = actor.Name, Report = reportText, + Started = reportTimer.GetCurrentTime(), }); } @@ -120,6 +123,7 @@ public void ActorSpotlit(Actor actor) { ActorName = actor.Name, Report = string.Format(ReportStrings.ActorSpotlitFormat, actor.Name), + Started = reportTimer.GetCurrentTime(), }); } @@ -136,6 +140,7 @@ public void SpotlightTurnedOff() NewPerformableList.Add(new SpotlightTurnedOffReport { Report = ReportStrings.SpotlightTurnedOff, + Started = reportTimer.GetCurrentTime(), }); } @@ -172,6 +177,7 @@ public void BeginPerformable(object performable, Actor actor, string performance PerformableType = performable.GetType().FullName, ActorName = actor.Name, PerformancePhase = performancePhase, + Started = reportTimer.GetCurrentTime(), }; NewPerformableList.Add(performableReport); @@ -235,6 +241,7 @@ public void EndPerformable(object performable, Actor actor) CurrentPerformable.Report = performable is ICanReport reporter ? reporter.GetReportFragment(actor, formatter).FormattedFragment : string.Format(ReportStrings.FallbackReportFormat, actor.Name, performable.GetType().FullName); + CurrentPerformable.Ended = reportTimer.GetCurrentTime(); performableStack.Pop(); } @@ -254,6 +261,7 @@ public void RecordFailureForCurrentPerformable(Exception exception) { CurrentPerformable.Exception = exception.ToString(); CurrentPerformable.ExceptionIsFromConsumedPerformable = exception is PerformableException; + CurrentPerformable.Ended = reportTimer.GetCurrentTime(); performableStack.Pop(); } @@ -266,11 +274,13 @@ public void RecordFailureForCurrentPerformable(Exception exception) /// A value formatter factory /// A report-fragment formatter /// A content type provider service + /// A report timer /// If any parameter is . public PerformanceReportBuilder(List namingHierarchy, IGetsValueFormatter valueFormatterProvider, IFormatsReportFragment formatter, - IGetsContentType contentTypeProvider) + IGetsContentType contentTypeProvider, + IMeasuresTime reportTimer) { if (namingHierarchy is null) throw new ArgumentNullException(nameof(namingHierarchy)); @@ -278,9 +288,12 @@ public PerformanceReportBuilder(List namingHierarchy, this.valueFormatterProvider = valueFormatterProvider ?? throw new ArgumentNullException(nameof(valueFormatterProvider)); this.formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); this.contentTypeProvider = contentTypeProvider ?? throw new ArgumentNullException(nameof(contentTypeProvider)); + this.reportTimer = reportTimer ?? throw new ArgumentNullException(nameof(reportTimer)); + report = new PerformanceReport { NamingHierarchy = namingHierarchy.ToList(), + Started = reportTimer.GetCurrentTime(), }; } } diff --git a/CSF.Screenplay/Reporting/ReportTimer.cs b/CSF.Screenplay/Reporting/ReportTimer.cs new file mode 100644 index 00000000..bcb81529 --- /dev/null +++ b/CSF.Screenplay/Reporting/ReportTimer.cs @@ -0,0 +1,36 @@ +using System; +using System.Diagnostics; + +namespace CSF.Screenplay.Reporting +{ +/// +/// Default implementation of which uses a . +/// +public sealed class ReportTimer : IMeasuresTime +{ + readonly Stopwatch stopwatch = new Stopwatch(); + + /// + public DateTimeOffset BeginTiming() + { + if(stopwatch.IsRunning) throw new InvalidOperationException($"The {nameof(BeginTiming)} method may not be used more than once."); + + stopwatch.Start(); + return DateTimeOffset.Now; + } + + /// + public void Dispose() + { + if(stopwatch.IsRunning) + stopwatch.Stop(); + } + + /// + public TimeSpan GetCurrentTime() + { + if(!stopwatch.IsRunning) throw new InvalidOperationException($"The {nameof(GetCurrentTime)} method may not be used before {nameof(BeginTiming)}."); + return stopwatch.Elapsed; + } +} +} diff --git a/CSF.Screenplay/Reporting/ScreenplayReportBuilder.cs b/CSF.Screenplay/Reporting/ScreenplayReportBuilder.cs index 3bff8710..c19fe23c 100644 --- a/CSF.Screenplay/Reporting/ScreenplayReportBuilder.cs +++ b/CSF.Screenplay/Reporting/ScreenplayReportBuilder.cs @@ -28,7 +28,8 @@ namespace CSF.Screenplay.Reporting public class ScreenplayReportBuilder { readonly ConcurrentDictionary performanceReports = new ConcurrentDictionary(); - readonly Func, PerformanceReportBuilder> performanceBuilderFactory; + readonly Func,IMeasuresTime,PerformanceReportBuilder> performanceBuilderFactory; + readonly IMeasuresTime reportTimer; /// /// Begins building a report about a new performance. @@ -52,7 +53,7 @@ public void BeginPerformance(Guid performanceIdentifier, IReadOnlyList new IdentifierAndNameModel { Identifier = x.Identifier, Name = x.Name, WasIdentifierAutoGenerated = x.WasIdentifierAutoGenerated }) .ToList(); - performanceReports.TryAdd(performanceIdentifier, performanceBuilderFactory(mappedNamingHierarchy)); + performanceReports.TryAdd(performanceIdentifier, performanceBuilderFactory(mappedNamingHierarchy, reportTimer)); } /// @@ -95,10 +96,12 @@ public PerformanceReport EndPerformanceAndGetReport(Guid performanceIdentifier, /// Initialises a new instance of . /// /// A factory function for performance report builders + /// A report timer /// If is . - public ScreenplayReportBuilder(Func,PerformanceReportBuilder> performanceBuilderFactory) + public ScreenplayReportBuilder(Func,IMeasuresTime,PerformanceReportBuilder> performanceBuilderFactory, IMeasuresTime reportTimer) { this.performanceBuilderFactory = performanceBuilderFactory ?? throw new ArgumentNullException(nameof(performanceBuilderFactory)); + this.reportTimer = reportTimer ?? throw new ArgumentNullException(nameof(reportTimer)); } } } \ No newline at end of file diff --git a/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs b/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs index 66b32186..5df4e8b0 100644 --- a/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs +++ b/CSF.Screenplay/ScreenplayServiceCollectionExtensions.cs @@ -72,15 +72,20 @@ public static IServiceCollection AddScreenplay(this IServiceCollection services) .AddTransient() .AddTransient() .AddTransient() - .AddTransient, PerformanceReportBuilder>>(s => + .AddTransient, IMeasuresTime, PerformanceReportBuilder>>(s => { - return idsAndNames => ActivatorUtilities.CreateInstance(s, idsAndNames); + return (idsAndNames, timer) => ActivatorUtilities.CreateInstance(s, idsAndNames, timer); }) .AddTransient() .AddTransient() .AddTransient() .AddTransient() - .AddTransient(); + .AddTransient() + .AddTransient() + .AddTransient>(s => + { + return timer => ActivatorUtilities.CreateInstance(s, timer); + }); return services; } diff --git a/Tests/CSF.Screenplay.Tests/Reporting/ScreenplayReportSerializerTests.cs b/Tests/CSF.Screenplay.Tests/Reporting/ScreenplayReportSerializerTests.cs index a4692291..83101f65 100644 --- a/Tests/CSF.Screenplay.Tests/Reporting/ScreenplayReportSerializerTests.cs +++ b/Tests/CSF.Screenplay.Tests/Reporting/ScreenplayReportSerializerTests.cs @@ -60,7 +60,7 @@ public async Task DeserializeAsyncShouldReturnAScreenplayReport(ScreenplayReport using var scope = Assert.EnterMultipleScope(); Assert.That(result, Is.Not.Null, "The deserialized report should not be null."); - Assert.That(result.Metadata.Timestamp, Is.EqualTo(new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc)), "The timestamp should be correct."); + Assert.That(result.Metadata.Timestamp, Is.EqualTo(new DateTimeOffset(2021, 1, 1, 0, 0, 0, TimeSpan.Zero)), "The timestamp should be correct."); Assert.That(result.Metadata.ReportFormatVersion, Is.EqualTo("2.0.0"), "The report format version should be correct."); Assert.That(result.Performances, Has.Count.EqualTo(1), "There should be one performance."); @@ -94,10 +94,10 @@ public void SerializeAsyncShouldThrowIfReportIsNull(ScreenplayReportSerializer s [Test, AutoMoqData] public async Task SerializeAsyncShouldSerializeToAStream(ScreenplayReportSerializer sut) { - var report = new ScreenplayReport() { Metadata = new () { ReportFormatVersion = "foo bar" } }; + var report = new ScreenplayReport() { Metadata = new () { ReportFormatVersion = "foo bar", Timestamp = DateTimeOffset.UtcNow } }; var stream = await sut.SerializeAsync(report); using var reader = new StreamReader(stream); var content = await reader.ReadToEndAsync(); - Assert.That(content, Does.Match(@"^\{""Metadata"":\{""Timestamp"":""[\d-]{10}T[\d:.]{10,16}Z"",""ReportFormatVersion"":""foo bar""\},""Performances"":\[\]\}$")); + Assert.That(content, Does.Match(@"^\{""Metadata"":\{""Timestamp"":""[\d-]{10}T[\d:.]{10,16}\+00:00"",""ReportFormatVersion"":""foo bar""\},""Performances"":\[\]\}$")); } } \ No newline at end of file