1 /++
2   A module containing the StatsReporter
3 
4   Copyright: © 2017 Szabo Bogdan
5   License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
6   Authors: Szabo Bogdan
7 +/
8 module trial.reporters.stats;
9 
10 import std.algorithm;
11 import std..string;
12 import std.conv;
13 import std.exception;
14 import std.array;
15 import std.datetime;
16 import std.stdio;
17 import std.file;
18 import std.path;
19 
20 import trial.runner;
21 import trial.interfaces;
22 
23 ///
24 struct Stat
25 {
26   ///
27   string name;
28   ///
29   SysTime begin;
30   ///
31   SysTime end;
32   ///
33   TestResult.Status status = TestResult.Status.unknown;
34   ///
35   SourceLocation location;
36 }
37 
38 ///
39 class StatStorage
40 {
41   ///
42   Stat[] values;
43 }
44 
45 Stat find(StatStorage storage, const(string) name)
46 {
47   auto res = storage.values.filter!(a => a.name == name);
48 
49   if (res.empty)
50   {
51     return Stat("", SysTime.min, SysTime.min);
52   }
53 
54   return res.front;
55 }
56 
57 /// The stats reporter creates a csv file with the duration and the result of all your steps and tests.
58 /// It's usefull to use it with other reporters, like spec progress.
59 class StatsReporter : ILifecycleListener, ITestCaseLifecycleListener,
60   ISuiteLifecycleListener, IStepLifecycleListener
61 {
62   private
63   {
64     immutable string destination;
65     StatStorage storage;
66     string[][string] path;
67   }
68 
69   this(StatStorage storage, string destination)
70   {
71     this.storage = storage;
72     this.destination = destination;
73   }
74 
75   this(string destination)
76   {
77     this(new StatStorage, destination);
78   }
79 
80   private
81   {
82     auto lastItem(string key)
83     {
84       enforce(path[key].length > 0, "There is no defined path");
85       return path[key][path.length - 1];
86     }
87   }
88 
89   void update()
90   {
91   }
92 
93   void begin(ref SuiteResult suite)
94   {
95   }
96 
97   void end(ref SuiteResult suite)
98   {
99     storage.values ~= Stat(suite.name, suite.begin, Clock.currTime);
100   }
101 
102   void begin(string suite, ref TestResult test)
103   {
104   }
105 
106   void end(string suite, ref TestResult test)
107   {
108     storage.values ~= Stat(suite ~ "." ~ test.name, test.begin, Clock.currTime, test.status, SourceLocation(test.fileName, test.line));
109   }
110 
111   void begin(string suite, string test, ref StepResult step)
112   {
113     string key = suite ~ "." ~ test;
114     path[key] ~= step.name;
115   }
116 
117   void end(string suite, string test, ref StepResult step)
118   {
119     string key = suite ~ "." ~ test;
120 
121     enforce(lastItem(key) == step.name, "Invalid step name");
122     storage.values ~= Stat(key ~ "." ~ path[key].join('.'), step.begin, Clock.currTime);
123     path[key] = path[key][0 .. $ - 1];
124   }
125 
126   void begin(ulong)
127   {
128   }
129 
130   void end(SuiteResult[])
131   {
132     auto parent = buildPath(pathSplitter(destination).array[0..$-1]);
133 
134     if(parent != "" && !parent.exists) {
135       mkdirRecurse(parent);
136     }
137 
138     std.file.write(destination, storage.toCsv);
139 
140     auto attachment = const Attachment("stats", destination, "text/csv");
141 
142     if(LifeCycleListeners.instance !is null) {
143       LifeCycleListeners.instance.attach(attachment);
144     }
145   }
146 }
147 
148 version (unittest)
149 {
150   version(Have_fluent_asserts) {
151     import fluent.asserts;
152     import std.datetime;
153     import std.stdio;
154   }
155 }
156 
157 /// It should write the stats to the expected path
158 unittest {
159   scope(exit) {
160     if(exists("destination.csv")) {
161       std.file.remove("destination.csv");
162     }
163   }
164 
165   auto stats = new StatsReporter("destination.csv");
166   stats.end([]);
167 
168   "destination.csv".exists.should.equal(true);
169 }
170 
171 @("it should add suite to the storage")
172 unittest
173 {
174   auto storage = new StatStorage;
175   auto stats = new StatsReporter(storage, "trial-stats.csv");
176 
177   SuiteResult suite = SuiteResult("suite1");
178 
179   stats.begin(suite);
180   stats.end(suite);
181 
182   storage.values.length.should.equal(1);
183 
184   suite.name = "suite2";
185   stats.begin(suite);
186   stats.end(suite);
187 
188   storage.values.length.should.equal(2);
189   storage.values.map!(a => a.name).array.should.equal(["suite1", "suite2"]);
190   storage.values.map!(a => a.status)
191     .array.should.equal([TestResult.Status.unknown, TestResult.Status.unknown]);
192   storage.values.map!(a => a.begin).array.should.equal([suite.begin, suite.begin]);
193   storage.values.map!(a => a.end > a.begin).array.should.equal([true, true]);
194 }
195 
196 @("it should add tests to the storage")
197 unittest
198 {
199   auto storage = new StatStorage;
200   auto stats = new StatsReporter(storage, "trial-stats.csv");
201 
202   SuiteResult suite = SuiteResult("suite");
203 
204   auto test = new TestResult("test1");
205   test.fileName = "file1.d";
206   test.line = 11;
207   test.status = TestResult.Status.success;
208 
209   stats.begin(suite);
210   stats.begin("suite", test);
211   stats.end("suite", test);
212 
213   storage.values.length.should.equal(1);
214 
215   test.name = "test2";
216   test.status = TestResult.Status.failure;
217   test.fileName = "file2.d";
218   test.line = 22;
219 
220   stats.begin("suite", test);
221   stats.end("suite", test);
222 
223   storage.values.length.should.equal(2);
224   storage.values.map!(a => a.name).array.should.equal(["suite.test1", "suite.test2"]);
225   storage.values.map!(a => a.status).array.should.equal([TestResult.Status.success, TestResult.Status.failure]);
226   storage.values.map!(a => a.begin).array.should.equal([test.begin, test.begin]);
227   storage.values.map!(a => a.end > a.begin).array.should.equal([true, true]);
228   storage.values.map!(a => a.location.fileName).array.should.equal(["file1.d", "file2.d"]);
229   storage.values.map!(a => a.location.line).array.should.equal([11, 22].to!(size_t[]));
230 }
231 
232 @("it should add steps to the storage")
233 unittest
234 {
235   auto storage = new StatStorage;
236   auto stats = new StatsReporter(storage, "trial-stats.csv");
237 
238   SuiteResult suite = SuiteResult("suite");
239 
240   auto test = new TestResult("test");
241   auto step = new StepResult;
242   step.name = "step1";
243   step.begin = Clock.currTime;
244 
245   stats.begin(suite);
246   stats.begin("suite", test);
247   stats.begin("suite", "test", step);
248   stats.end("suite", "test", step);
249 
250   storage.values.length.should.equal(1);
251 
252   step.name = "step2";
253   stats.begin("suite", "test", step);
254   stats.end("suite", "test", step);
255 
256   storage.values.length.should.equal(2);
257   storage.values.map!(a => a.name).array.should.equal(["suite.test.step1", "suite.test.step2"]);
258   storage.values.map!(a => a.status)
259     .array.should.equal([TestResult.Status.unknown, TestResult.Status.unknown]);
260   storage.values.map!(a => a.begin).array.should.equal([step.begin, step.begin]);
261   storage.values.map!(a => a.end > a.begin).array.should.equal([true, true]);
262 }
263 
264 string toCsv(const(StatStorage) storage)
265 {
266   return storage.values.map!(a => [a.name, a.begin.toISOExtString,
267       a.end.toISOExtString, a.status.to!string, a.location.fileName, a.location.line.to!string]).map!(a => a.join(',')).join('\n');
268 }
269 
270 @("it should convert stat storage to csv")
271 unittest
272 {
273   auto stats = new StatStorage;
274   stats.values = [Stat("1", SysTime.min, SysTime.max), Stat("2", SysTime.min, SysTime.max, TestResult.Status.success, SourceLocation("file.d", 2))];
275 
276   stats.toCsv.should.equal("1,-29227-04-19T21:11:54.5224192Z,+29228-09-14T02:48:05.4775807Z,unknown,,0\n"
277       ~ "2,-29227-04-19T21:11:54.5224192Z,+29228-09-14T02:48:05.4775807Z,success,file.d,2");
278 }
279 
280 StatStorage toStatStorage(const(string) data)
281 {
282   auto stat = new StatStorage;
283 
284   stat.values = data
285     .split('\n')
286     .map!(a => a.split(','))
287     .filter!(a => a.length == 6)
288     .map!(a =>
289       Stat(a[0],
290       SysTime.fromISOExtString(a[1]),
291       SysTime.fromISOExtString(a[2]),
292       a[3].to!(TestResult.Status),
293       SourceLocation(a[4], a[5].to!size_t)))
294     .array;
295 
296   return stat;
297 }
298 
299 @("it should create stat storage from csv")
300 unittest
301 {
302   auto storage = ("1,-29227-04-19T21:11:54.5224192Z,+29228-09-14T02:48:05.4775807Z,success,,0\n"
303       ~ "2,-29227-04-19T21:11:54.5224192Z,+29228-09-14T02:48:05.4775807Z,unknown,file.d,12").toStatStorage;
304 
305   storage.values.length.should.equal(2);
306   storage.values[0].name.should.equal("1");
307   storage.values[0].begin.should.equal(SysTime.min);
308   storage.values[0].end.should.equal(SysTime.max);
309   storage.values[0].status.should.equal(TestResult.Status.success);
310   storage.values[0].location.fileName.should.equal("");
311   storage.values[0].location.line.should.equal(0);
312 
313   storage.values[1].name.should.equal("2");
314   storage.values[1].begin.should.equal(SysTime.min);
315   storage.values[1].end.should.equal(SysTime.max);
316   storage.values[1].status.should.equal(TestResult.Status.unknown);
317   storage.values[1].location.fileName.should.equal("file.d");
318   storage.values[1].location.line.should.equal(12);
319 }
320 
321 StatStorage statsFromFile(string fileName)
322 {
323   if (!fileName.exists)
324   {
325     return new StatStorage();
326   }
327 
328   return fileName.readText.toStatStorage;
329 }