diff --git a/dotnet/.gitignore b/dotnet/.gitignore new file mode 100644 index 00000000..571b6977 --- /dev/null +++ b/dotnet/.gitignore @@ -0,0 +1,180 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates +*.ide + +# Build results + +[Dd]ebug/ +[Rr]elease/ +x64/ +build/ +[Oo]bj/ +*/**/bin + +# Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets +!packages/*/build/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +*_i.c +*_p.c +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.log +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +*.ncrunch* +.*crunch*.local.xml + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.Publish.xml + +# NuGet Packages Directory +## TODO: If you have NuGet Package Restore enabled, uncomment the next line +#packages/ + +# Windows Azure Build Output +csx +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.[Pp]ublish.xml +*.pfx +*.publishsettings + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +App_Data/*.mdf +App_Data/*.ldf + + +#LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml + +# ========================= +# Windows detritus +# ========================= + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac desktop service store files +.DS_Store + +packages/ +acceptance/ +output/ +.built +.compared +.sln_built_debug +*.userprefs +*.nupkg +Gherkin.NuGetPackages/bin/ +.build* +.built* +.vscode +.run_tests +.generated +.packed +.tested +.fixprotoc +.vs/ + +# ======================== +# Query project specific ignore settings +Query/QueryTest/testdata/ \ No newline at end of file diff --git a/dotnet/Query/Query.sln b/dotnet/Query/Query.sln new file mode 100644 index 00000000..59ccba02 --- /dev/null +++ b/dotnet/Query/Query.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36221.1 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Query", "Query\Query.csproj", "{01EF081E-A17A-4630-9C7D-40BA4BE3F9BC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QueryTest", "QueryTest\QueryTest.csproj", "{F0EA5832-C5B7-42CF-9BDB-6EE21C589C8B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {01EF081E-A17A-4630-9C7D-40BA4BE3F9BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01EF081E-A17A-4630-9C7D-40BA4BE3F9BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01EF081E-A17A-4630-9C7D-40BA4BE3F9BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01EF081E-A17A-4630-9C7D-40BA4BE3F9BC}.Release|Any CPU.Build.0 = Release|Any CPU + {F0EA5832-C5B7-42CF-9BDB-6EE21C589C8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0EA5832-C5B7-42CF-9BDB-6EE21C589C8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0EA5832-C5B7-42CF-9BDB-6EE21C589C8B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0EA5832-C5B7-42CF-9BDB-6EE21C589C8B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {923928DC-6FB0-4C25-9B2B-721A9B60C602} + EndGlobalSection +EndGlobal diff --git a/dotnet/Query/Query/ILineageReducer.cs b/dotnet/Query/Query/ILineageReducer.cs new file mode 100644 index 00000000..39848491 --- /dev/null +++ b/dotnet/Query/Query/ILineageReducer.cs @@ -0,0 +1,25 @@ +using System; +using Io.Cucumber.Messages.Types; + +namespace Io.Cucumber.Query +{ + // port of io.cucumber.query.LineageReducer (Java) + public interface ILineageReducer + { + T Reduce(Lineage lineage); + T Reduce(Lineage lineage, Pickle pickle); + } + + // port of io.cucumber.query.LineageReducer.Collector (Java) + public interface ICollector + { + void Add(GherkinDocument document); + void Add(Feature feature); + void Add(Rule rule); + void Add(Scenario scenario); + void Add(Examples examples, int index); + void Add(TableRow example, int index); + void Add(Pickle pickle); + T Finish(); + } +} diff --git a/dotnet/Query/Query/Lineage.cs b/dotnet/Query/Query/Lineage.cs new file mode 100644 index 00000000..b2dd324b --- /dev/null +++ b/dotnet/Query/Query/Lineage.cs @@ -0,0 +1,92 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using System; +using Io.Cucumber.Messages.Types; + +namespace Io.Cucumber.Query; + +/// +/// A structure containing all ancestors of a given +/// element or . +/// +/// +/// See . +/// +public class Lineage : IEquatable +{ + private readonly GherkinDocument _document; + private readonly Feature? _feature; + private readonly Rule? _rule; + private readonly Scenario? _scenario; + private readonly Examples? _examples; + private readonly int? _examplesIndex; + private readonly TableRow? _example; + private readonly int? _exampleIndex; + + internal Lineage([Required] GherkinDocument document) : this(document, null, null, null, null, null, null, null) { } + + internal Lineage(Lineage parent, Feature feature) + : this(parent._document, feature, null, null, null, null, null, null) { } + + internal Lineage(Lineage parent, Rule rule) + : this(parent._document, parent._feature, rule, null, null, null, null, null) { } + + internal Lineage(Lineage parent, Scenario scenario) + : this(parent._document, parent._feature, parent._rule, scenario, null, null, null, null) { } + + internal Lineage(Lineage parent, Examples examples, int examplesIndex) + : this(parent._document, parent._feature, parent._rule, parent._scenario, examples, examplesIndex, null, null) { } + + internal Lineage(Lineage parent, TableRow example, int exampleIndex) + : this(parent._document, parent._feature, parent._rule, parent._scenario, parent._examples, parent._examplesIndex, example, exampleIndex) { } + + private Lineage( + [Required] GherkinDocument document, + Feature? feature, + Rule? rule, + Scenario? scenario, + Examples? examples, + int? examplesIndex, + TableRow? example, + int? exampleIndex) + { + _document = document ?? throw new ArgumentNullException(nameof(document)); + _feature = feature; + _rule = rule; + _scenario = scenario; + _examples = examples; + _examplesIndex = examplesIndex; + _example = example; + _exampleIndex = exampleIndex; + } + + public GherkinDocument Document => _document; + public Feature? Feature => _feature; + public Rule? Rule => _rule; + public Scenario? Scenario => _scenario; + public Examples? Examples => _examples; + public TableRow? Example => _example; + public int? ExamplesIndex => _examplesIndex; + public int? ExampleIndex => _exampleIndex; + + public bool Equals(Lineage? other) + { + if (ReferenceEquals(this, other)) return true; + if (other is null) return false; + return Equals(_document, other._document) + && Equals(_feature, other._feature) + && Equals(_rule, other._rule) + && Equals(_scenario, other._scenario) + && Equals(_examples, other._examples) + && Equals(_example, other._example) + && Equals(_examplesIndex, other._examplesIndex) + && Equals(_exampleIndex, other._exampleIndex); + } + + public override bool Equals(object? obj) => Equals(obj as Lineage); + + public override int GetHashCode() + { + return (_document, _feature, _rule, _scenario, _examples, _example, _examplesIndex, _exampleIndex).GetHashCode(); + } +} diff --git a/dotnet/Query/Query/LineageReducerAscending.cs b/dotnet/Query/Query/LineageReducerAscending.cs new file mode 100644 index 00000000..b7ad08cf --- /dev/null +++ b/dotnet/Query/Query/LineageReducerAscending.cs @@ -0,0 +1,47 @@ +using System; +using Io.Cucumber.Messages.Types; + +namespace Io.Cucumber.Query +{ + // port of io.cucumber.query.LineageReducerAscending (Java) + public class LineageReducerAscending : ILineageReducer + { + private readonly Func> _collectorSupplier; + + public LineageReducerAscending(Func> collectorSupplier) + { + _collectorSupplier = collectorSupplier ?? throw new ArgumentNullException(nameof(collectorSupplier)); + } + + public T Reduce(Lineage lineage) + { + var collector = _collectorSupplier(); + ReduceAddLineage(collector, lineage); + return collector.Finish(); + } + + public T Reduce(Lineage lineage, Pickle pickle) + { + var collector = _collectorSupplier(); + collector.Add(pickle); + ReduceAddLineage(collector, lineage); + return collector.Finish(); + } + + private static void ReduceAddLineage(ICollector collector, Lineage lineage) + { + if (lineage.Example != null) + collector.Add(lineage.Example, lineage.ExampleIndex ?? 0); + if (lineage.Examples != null) + collector.Add(lineage.Examples, lineage.ExamplesIndex ?? 0); + if (lineage.Scenario != null) + collector.Add(lineage.Scenario); + if (lineage.Rule != null) + collector.Add(lineage.Rule); + if (lineage.Feature != null) + collector.Add(lineage.Feature); + if (lineage.Document != null) + collector.Add(lineage.Document); + } + } +} diff --git a/dotnet/Query/Query/LineageReducerDescending.cs b/dotnet/Query/Query/LineageReducerDescending.cs new file mode 100644 index 00000000..92bf0644 --- /dev/null +++ b/dotnet/Query/Query/LineageReducerDescending.cs @@ -0,0 +1,47 @@ +using System; +using Io.Cucumber.Messages.Types; + +namespace Io.Cucumber.Query +{ + // port of io.cucumber.query.LineageReducerDescending (Java) + public class LineageReducerDescending : ILineageReducer + { + private readonly Func> _collectorSupplier; + + public LineageReducerDescending(Func> collectorSupplier) + { + _collectorSupplier = collectorSupplier ?? throw new ArgumentNullException(nameof(collectorSupplier)); + } + + public T Reduce(Lineage lineage) + { + var collector = _collectorSupplier(); + ReduceAddLineage(collector, lineage); + return collector.Finish(); + } + + public T Reduce(Lineage lineage, Pickle pickle) + { + var collector = _collectorSupplier(); + ReduceAddLineage(collector, lineage); + collector.Add(pickle); + return collector.Finish(); + } + + private static void ReduceAddLineage(ICollector collector, Lineage lineage) + { + if (lineage.Document != null) + collector.Add(lineage.Document); + if (lineage.Feature != null) + collector.Add(lineage.Feature); + if (lineage.Rule != null) + collector.Add(lineage.Rule); + if (lineage.Scenario != null) + collector.Add(lineage.Scenario); + if (lineage.Examples != null) + collector.Add(lineage.Examples, lineage.ExamplesIndex ?? 0); + if (lineage.Example != null) + collector.Add(lineage.Example, lineage.ExampleIndex ?? 0); + } + } +} diff --git a/dotnet/Query/Query/Lineages.cs b/dotnet/Query/Query/Lineages.cs new file mode 100644 index 00000000..37611579 --- /dev/null +++ b/dotnet/Query/Query/Lineages.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using Io.Cucumber.Messages.Types; + +namespace Io.Cucumber.Query +{ + // port of io.cucumber.query.Lineages (Java) + internal static class Lineages + { + /// + /// Create map of a GherkinDocument element to its Lineage in that document. + /// + /// The GherkinDocument to create the lineage of. + /// A map of the document elements to their lineage. + public static Dictionary Of(GherkinDocument document) + { + var elements = new Dictionary(); + var lineage = new Lineage(document); + var uri = document.Uri ?? throw new ArgumentException("document.uri must not be null"); + elements[uri] = lineage; + if (document.Feature != null) + OfFeature(lineage, elements)(document.Feature); + return elements; + } + + private static Action OfFeature(Lineage parent, Dictionary elements) + { + return feature => + { + var lineage = new Lineage(parent, feature); + foreach (var child in feature.Children) + OfFeatureChild(lineage, elements)(child); + }; + } + + private static Action OfFeatureChild(Lineage parent, Dictionary elements) + { + return featureChild => + { + if (featureChild.Scenario != null) + OfScenario(parent, elements)(featureChild.Scenario); + if (featureChild.Rule != null) + OfRule(parent, elements)(featureChild.Rule); + }; + } + + private static Action OfRule(Lineage parent, Dictionary elements) + { + return rule => + { + var lineage = new Lineage(parent, rule); + elements[rule.Id] = lineage; + foreach (var child in rule.Children) + OfRuleChild(lineage, elements)(child); + }; + } + + private static Action OfRuleChild(Lineage parent, Dictionary elements) + { + return ruleChild => + { + if (ruleChild.Scenario != null) + OfScenario(parent, elements)(ruleChild.Scenario); + }; + } + + private static Action OfScenario(Lineage parent, Dictionary elements) + { + return scenario => + { + var lineage = new Lineage(parent, scenario); + elements[scenario.Id] = lineage; + ForEachIndexed(scenario.Examples, OfExamples(lineage, elements)); + }; + } + + private static Action OfExamples(Lineage parent, Dictionary elements) + { + return (examples, examplesIndex) => + { + var lineage = new Lineage(parent, examples, examplesIndex); + elements[examples.Id] = lineage; + ForEachIndexed(examples.TableBody, OfExample(lineage, elements)); + }; + } + + private static Action OfExample(Lineage parent, Dictionary elements) + { + return (example, exampleIndex) => + { + var lineage = new Lineage(parent, example, exampleIndex); + elements[example.Id] = lineage; + }; + } + + private static void ForEachIndexed(IList items, Action consumer) + { + if (items == null) return; + for (int i = 0; i < items.Count; i++) + { + consumer(items[i], i); + } + } + } +} \ No newline at end of file diff --git a/dotnet/Query/Query/NamingCollector.cs b/dotnet/Query/Query/NamingCollector.cs new file mode 100644 index 00000000..c959a7b6 --- /dev/null +++ b/dotnet/Query/Query/NamingCollector.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Io.Cucumber.Messages.Types; + +namespace Io.Cucumber.Query +{ + /// + /// Names GherkinDocument elements or Pickles. + /// + internal class NamingCollector : ICollector + { + // There are at most 5 levels to a feature file. + private readonly List parts = new List(5); + private readonly string delimiter = " - "; + private readonly NamingStrategy.Strategy strategy; + private readonly NamingStrategy.FeatureName featureName; + private readonly NamingStrategy.ExampleName exampleName; + + private string scenarioName; + private bool isExample; + private int examplesIndex; + + public static Func Of(NamingStrategy.Strategy strategy, NamingStrategy.FeatureName featureName, NamingStrategy.ExampleName exampleName) + { + return () => new NamingCollector(strategy, featureName, exampleName); + } + + public NamingCollector(NamingStrategy.Strategy strategy, NamingStrategy.FeatureName featureName, NamingStrategy.ExampleName exampleName) + { + this.strategy = strategy; + this.featureName = featureName; + this.exampleName = exampleName; + } + + public void Add(GherkinDocument document) { } + public void Add(Feature feature) + { + if (featureName == NamingStrategy.FeatureName.INCLUDE || strategy == NamingStrategy.Strategy.SHORT) + { + parts.Add(feature.Name); + } + } + public void Add(Rule rule) + { + parts.Add(rule.Name); + } + public void Add(Scenario scenario) + { + scenarioName = scenario.Name; + parts.Add(scenarioName); + } + public void Add(Examples examples, int index) + { + parts.Add(examples.Name); + this.examplesIndex = index; + } + public void Add(TableRow example, int index) + { + isExample = true; + parts.Add("#" + (examplesIndex + 1) + "." + (index + 1)); + } + public void Add(Pickle pickle) + { + string pickleName = pickle.Name; + + // Case 0: Pickles with an empty lineage + if (scenarioName == null) + { + parts.Add(pickleName); + return; + } + + // Case 1: Pickles from a scenario outline + if (isExample) + { + switch (exampleName) + { + case NamingStrategy.ExampleName.NUMBER: + break; + case NamingStrategy.ExampleName.NUMBER_AND_PICKLE_IF_PARAMETERIZED: + bool parameterized = !scenarioName.Equals(pickleName); + if (parameterized) + { + string exampleNumber = parts[parts.Count - 1]; + parts.RemoveAt(parts.Count - 1); + parts.Add(exampleNumber + ": " + pickleName); + } + break; + case NamingStrategy.ExampleName.PICKLE: + parts.RemoveAt(parts.Count - 1); // Remove example number + parts.Add(pickleName); + break; + } + } + // Case 2: Pickles from a scenario + // Nothing to do, scenario name and pickle name are the same. + } + + public string Finish() + { + if (strategy == NamingStrategy.Strategy.SHORT) + { + return parts.Count > 0 ? parts[parts.Count - 1] : string.Empty; + } + return string.Join(delimiter, parts.Where(s => !string.IsNullOrEmpty(s))); + } + } +} \ No newline at end of file diff --git a/dotnet/Query/Query/NamingStrategy.cs b/dotnet/Query/Query/NamingStrategy.cs new file mode 100644 index 00000000..705547f6 --- /dev/null +++ b/dotnet/Query/Query/NamingStrategy.cs @@ -0,0 +1,94 @@ +using System; +using Io.Cucumber.Messages.Types; + +namespace Io.Cucumber.Query +{ + /// + /// Names Pickles and other elements in a GherkinDocument. + /// + public abstract class NamingStrategy : ILineageReducer + { + public enum Strategy + { + LONG, + SHORT + } + + public enum ExampleName + { + NUMBER, + PICKLE, + NUMBER_AND_PICKLE_IF_PARAMETERIZED + } + + + public enum FeatureName + { + INCLUDE, + EXCLUDE + } + + public static Builder Create(Strategy strategy) + { + return new Builder(strategy); + } + + protected NamingStrategy() { } + + public abstract string Reduce(Lineage lineage); + public abstract string Reduce(Lineage lineage, Pickle pickle); + + public class Builder + { + private readonly Strategy _strategy; + private FeatureName _featureName = NamingStrategy.FeatureName.INCLUDE; + private ExampleName _exampleName = NamingStrategy.ExampleName.NUMBER_AND_PICKLE_IF_PARAMETERIZED; + + public Builder(Strategy strategy) + { + _strategy = strategy; + } + + public Builder ExampleName(ExampleName exampleName) + { + _exampleName = exampleName; + return this; + } + + public Builder FeatureName(FeatureName featureName) + { + _featureName = featureName; + return this; + } + + public NamingStrategy Build() + { + return new Adaptor( + new LineageReducerDescending( + () => new NamingCollector((Strategy)_strategy, (FeatureName)_featureName, (ExampleName)_exampleName) + ) + ); + } + } + + private class Adaptor : NamingStrategy + { + private readonly ILineageReducer _delegate; + + public Adaptor(ILineageReducer del) + { + _delegate = del; + } + + public override string Reduce(Lineage lineage) + { + return _delegate.Reduce(lineage); + } + + public override string Reduce(Lineage lineage, Pickle pickle) + { + return _delegate.Reduce(lineage, pickle); + } + } + } +} diff --git a/dotnet/Query/Query/Query.cs b/dotnet/Query/Query/Query.cs new file mode 100644 index 00000000..c8e96bac --- /dev/null +++ b/dotnet/Query/Query/Query.cs @@ -0,0 +1,451 @@ +#nullable enable +using System; +using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.Linq; +using Io.Cucumber.Messages.Types; +using System.Collections.Concurrent; +using Cucumber.Messages; + +namespace Io.Cucumber.Query; + +// Ported from io.cucumber.query.Query (Java) +public class Query +{ + + private readonly ConcurrentDictionary _testCaseStartedById = new(); + private readonly ConcurrentDictionary _testCaseFinishedByTestCaseStartedId = new(); + private readonly ConcurrentDictionary> _testStepsFinishedByTestCaseStartedId = new(); + private readonly ConcurrentDictionary> _testStepsStartedByTestCaseStartedId = new(); + private readonly ConcurrentDictionary _pickleById = new(); + private readonly ConcurrentDictionary _testCaseById = new(); + private readonly ConcurrentDictionary _stepById = new(); + private readonly ConcurrentDictionary _testStepById = new(); + private readonly ConcurrentDictionary _pickleStepById = new(); + private readonly ConcurrentDictionary _hookById = new(); + private readonly ConcurrentDictionary> _attachmentsByTestCaseStartedId = new(); + private readonly ConcurrentDictionary _lineageById = new(); + private Meta? _meta; + private TestRunStarted? _testRunStarted; + private TestRunFinished? _testRunFinished; + + public Query() { } + + public int MostSevereTestStepResultStatusCount => CountMostSevereTestStepResultStatus().Count; + public int TestCasesStartedCount => FindAllTestCaseStarted().Count; + + public IDictionary CountMostSevereTestStepResultStatus() + { + var statusCounts = new Dictionary(); + // Initialize with zero for each possible TestStepResultStatus + foreach (TestStepResultStatus status in Enum.GetValues(typeof(TestStepResultStatus))) + { + statusCounts[status] = 0; + } + foreach (var testCaseStarted in FindAllTestCaseStarted()) + { + var finishedSteps = FindTestStepsFinishedBy(testCaseStarted); + if (finishedSteps.Count == 0) + continue; + // Find the most severe status (largest enum value) + var mostSevere = finishedSteps + .Select(f => f.TestStepResult.Status) + .Max(); + if (statusCounts.ContainsKey(mostSevere)) + statusCounts[mostSevere]++; + else + statusCounts[mostSevere] = 1; + } + return statusCounts; + } + + public IList FindAllPickles() => _pickleById.Values.OrderBy(p => p.Id).ToList(); + + public IList FindAllPickleSteps() => _pickleStepById.Values.OrderBy(ps => ps.Id).ToList(); + + public IList FindAllTestCaseStarted() => _testCaseStartedById.Values + .OrderBy(tcs => tcs.Timestamp, new TimestampComparer()) + .ThenBy(tcs => tcs.Id) + .Where(tcs => !FindTestCaseFinishedBy(tcs)?.WillBeRetried ?? true) // Exclude finished cases that will be retried + .ToList(); + + public IList FindAllTestSteps() => _testStepById.Values.OrderBy(ts => ts.Id).ToList(); + + public Meta? FindMeta() => _meta; + public TestRunStarted? FindTestRunStarted() => _testRunStarted; + public TestRunFinished? FindTestRunFinished() => _testRunFinished; + + public IList FindAttachmentsBy(TestStepFinished testStepFinished) => + _attachmentsByTestCaseStartedId.TryGetValue(testStepFinished.TestCaseStartedId, out var attachments) + ? attachments.Where(a => a.TestStepId == testStepFinished.TestStepId).ToList() + : new List(); + + public Hook? FindHookBy(TestStep testStep) + { + if (!string.IsNullOrEmpty(testStep.HookId) && _hookById.TryGetValue(testStep.HookId, out var hook)) + { + return hook; + } + return null; + } + + public Pickle? FindPickleBy(TestCaseStarted testCaseStarted) + { + var testCase = FindTestCaseBy(testCaseStarted); + if (testCase != null && _pickleById.TryGetValue(testCase.PickleId, out var pickle)) + { + return pickle; + } + return null; + } + + public Pickle? FindPickleBy(TestStepStarted testStepStarted) + { + var testCaseStarted = FindTestCaseStartedBy(testStepStarted); + if (testCaseStarted != null) + { + return FindPickleBy(testCaseStarted); + } + return null; + } + + public TestCase? FindTestCaseBy(TestCaseStarted testCaseStarted) + { + if (_testCaseById.TryGetValue(testCaseStarted.TestCaseId, out var testCase)) + { + return testCase; + } + return null; + } + + public TestCaseStarted? FindTestCaseStartedBy(TestStepStarted testStepStarted) + { + return _testCaseStartedById.TryGetValue(testStepStarted.TestCaseStartedId, out var tcs) ? tcs : null; + } + + public TestCase? FindTestCaseBy(TestStepStarted testStepStarted) + { + var testCaseStarted = FindTestCaseStartedBy(testStepStarted); + return testCaseStarted != null ? FindTestCaseBy(testCaseStarted) : null; + } + + public TestStep? FindTestStepBy(TestStepStarted testStepStarted) + { + return _testStepById.TryGetValue(testStepStarted.TestStepId, out var testStep) ? testStep : null; + } + + public TestStep? FindTestStepBy(TestStepFinished testStepFinished) + { + return _testStepById.TryGetValue(testStepFinished.TestStepId, out var testStep) ? testStep : null; + } + + public PickleStep? FindPickleStepBy(TestStep testStep) + { + if (!string.IsNullOrEmpty(testStep.PickleStepId)) + { + if (_pickleStepById.TryGetValue(testStep.PickleStepId, out var pickleStep)) + { + return pickleStep; + } + } + return null; + } + + public Step? FindStepBy(PickleStep pickleStep) + { + if (pickleStep.AstNodeIds != null && pickleStep.AstNodeIds.Count > 0) + { + var stepId = pickleStep.AstNodeIds[0]; + if (!string.IsNullOrEmpty(stepId) && _stepById.TryGetValue(stepId, out var step)) + { + return step; + } + } + return null; + } + + public System.TimeSpan? FindTestCaseDurationBy(TestCaseStarted testCaseStarted) + { + var started = testCaseStarted.Timestamp; + var finished = FindTestCaseFinishedBy(testCaseStarted)?.Timestamp; + if (finished != null) + { + var startTime = Converters.ToDateTime(started); + var finishTime = Converters.ToDateTime(finished); + return finishTime - startTime; + } + return null; + } + + public TestCaseFinished? FindTestCaseFinishedBy(TestCaseStarted testCaseStarted) + { + return _testCaseFinishedByTestCaseStartedId.TryGetValue(testCaseStarted.Id, out var finished) ? finished : null; + } + + public System.TimeSpan? FindTestRunDuration() + { + if (_testRunStarted == null || _testRunFinished == null) + return null; + var start = Converters.ToDateTime(_testRunStarted.Timestamp); + var finish = Converters.ToDateTime(_testRunFinished.Timestamp); + return finish - start; + } + + public IList FindTestStepsStartedBy(TestCaseStarted testCaseStarted) + { + if (_testStepsStartedByTestCaseStartedId.TryGetValue(testCaseStarted.Id, out var steps)) + { + return new List(steps); + } + return new List(); + } + + public IList FindTestStepsFinishedBy(TestCaseStarted testCaseStarted) + { + if (_testStepsFinishedByTestCaseStartedId.TryGetValue(testCaseStarted.Id, out var steps)) + { + // Return a copy for concurrency safety + return new List(steps); + } + return new List(); + } + + public IList<(TestStepFinished, TestStep)> FindTestStepFinishedAndTestStepBy(TestCaseStarted testCaseStarted) + { + var finishedSteps = FindTestStepsFinishedBy(testCaseStarted); + var result = new List<(TestStepFinished, TestStep)>(); + foreach (var testStepFinished in finishedSteps) + { + var testStep = FindTestStepBy(testStepFinished); + if (testStep != null) + { + result.Add((testStepFinished, testStep)); + } + } + return result; + } + + public TestStepResult? FindMostSevereTestStepResultBy(TestCaseStarted testCaseStarted) + { + var finishedSteps = FindTestStepsFinishedBy(testCaseStarted); + if (finishedSteps.Count == 0) + return null; + // Find the TestStepFinished with the most severe status (highest enum value) + var mostSevere = finishedSteps + .OrderBy(f => f.TestStepResult.Status) + .LastOrDefault(); + return mostSevere?.TestStepResult; + } + + // Update methods for each message type (ported from Java) + internal void UpdateAttachment(Attachment attachment) + { + if (attachment.TestCaseStartedId != null) + { + _attachmentsByTestCaseStartedId.AddOrUpdate( + attachment.TestCaseStartedId, + _ => new List { attachment }, + (_, list) => { list.Add(attachment); return list; }); + } + } + + internal void UpdateHook(Hook hook) + { + _hookById[hook.Id] = hook; + } + + internal void UpdateTestCaseStarted(TestCaseStarted testCaseStarted) + { + _testCaseStartedById[testCaseStarted.Id] = testCaseStarted; + } + + internal void UpdateTestCase(TestCase testCase) + { + _testCaseById[testCase.Id] = testCase; + foreach (var testStep in testCase.TestSteps) + { + _testStepById[testStep.Id] = testStep; + } + } + + internal void UpdatePickle(Pickle pickle) + { + _pickleById[pickle.Id] = pickle; + foreach (var step in pickle.Steps) + { + _pickleStepById[step.Id] = step; + } + } + + internal void UpdateGherkinDocument(GherkinDocument document) + { + foreach (var lineage in Lineages.Of(document)) + { + _lineageById.TryAdd(lineage.Key, lineage.Value); + } + if (document.Feature != null) + { + UpdateFeature(document.Feature); + } + } + + internal void UpdateFeature(Feature feature) + { + foreach (var child in feature.Children) + { + if (child.Background != null) + { + UpdateSteps(child.Background.Steps); + } + if (child.Scenario != null) + { + UpdateScenario(child.Scenario); + } + if (child.Rule != null) + { + foreach (var ruleChild in child.Rule.Children) + { + if (ruleChild.Background != null) + { + UpdateSteps(ruleChild.Background.Steps); + } + if (ruleChild.Scenario != null) + { + UpdateScenario(ruleChild.Scenario); + } + } + } + } + } + + internal void UpdateTestStepStarted(TestStepStarted testStepStarted) + { + _testStepsStartedByTestCaseStartedId.AddOrUpdate( + testStepStarted.TestCaseStartedId, + _ => new List { testStepStarted }, + (_, list) => { list.Add(testStepStarted); return list; }); + } + + internal void UpdateTestStepFinished(TestStepFinished testStepFinished) + { + _testStepsFinishedByTestCaseStartedId.AddOrUpdate( + testStepFinished.TestCaseStartedId, + _ => new List { testStepFinished }, + (_, list) => { list.Add(testStepFinished); return list; }); + } + + internal void UpdateTestCaseFinished(TestCaseFinished testCaseFinished) + { + _testCaseFinishedByTestCaseStartedId[testCaseFinished.TestCaseStartedId] = testCaseFinished; + } + + internal void UpdateTestRunFinished(TestRunFinished testRunFinished) + { + _testRunFinished = testRunFinished; + } + + internal void UpdateTestRunStarted(TestRunStarted testRunStarted) + { + _testRunStarted = testRunStarted; + } + + internal void UpdateScenario(Scenario scenario) + { + UpdateSteps(scenario.Steps); + } + + internal void UpdateSteps(IList steps) + { + foreach (var step in steps) + { + _stepById[step.Id] = step; + } + } + + internal void UpdateMeta(Meta meta) + { + _meta = meta; + } + + public void Update(Envelope envelope) + { + if (envelope.Meta != null) UpdateMeta(envelope.Meta); + if (envelope.TestRunStarted != null) UpdateTestRunStarted(envelope.TestRunStarted); + if (envelope.TestRunFinished != null) UpdateTestRunFinished(envelope.TestRunFinished); + if (envelope.TestCaseStarted != null) UpdateTestCaseStarted(envelope.TestCaseStarted); + if (envelope.TestCaseFinished != null) UpdateTestCaseFinished(envelope.TestCaseFinished); + if (envelope.TestStepStarted != null) UpdateTestStepStarted(envelope.TestStepStarted); + if (envelope.TestStepFinished != null) UpdateTestStepFinished(envelope.TestStepFinished); + if (envelope.GherkinDocument != null) UpdateGherkinDocument(envelope.GherkinDocument); + if (envelope.Pickle != null) UpdatePickle(envelope.Pickle); + if (envelope.TestCase != null) UpdateTestCase(envelope.TestCase); + if (envelope.Hook != null) UpdateHook(envelope.Hook); + if (envelope.Attachment != null) UpdateAttachment(envelope.Attachment); + } + + // FindLineageBy methods + public Lineage? FindLineageBy(GherkinDocument element) + { + _lineageById.TryGetValue(element, out var lineage); + return lineage; + } + + public Lineage? FindLineageBy(Feature element) + { + _lineageById.TryGetValue(element, out var lineage); + return lineage; + } + + public Lineage? FindLineageBy(Rule element) + { + _lineageById.TryGetValue(element, out var lineage); + return lineage; + } + + public Lineage? FindLineageBy(Scenario element) + { + _lineageById.TryGetValue(element, out var lineage); + return lineage; + } + + public Lineage? FindLineageBy(Examples element) + { + _lineageById.TryGetValue(element, out var lineage); + return lineage; + } + + public Lineage? FindLineageBy(TableRow element) + { + _lineageById.TryGetValue(element, out var lineage); + return lineage; + } + + public Lineage? FindLineageBy(Pickle pickle) + { + var astNodeIds = pickle.AstNodeIds; + var lastAstNodeId = astNodeIds.LastOrDefault(); + _lineageById.TryGetValue(lastAstNodeId, out var lineage); + return lineage; + } + + public Lineage? FindLineageBy(TestCaseStarted testCaseStarted) + { + var pickle = FindPickleBy(testCaseStarted); + if (pickle == null) + { + return null; + } + return FindLineageBy(pickle); + } + public Location? FindLocationOf(Pickle pickle) + { + var lineage = FindLineageBy(pickle); + if (lineage == null) + return null; + if (lineage.Example != null) + return lineage.Example.Location; + if (lineage.Scenario != null) + return lineage.Scenario.Location; + return null; + } +} diff --git a/dotnet/Query/Query/Query.csproj b/dotnet/Query/Query/Query.csproj new file mode 100644 index 00000000..70348462 --- /dev/null +++ b/dotnet/Query/Query/Query.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + latest + + + + + + + + + + + + diff --git a/dotnet/Query/Query/TimestampComparer.cs b/dotnet/Query/Query/TimestampComparer.cs new file mode 100644 index 00000000..566750e1 --- /dev/null +++ b/dotnet/Query/Query/TimestampComparer.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using Io.Cucumber.Messages.Types; + +namespace Io.Cucumber.Query +{ + // Port of io.cucumber.query.TimestampComparator (Java) + internal class TimestampComparer : IComparer + { + public int Compare(Timestamp a, Timestamp b) + { + long sa = a.Seconds; + long sb = b.Seconds; + + if (sa < sb) + return -1; + else if (sb < sa) + return 1; + + long na = a.Nanos; + long nb = b.Nanos; + + if (na < nb) + return -1; + else if (nb < na) + return 1; + + return 0; + } + } +} diff --git a/dotnet/Query/QueryTest/CucumberMessageEnumConverter.cs b/dotnet/Query/QueryTest/CucumberMessageEnumConverter.cs new file mode 100644 index 00000000..566b3764 --- /dev/null +++ b/dotnet/Query/QueryTest/CucumberMessageEnumConverter.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace QueryTest +{ + internal class CucumberMessageEnumConverter : JsonConverter where T : struct, Enum + { + private readonly Dictionary _enumToString = new(); + private readonly Dictionary _stringToEnum = new(); + + protected internal CucumberMessageEnumConverter() + { + var type = typeof(T); + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static)) + { +#pragma warning disable CS8605 // Unboxing a possibly null value. + var value = (T)field.GetValue(null); +#pragma warning restore CS8605 // Unboxing a possibly null value. + var attribute = field.GetCustomAttribute(); + var name = attribute?.Description ?? field.Name; + _enumToString[value] = name; + _stringToEnum[name] = value; + } + } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var stringValue = reader.GetString(); + return _stringToEnum.TryGetValue(stringValue!, out var enumValue) ? enumValue : default; + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(_enumToString.TryGetValue(value, out var stringValue) ? stringValue : value.ToString()); + } + } + +} \ No newline at end of file diff --git a/dotnet/Query/QueryTest/MSTestSettings.cs b/dotnet/Query/QueryTest/MSTestSettings.cs new file mode 100644 index 00000000..aaf278c8 --- /dev/null +++ b/dotnet/Query/QueryTest/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/dotnet/Query/QueryTest/NamingStrategyAcceptanceTest.cs b/dotnet/Query/QueryTest/NamingStrategyAcceptanceTest.cs new file mode 100644 index 00000000..8e64932b --- /dev/null +++ b/dotnet/Query/QueryTest/NamingStrategyAcceptanceTest.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Io.Cucumber.Messages.Types; +using Io.Cucumber.Query; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace QueryTest +{ + [TestClass] + public class NamingStrategyAcceptanceTest + { + private static readonly Dictionary Strategies = new() + { + { "long", NamingStrategy.Create(NamingStrategy.Strategy.LONG).Build() }, + { "long-exclude-feature-name", NamingStrategy.Create(NamingStrategy.Strategy.LONG).FeatureName(NamingStrategy.FeatureName.EXCLUDE).Build() }, + { "long-with-pickle-name", NamingStrategy.Create(NamingStrategy.Strategy.LONG).ExampleName(NamingStrategy.ExampleName.PICKLE).Build() }, + { "long-with-pickle-name-if-parameterized", NamingStrategy.Create(NamingStrategy.Strategy.LONG).ExampleName(NamingStrategy.ExampleName.NUMBER_AND_PICKLE_IF_PARAMETERIZED).Build() }, + { "short", NamingStrategy.Create(NamingStrategy.Strategy.SHORT).Build() } + }; + + public static IEnumerable Acceptance() + { + var sources = new[] + { + Path.Combine("..", "..", "..", "..", "..", "..", "testdata", "minimal.feature.ndjson"), + Path.Combine("..", "..", "..", "..", "..", "..", "testdata", "rules.feature.ndjson"), + Path.Combine("..", "..", "..", "..", "..", "..", "testdata", "examples-tables.feature.ndjson") + }; + + foreach (var source in sources) + { + foreach (var kvp in Strategies) + { + yield return new object[] { new TestCase(source, kvp.Key, kvp.Value) }; + } + } + } + + [TestMethod] + [DynamicData(nameof(Acceptance), DynamicDataSourceType.Method)] + public void Test(TestCase testCase) + { + var actual = WriteResults(testCase, testCase.Strategy); + var expected = File.ReadAllText(testCase.Expected, Encoding.UTF8); + actual.Should().Be(expected, $"NamingStrategy results for {testCase} do not match expected results."); + } + + // Disabled: Only for updating expected files + // [TestMethod] + // [DynamicData(nameof(Acceptance), DynamicDataSourceType.Method)] + // [Ignore] + public void UpdateExpectedQueryResultFiles(TestCase testCase) + { + using var outStream = File.Open(testCase.Expected, FileMode.Create, FileAccess.Write); + WriteResults(testCase.Strategy, testCase, outStream); + } + + private static string WriteResults(TestCase testCase, NamingStrategy strategy) + { + using var outStream = new MemoryStream(); + WriteResults(strategy, testCase, outStream); + return Encoding.UTF8.GetString(outStream.ToArray()); + } + + private static void WriteResults(NamingStrategy strategy, TestCase testCase, Stream outStream) + { + using var inStream = File.OpenRead(testCase.Source); + using var reader = new StreamReader(inStream, Encoding.UTF8); + using var writer = new StreamWriter(outStream, Encoding.UTF8, leaveOpen: true); + var query = new Query(); + + string? line; + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + var envelope = NdjsonSerializer.Deserialize(line); + query.Update(envelope); + } + + foreach (var pickle in query.FindAllPickles()) + { + var lineage = query.FindLineageBy(pickle); + if (lineage != null) + { + var name = strategy.Reduce(lineage, pickle); + if (name != null) + writer.WriteLine(name); + } + } + writer.Flush(); + } + + public class TestCase + { + public string Source { get; } + public NamingStrategy Strategy { get; } + public string Expected { get; } + public string Name { get; } + public string StrategyName { get; } + + public TestCase(string source, string strategyName, NamingStrategy strategy) + { + Source = source; + Strategy = strategy; + StrategyName = strategyName; + var fileName = Path.GetFileName(source); + Name = fileName.Substring(0, fileName.LastIndexOf(".ndjson", StringComparison.Ordinal)); + Expected = Path.Combine(Path.GetDirectoryName(source)!, $"{Name}.naming-strategy.{strategyName}.txt"); + } + + public override string ToString() => $"{Name} -> {StrategyName}"; + } + } +} \ No newline at end of file diff --git a/dotnet/Query/QueryTest/NdjsonSerializer.cs b/dotnet/Query/QueryTest/NdjsonSerializer.cs new file mode 100644 index 00000000..4d49a561 --- /dev/null +++ b/dotnet/Query/QueryTest/NdjsonSerializer.cs @@ -0,0 +1,60 @@ +using Io.Cucumber.Messages.Types; +using System; +using System.IO; +using System.Text.Json; + +namespace QueryTest +{ + /// + /// When using System.Text.Json to serialize a Cucumber Message Envelope, the following serialization options are used. + /// Consumers of Cucumber.Messages should use these options, or their serialization library's equivalent options. + /// These options should work with System.Text.Json v6 or above. + /// + public class NdjsonSerializer + { + private static readonly Lazy _jsonOptions = new(() => + { + var options = new JsonSerializerOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull; + options.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping; + + return options; + }); + + private static JsonSerializerOptions JsonOptions + { + get + { + return _jsonOptions.Value; + } + } + + public static string Serialize(Envelope message) + { + return Serialize(message); + } + + internal static string Serialize(T message) + { + return JsonSerializer.Serialize(message, JsonOptions); + } + + public static Envelope Deserialize(string json) + { + return Deserialize(json); + } + + internal static T Deserialize(string json) + { + return JsonSerializer.Deserialize(json, JsonOptions)!; + } + } +} \ No newline at end of file diff --git a/dotnet/Query/QueryTest/QueryAcceptanceTest.cs b/dotnet/Query/QueryTest/QueryAcceptanceTest.cs new file mode 100644 index 00000000..98dac716 --- /dev/null +++ b/dotnet/Query/QueryTest/QueryAcceptanceTest.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Io.Cucumber.Messages.Types; +using Io.Cucumber.Query; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using FluentAssertions; +using System.Security.Cryptography; + +namespace QueryTest +{ + [TestClass] + public class QueryAcceptanceTest + { + private static readonly string[] TestFiles = new[] + { + "attachments.feature.ndjson", + "empty.feature.ndjson", + "hooks.feature.ndjson", + "minimal.feature.ndjson", + "rules.feature.ndjson", + "examples-tables.feature.ndjson" + }; + + public static IEnumerable Acceptance() + { + foreach (var file in TestFiles) + { + yield return new object[] { new TestCase(file) }; + } + } + + [TestMethod] + [DynamicData(nameof(Acceptance), DynamicDataSourceType.Method)] + public void Test(TestCase testCase) + { + var actual = WriteQueryResults(testCase); + var expected = ReadResourceAsString(testCase.ExpectedResourceName); + + // Compare as JSON for robust diff + var actualJson = JsonNode.Parse(actual); + var expectedJson = JsonNode.Parse(expected); + + actualJson!.ToJsonString().Should().Be(expectedJson!.ToJsonString(), + $"Query results for {testCase.Name} do not match expected results."); + } + + private static string WriteQueryResults(TestCase testCase) + { + using var inStream = ReadResourceAsStream(testCase.SourceResourceName); + using var reader = new StreamReader(inStream, Encoding.UTF8); + var query = new Query(); + + // Read NDJSON lines and update query + string? line; + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + var envelope = NdjsonSerializer.Deserialize(line); + query.Update(envelope); + } + + var queryResults = CreateQueryResults(query); + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new CucumberMessageEnumConverter()); + options.Converters.Add(new TimestampOrderedConverter()); + options.Converters.Add(new TestRunStartedOrderedConverter()); + options.Converters.Add(new TestRunFinishedOrderedConverter()); + + return JsonSerializer.Serialize(queryResults, options); + } + + private static Dictionary CreateQueryResults(Query query) + { + var results = new Dictionary + { + ["countMostSevereTestStepResultStatus"] = query.CountMostSevereTestStepResultStatus() + .ToDictionary( + kvp => kvp.Key.ToString(), + kvp => (object)kvp.Value + ), + ["countTestCasesStarted"] = query.TestCasesStartedCount, + ["findAllPickles"] = query.FindAllPickles().Count, + ["findAllPickleSteps"] = query.FindAllPickleSteps().Count, + ["findAllTestCaseStarted"] = query.FindAllTestCaseStarted().Count, + ["findAllTestSteps"] = query.FindAllTestSteps().Count, + ["findAttachmentsBy"] = query.FindAllTestCaseStarted() + .SelectMany(tcs => query.FindTestStepFinishedAndTestStepBy(tcs)) + .Select(pair => pair.Item1) + .SelectMany(tsf => query.FindAttachmentsBy(tsf)) + .Select(att => new object?[] + { + att.TestStepId, + att.TestCaseStartedId, + att.MediaType, + att.ContentEncoding + }).ToList(), + ["findHookBy"] = query.FindAllTestSteps() + .Select(ts => query.FindHookBy(ts)?.Id) + .Where(id => id != null) + .ToList(), + ["findLocationOf"] = query.FindAllPickles() + .Select(pickle => query.FindLocationOf(pickle)) + .Where(loc => loc != null) + .ToList(), + ["findMeta"] = query.FindMeta()?.Implementation?.Name, + ["findMostSevereTestStepResultBy"] = query.FindAllTestCaseStarted() + .Select(tcs => query.FindMostSevereTestStepResultBy(tcs)?.Status.ToString()) + .ToList(), + ["findPickleBy"] = query.FindAllTestCaseStarted() + .Select(tcs => query.FindPickleBy(tcs)?.Name) + .ToList(), + ["findPickleStepBy"] = query.FindAllTestSteps() + .Select(ts => query.FindPickleStepBy(ts)?.Text) + .Where(text => text != null) + .ToList(), + ["findStepBy"] = query.FindAllPickleSteps() + .Select(ps => query.FindStepBy(ps)?.Text) + .ToList(), + ["findTestCaseBy"] = query.FindAllTestCaseStarted() + .Select(tcs => query.FindTestCaseBy(tcs)?.Id) + .ToList(), + ["findTestCaseDurationBy"] = query.FindAllTestCaseStarted() + .Select(tcs => + { + var duration = query.FindTestCaseDurationBy(tcs); + var ts = ConvertTimeSpanToTimestamp(duration); + return ts; + }) + .ToList(), + ["findTestCaseFinishedBy"] = query.FindAllTestCaseStarted() + .Select(tcs => query.FindTestCaseFinishedBy(tcs)?.TestCaseStartedId) + .ToList(), + ["findTestRunDuration"] = ConvertTimeSpanToTimestamp(query.FindTestRunDuration()), + ["findTestRunFinished"] = query.FindTestRunFinished(), + ["findTestRunStarted"] = query.FindTestRunStarted(), + ["findTestStepByTestStepStarted"] = query.FindAllTestCaseStarted() + .SelectMany(tcs => query.FindTestStepsStartedBy(tcs)) + .Select(tss => query.FindTestStepBy(tss)?.Id) + .ToList(), + ["findTestStepByTestStepFinished"] = query.FindAllTestCaseStarted() + .SelectMany(tcs => query.FindTestStepsFinishedBy(tcs)) + .Select(tsf => query.FindTestStepBy(tsf)?.Id) + .ToList(), + ["findTestStepsFinishedBy"] = query.FindAllTestCaseStarted() + .Select(tcs => query.FindTestStepsFinishedBy(tcs).Select(tsf => tsf.TestStepId).ToList()) + .ToList(), + ["findTestStepFinishedAndTestStepBy"] = query.FindAllTestCaseStarted() + .SelectMany(tcs => query.FindTestStepFinishedAndTestStepBy(tcs)) + .Select(pair => new object?[] { pair.Item1.TestStepId, pair.Item2.Id }) + .ToList(), + }; + // Filter out null values and empty collections + return results + .Where(kvp => + kvp.Value != null && + (!(kvp.Value is IEnumerable enumerable) || enumerable.Cast().Any())) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + private static Timestamp? ConvertTimeSpanToTimestamp(TimeSpan? duration) + { + if (duration == null) return null; + return new Timestamp( + (long)duration.Value.TotalSeconds, + (int)((duration.Value.Ticks % TimeSpan.TicksPerSecond) * 100) + ); + } + + private static Stream ReadResourceAsStream(string resourceName) + { + var assemblyLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + if (assemblyLocation == null) + throw new InvalidOperationException("Assembly location could not be determined."); + + var fullName = Path.Combine(assemblyLocation, "..\\..\\..\\..\\..\\..\\testdata", resourceName); + if (!File.Exists(fullName)) + throw new FileNotFoundException($"Resource {fullName} not found."); + return File.OpenRead(fullName); + } + + private static string ReadResourceAsString(string resourceName) + { + using var stream = ReadResourceAsStream(resourceName); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + public class TestCase + { + public string SourceResourceName { get; } + public string ExpectedResourceName { get; } + public string Name { get; } + + public TestCase(string ndjsonFile) + { + Name = ndjsonFile.Substring(0, ndjsonFile.LastIndexOf(".ndjson", StringComparison.Ordinal)); + SourceResourceName = ndjsonFile; + ExpectedResourceName = $"{Name}.query-results.json"; + } + + public override string ToString() => Name; + } + } +} diff --git a/dotnet/Query/QueryTest/QueryTest.cs b/dotnet/Query/QueryTest/QueryTest.cs new file mode 100644 index 00000000..f5016c1a --- /dev/null +++ b/dotnet/Query/QueryTest/QueryTest.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Io.Cucumber.Query; +using Io.Cucumber.Messages.Types; +using System.Collections.Generic; + +namespace QueryTest +{ + [TestClass] + public class QueryTest + { + private readonly Query query = new Query(); + + [TestMethod] + public void RetainsTimestampOrderForTestCaseStarted() + { + var a = new TestCaseStarted(0L, RandomId(), RandomId(), "main", new Timestamp(1L, 0L)); + var b = new TestCaseStarted(0L, RandomId(), RandomId(), "main", new Timestamp(2L, 0L)); + var c = new TestCaseStarted(0L, RandomId(), RandomId(), "main", new Timestamp(3L, 0L)); + + foreach (var tcs in new[] { a, b, c }) + query.UpdateTestCaseStarted(tcs); + + var result = query.FindAllTestCaseStarted(); + CollectionAssert.AreEqual(new[] { a, b, c }, result.ToArray()); + } + + [TestMethod] + public void IdIsTieOrderTieBreaker() + { + var a = new TestCaseStarted(0L, "2", RandomId(), "main", new Timestamp(1L, 0L)); + var b = new TestCaseStarted(0L, "1", RandomId(), "main", new Timestamp(1L, 0L)); + var c = new TestCaseStarted(0L, "0", RandomId(), "main", new Timestamp(1L, 0L)); + + foreach (var tcs in new[] { a, b, c }) + query.UpdateTestCaseStarted(tcs); + + var result = query.FindAllTestCaseStarted(); + CollectionAssert.AreEqual(new[] { c, b, a }, result.ToArray()); + } + + [TestMethod] + public void OmitsTestCaseStartedIfFinishedAndWillBeRetried() + { + var a = new TestCaseStarted(0L, RandomId(), RandomId(), "main", new Timestamp(0L, 0L)); + var b = new TestCaseFinished(a.Id, new Timestamp(0L, 0L), true); + var c = new TestCaseStarted(0L, RandomId(), RandomId(), "main", new Timestamp(0L, 0L)); + var d = new TestCaseFinished(c.Id, new Timestamp(0L, 0L), false); + + query.UpdateTestCaseStarted(a); + query.UpdateTestCaseStarted(c); + query.UpdateTestCaseFinished(b); + query.UpdateTestCaseFinished(d); + + var result = query.FindAllTestCaseStarted(); + Assert.AreEqual(1, result.Count); + Assert.AreEqual(c, result[0]); + Assert.AreEqual(1, query.TestCasesStartedCount); + } + + private static string RandomId() => Guid.NewGuid().ToString(); + } +} diff --git a/dotnet/Query/QueryTest/QueryTest.csproj b/dotnet/Query/QueryTest/QueryTest.csproj new file mode 100644 index 00000000..77bd71e4 --- /dev/null +++ b/dotnet/Query/QueryTest/QueryTest.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + latest + enable + enable + true + Exe + true + + true + + + + + + + + + + + + + + + + diff --git a/dotnet/Query/QueryTest/TestRunFinishedOrderedConverter.cs b/dotnet/Query/QueryTest/TestRunFinishedOrderedConverter.cs new file mode 100644 index 00000000..2fda4f69 --- /dev/null +++ b/dotnet/Query/QueryTest/TestRunFinishedOrderedConverter.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Io.Cucumber.Messages.Types; + +public class TestRunFinishedOrderedConverter : JsonConverter +{ + public override TestRunFinished? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return JsonSerializer.Deserialize(ref reader, options); + } + + public override void Write(Utf8JsonWriter writer, TestRunFinished value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + // Write properties in the expected order + if (!string.IsNullOrEmpty(value.Message)) + writer.WriteString("message", value.Message); + + writer.WriteBoolean("success", value.Success); + + writer.WritePropertyName("timestamp"); + JsonSerializer.Serialize(writer, value.Timestamp, options); + + if (value.Exception != null) + { + writer.WritePropertyName("exception"); + JsonSerializer.Serialize(writer, value.Exception, options); + } + + if (!string.IsNullOrEmpty(value.TestRunStartedId)) + writer.WriteString("testRunStartedId", value.TestRunStartedId); + + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/dotnet/Query/QueryTest/TestRunStartedOrderedConverter.cs b/dotnet/Query/QueryTest/TestRunStartedOrderedConverter.cs new file mode 100644 index 00000000..6e1965fc --- /dev/null +++ b/dotnet/Query/QueryTest/TestRunStartedOrderedConverter.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Io.Cucumber.Messages.Types; + +public class TestRunStartedOrderedConverter : JsonConverter +{ + public override TestRunStarted? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return JsonSerializer.Deserialize(ref reader, options); + } + + public override void Write(Utf8JsonWriter writer, TestRunStarted value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + // Write properties in the expected order + writer.WritePropertyName("timestamp"); + JsonSerializer.Serialize(writer, value.Timestamp, options); + + if (!string.IsNullOrEmpty(value.Id)) + writer.WriteString("id", value.Id); + + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/dotnet/Query/QueryTest/TimestampComparerTest.cs b/dotnet/Query/QueryTest/TimestampComparerTest.cs new file mode 100644 index 00000000..ce45b7f3 --- /dev/null +++ b/dotnet/Query/QueryTest/TimestampComparerTest.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Io.Cucumber.Query; +using Io.Cucumber.Messages.Types; + +namespace QueryTest +{ + [TestClass] + public class TimestampComparerTest + { + private readonly TimestampComparer comparer = new TimestampComparer(); + + [TestMethod] + public void Identity() + { + var a = new Timestamp(1L, 1L); + var b = new Timestamp(1L, 1L); + + Assert.AreEqual(0, comparer.Compare(a, b)); + Assert.AreEqual(0, comparer.Compare(b, a)); + } + + [TestMethod] + public void OnSeconds() + { + var a = new Timestamp(1L, 1L); + var b = new Timestamp(2L, 2L); + Assert.AreEqual(-1, comparer.Compare(a, b)); + Assert.AreEqual(1, comparer.Compare(b, a)); + } + + [TestMethod] + public void OnNanoSeconds() + { + var a = new Timestamp(1L, 1L); + var b1 = new Timestamp(1L, 0L); + var b2 = new Timestamp(1L, 2L); + + Assert.AreEqual(1, comparer.Compare(a, b1)); + Assert.AreEqual(-1, comparer.Compare(b1, a)); + + Assert.AreEqual(-1, comparer.Compare(a, b2)); + Assert.AreEqual(1, comparer.Compare(b2, a)); + } + } +} diff --git a/dotnet/Query/QueryTest/TimestampOrderedConverter.cs b/dotnet/Query/QueryTest/TimestampOrderedConverter.cs new file mode 100644 index 00000000..80ddbdb2 --- /dev/null +++ b/dotnet/Query/QueryTest/TimestampOrderedConverter.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Io.Cucumber.Messages.Types; + +public class TimestampOrderedConverter : JsonConverter +{ + public override Timestamp? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Use default deserialization + return JsonSerializer.Deserialize(ref reader, options); + } + + public override void Write(Utf8JsonWriter writer, Timestamp value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteNumber("seconds", value.Seconds); + writer.WriteNumber("nanos", value.Nanos); + writer.WriteEndObject(); + } +} \ No newline at end of file