1 /++
2   A module containing the default test discovery logic
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.discovery.unit;
9 
10 import std..string;
11 import std.traits;
12 import std.conv;
13 import std.array;
14 import std.file;
15 import std.algorithm;
16 import std.range;
17 import std.typecons;
18 
19 import trial.interfaces;
20 import trial.discovery.code;
21 
22 static if(__VERSION__ >= 2077) {
23   enum unitTestKey = "__un" ~ "ittest_";
24 } else {
25   enum unitTestKey = "__un" ~ "ittestL";
26 }
27 
28 enum CommentType
29 {
30   none,
31   begin,
32   end,
33   comment
34 }
35 
36 CommentType commentType(T)(T line)
37 {
38   if (line.length < 2)
39   {
40     return CommentType.none;
41   }
42 
43   if (line[0 .. 2] == "//")
44   {
45     return CommentType.comment;
46   }
47 
48   if (line[0 .. 2] == "/+" || line[0 .. 2] == "/*")
49   {
50     return CommentType.begin;
51   }
52 
53   if (line.indexOf("+/") != -1 || line.indexOf("*/") != -1)
54   {
55     return CommentType.end;
56   }
57 
58   return CommentType.none;
59 }
60 
61 @("It should group comments")
62 unittest
63 {
64   string comments = "//line 1
65 	// line 2
66 
67 	//// other line
68 
69 	/** line 3
70 	line 4 ****/
71 
72 	//// other line
73 
74 	/++ line 5
75 	line 6
76 	+++/
77 
78 	/** line 7
79    *
80    * line 8
81    */";
82 
83   auto results = comments.compressComments;
84 
85   results.length.should.equal(6);
86   results[0].value.should.equal("line 1 line 2");
87   results[1].value.should.equal("other line");
88   results[2].value.should.equal("line 3 line 4");
89   results[3].value.should.equal("other line");
90   results[4].value.should.equal("line 5 line 6");
91   results[5].value.should.equal("line 7  line 8");
92 
93   results[0].line.should.equal(2);
94   results[1].line.should.equal(4);
95   results[2].line.should.equal(7);
96   results[3].line.should.equal(9);
97   results[4].line.should.equal(13);
98 }
99 
100 struct Comment
101 {
102   ulong line;
103   string value;
104 
105   string toCode() {
106     return `Comment(` ~ line.to!string ~ `, "` ~ value.replace(`\`, `\\`).replace(`"`, `\"`) ~ `")`;
107   }
108 }
109 
110 Comment[] commentGroupToString(T)(T[] group)
111 {
112   if (group.front[1] == CommentType.comment)
113   {
114     auto slice = group.until!(a => a[1] != CommentType.comment).array;
115 
116     string value = slice.map!(a => a[2].stripLeft('/').array.to!string).map!(a => a.strip)
117       .join(' ').array.to!string;
118 
119     return [Comment(slice[slice.length - 1][0], value)];
120   }
121 
122   if (group.front[1] == CommentType.begin)
123   {
124     auto ch = group.front[2][1];
125     auto index = 0;
126 
127     auto newGroup = group.map!(a => Tuple!(int, CommentType, immutable(char),
128         string)(a[0], a[1], a[2].length > 2 ? a[2][1] : ' ', a[2])).array;
129 
130     foreach (item; newGroup)
131     {
132       index++;
133       if (item[1] == CommentType.end && item[2] == ch)
134       {
135         break;
136       }
137     }
138 
139     auto slice = group.map!(a => Tuple!(int, CommentType, immutable(char), string)(a[0],
140         a[1], a[2].length > 2 ? a[2][1] : ' ', a[2])).take(index);
141 
142     string value = slice.map!(a => a[3].strip).map!(a => a.stripLeft('/')
143         .stripLeft(ch).array.to!string).map!(a => a.strip).join(' ')
144       .until(ch ~ "/").array.stripRight('/').stripRight(ch).strip.to!string;
145 
146     return [Comment(slice[slice.length - 1][0], value)];
147   }
148 
149   return [];
150 }
151 
152 string getComment(const Comment[] comments, const ulong line, const string defaultValue) pure
153 {
154   auto r = comments.filter!(a => (line - a.line) < 3);
155 
156   return r.empty ? defaultValue : r.front.value;
157 }
158 
159 bool connects(T)(T a, T b)
160 {
161   auto items = a[0] < b[0] ? [a, b] : [b, a];
162 
163   if (items[1][0] - items[0][0] != 1)
164   {
165     return false;
166   }
167 
168   if (a[1] == b[1])
169   {
170     return true;
171   }
172 
173   if (items[0][1] != CommentType.end && items[1][1] != CommentType.begin)
174   {
175     return true;
176   }
177 
178   return false;
179 }
180 
181 @("check comment types")
182 unittest
183 {
184   "".commentType.should.equal(CommentType.none);
185   "some".commentType.should.equal(CommentType.none);
186   "//some".commentType.should.equal(CommentType.comment);
187   "/+some".commentType.should.equal(CommentType.begin);
188   "/*some".commentType.should.equal(CommentType.begin);
189   "some+/some".commentType.should.equal(CommentType.end);
190   "some*/some".commentType.should.equal(CommentType.end);
191 }
192 
193 auto compressComments(string code)
194 {
195   Comment[] result;
196 
197   auto lines = code.splitter("\n").map!(a => a.strip).enumerate(1)
198     .map!(a => Tuple!(int, CommentType, string)(a[0], a[1].commentType, a[1])).filter!(
199         a => a[2] != "").array;
200 
201   auto tmp = [lines[0]];
202   auto prev = lines[0];
203 
204   foreach (line; lines[1 .. $])
205   {
206     if (tmp.length == 0 || line.connects(tmp[tmp.length - 1]))
207     {
208       tmp ~= line;
209     }
210     else
211     {
212       result ~= tmp.commentGroupToString;
213       tmp = [line];
214     }
215   }
216 
217   if (tmp.length > 0)
218   {
219     result ~= tmp.commentGroupToString;
220   }
221 
222   return result;
223 }
224 
225 /// Remove comment tokens
226 string clearCommentTokens(string text)
227 {
228   return text.strip('/').strip('+').strip('*').strip;
229 }
230 
231 /// clearCommentTokens should remove comment tokens
232 unittest
233 {
234   clearCommentTokens("// text").should.equal("text");
235   clearCommentTokens("///// text").should.equal("text");
236   clearCommentTokens("/+++ text").should.equal("text");
237   clearCommentTokens("/*** text").should.equal("text");
238   clearCommentTokens("/*** text ***/").should.equal("text");
239   clearCommentTokens("/+++ text +++/").should.equal("text");
240 }
241 
242 size_t extractLine(string name) {
243   static if(__VERSION__ >= 2077) {
244     auto idx = name.indexOf("_d_");
245 
246     if(idx > 0) {
247       idx += 3;
248       auto lastIdx = name.lastIndexOf("_");
249 
250       if(idx != -1 && isNumeric(name[idx .. lastIdx])) {
251         return name[idx .. lastIdx].to!size_t;
252       }
253     }
254   } else {
255     enum len = unitTestKey.length;
256 
257     if(name.length < len) {
258       return 0;
259     }
260 
261     auto postFix = name[len .. $];
262     auto idx = postFix.indexOf("_");
263 
264     if(idx != -1 && isNumeric(postFix[0 .. idx])) {
265       return postFix[0 .. idx].to!size_t;
266     }
267   }
268 
269   auto pieces = name.split("_")
270     .filter!(a => a != "")
271     .map!(a => a[0] == 'L' ? a[1..$] : a)
272     .filter!(a => a.isNumeric)
273     .map!(a => a.to!size_t).array;
274 
275   if(pieces.length > 0) {
276     return pieces[0];
277   }
278 
279   return 0;
280 }
281 
282 /// The default test discovery looks for unit test sections and groups them by module
283 class UnitTestDiscovery : ITestDiscovery
284 {
285   TestCase[string][string] testCases;
286   static Comment[][string] comments;
287 
288   TestCase[] getTestCases()
289   {
290     return testCases.values.map!(a => a.values).joiner.array;
291   }
292 
293   TestCase[] discoverTestCases(string file)
294   {
295     TestCase[] testCases = [];
296 
297     version (Have_fluent_asserts)
298       version (Have_libdparse)
299       {
300         import fluentasserts.core.results;
301 
302         auto tokens = fileToDTokens(file);
303 
304         void noTest()
305         {
306           assert(false, "you can not run this test");
307         }
308 
309         auto iterator = TokenIterator(tokens);
310         auto moduleName = iterator.skipUntilType("module").skipOne.readUntilType(";").strip;
311 
312         string lastName;
313         DLangAttribute[] attributes;
314 
315         foreach (token; iterator)
316         {
317           auto type = str(token.type);
318 
319           if (type == "}")
320           {
321             lastName = "";
322             attributes = [];
323           }
324 
325           if (type == "@")
326           {
327             attributes ~= iterator.readAttribute;
328           }
329 
330           if (type == "comment")
331           {
332             if (lastName != "")
333             {
334               lastName ~= " ";
335             }
336 
337             lastName ~= token.text.clearCommentTokens;
338           }
339 
340           if (type == "version")
341           {
342             iterator.skipUntilType(")");
343           }
344 
345           if (type == "unittest")
346           {
347             auto issues = attributes.filter!(a => a.identifier == "Issue");
348             auto flakynes = attributes.filter!(a => a.identifier == "Flaky");
349             auto stringAttributes = attributes.filter!(a => a.identifier == "");
350 
351             Label[] labels = [];
352 
353             foreach (issue; issues)
354             {
355               labels ~= Label("issue", issue.value);
356             }
357 
358             if (!flakynes.empty)
359             {
360               labels ~= Label("status_details", "flaky");
361             }
362 
363             if (!stringAttributes.empty)
364             {
365               lastName = stringAttributes.front.value.strip;
366             }
367 
368             if (lastName == "")
369             {
370               lastName = "unnamed test at line " ~ token.line.to!string;
371             }
372 
373             auto testCase = TestCase(moduleName, lastName, &noTest, labels);
374 
375             testCase.location = SourceLocation(file, token.line);
376 
377             testCases ~= testCase;
378           }
379         }
380       }
381 
382     return testCases;
383   }
384 
385   void addModule(string file, string moduleName)()
386   {
387     mixin("import " ~ moduleName ~ ";");
388     mixin("discover!(`" ~ file ~ "`, `" ~ moduleName ~ "`, " ~ moduleName ~ ")(0);");
389   }
390 
391   private
392   {
393     string testName(alias test)(ref Comment[] comments)
394     {
395       string defaultName = test.stringof.to!string;
396       string name = defaultName;
397 
398       foreach (attr; __traits(getAttributes, test))
399       {
400         static if (is(typeof(attr) == string))
401         {
402           name = attr;
403         }
404       }
405 
406       enum len = unitTestKey.length;
407       size_t line;
408 
409       try
410       {
411         line = extractLine(name);
412       } catch(Exception) {}
413 
414       if (name == defaultName && name.indexOf(unitTestKey) == 0)
415       {
416         try
417         {
418           if(line != 0) {
419             name = comments.getComment(line, defaultName);
420           }
421         }
422         catch (Exception e)
423         {
424         }
425       }
426 
427       if (name == defaultName || name == "") {
428         name = "unnamed test at line " ~ line.to!string;
429       }
430 
431       return name;
432     }
433 
434     SourceLocation testSourceLocation(alias test)(string fileName)
435     {
436       string name = test.stringof.to!string;
437 
438       enum len = unitTestKey.length;
439       size_t line;
440 
441       try
442       {
443         line = extractLine(name);
444       }
445       catch (Exception e)
446       {
447         return SourceLocation();
448       }
449 
450       return SourceLocation(fileName, line);
451     }
452 
453     Label[] testLabels(alias test)()
454     {
455       Label[] labels;
456 
457       foreach (attr; __traits(getAttributes, test))
458       {
459         static if (__traits(hasMember, attr, "labels"))
460         {
461           labels ~= attr.labels;
462         }
463       }
464 
465       return labels;
466     }
467 
468     void addTestCases(string file, alias moduleName, composite...)()
469         if (composite.length == 1 && isUnitTestContainer!(composite))
470     {
471       static if( !composite[0].stringof.startsWith("package") && std.traits.moduleName!composite != moduleName ) {
472         return;
473       } else {
474         if(file !in comments) {
475           comments[file] = file.readText.compressComments;
476         }
477 
478         foreach (test; __traits(getUnitTests, composite))
479         {
480           auto testCase = TestCase(moduleName, testName!(test)(comments[file]), {
481             test();
482           }, testLabels!(test));
483 
484           testCase.location = testSourceLocation!test(file);
485 
486           testCases[moduleName][test.mangleof] = testCase;
487         }
488       }
489     }
490 
491     void discover(string file, alias moduleName, composite...)(int index)
492         if (composite.length == 1 && isUnitTestContainer!(composite))
493     {
494       if(index > 10) {
495         return;
496       }
497 
498       addTestCases!(file, moduleName, composite);
499 
500       static if (isUnitTestContainer!composite)
501       {
502         foreach (member; __traits(allMembers, composite))
503         {
504           static if(!is( typeof(__traits(getMember, composite, member)) == void)) {
505             static if (__traits(compiles, __traits(getMember, composite, member))
506                 && isSingleField!(__traits(getMember, composite, member)) && isUnitTestContainer!(__traits(getMember,
507                   composite, member)) && !isModule!(__traits(getMember, composite, member)))
508             {
509               if (__traits(getMember, composite, member).mangleof !in testCases)
510               {
511                 discover!(file, moduleName, __traits(getMember, composite, member))(index + 1);
512               }
513             }
514           }
515         }
516       }
517     }
518   }
519 }
520 
521 private template isUnitTestContainer(DECL...) if (DECL.length == 1)
522 {
523   static if (!isAccessible!DECL)
524   {
525     enum isUnitTestContainer = false;
526   }
527   else static if (is(FunctionTypeOf!(DECL[0])))
528   {
529     enum isUnitTestContainer = false;
530   }
531   else static if (is(DECL[0]) && !isAggregateType!(DECL[0]))
532   {
533     enum isUnitTestContainer = false;
534   }
535   else static if (isPackage!(DECL[0]))
536   {
537     enum isUnitTestContainer = true;
538   }
539   else static if (isModule!(DECL[0]))
540   {
541     enum isUnitTestContainer = DECL[0].stringof != "module object";
542   }
543   else static if (!__traits(compiles, fullyQualifiedName!(DECL[0])))
544   {
545     enum isUnitTestContainer = false;
546   }
547   else static if (!is(typeof(__traits(allMembers, DECL[0]))))
548   {
549     enum isUnitTestContainer = false;
550   }
551   else
552   {
553     enum isUnitTestContainer = true;
554   }
555 }
556 
557 private template isModule(DECL...) if (DECL.length == 1)
558 {
559   static if (is(DECL[0]))
560     enum isModule = false;
561   else static if (is(typeof(DECL[0])) && !is(typeof(DECL[0]) == void))
562     enum isModule = false;
563   else static if (!is(typeof(DECL[0].stringof)))
564     enum isModule = false;
565   else static if (is(FunctionTypeOf!(DECL[0])))
566     enum isModule = false;
567   else
568     enum isModule = DECL[0].stringof.startsWith("module ");
569 }
570 
571 private template isPackage(DECL...) if (DECL.length == 1)
572 {
573   static if (is(DECL[0]))
574     enum isPackage = false;
575   else static if (is(typeof(DECL[0])) && !is(typeof(DECL[0]) == void))
576     enum isPackage = false;
577   else static if (!is(typeof(DECL[0].stringof)))
578     enum isPackage = false;
579   else static if (is(FunctionTypeOf!(DECL[0])))
580     enum isPackage = false;
581   else
582     enum isPackage = DECL[0].stringof.startsWith("package ");
583 }
584 
585 private template isAccessible(DECL...) if (DECL.length == 1)
586 {
587   enum isAccessible = __traits(compiles, testTempl!(DECL[0])());
588 }
589 
590 private template isSingleField(DECL...)
591 {
592   enum isSingleField = DECL.length == 1;
593 }
594 
595 private void testTempl(X...)() if (X.length == 1)
596 {
597   static if (is(X[0]))
598   {
599     auto x = X[0].init;
600   }
601   else
602   {
603     auto x = X[0].stringof;
604   }
605 }
606 
607 /// This adds asserts to the module
608 version (unittest)
609 {
610   version(Have_fluent_asserts) {
611     import fluent.asserts;
612   }
613 }
614 
615 /// It should extract the line from the default test name
616 unittest {
617   extractLine("__unittest_runTestsOnDevices_133_0()").should.equal(133);
618 }
619 
620 /// It should extract the line from the default test name with _d_ in symbol name
621 unittest {
622   extractLine("__unittest_runTestsOnDevices_d_133_0()").should.equal(133);
623 }
624 
625 @("It should extract the line from the default test name with _L in symbol name")
626 unittest {
627   extractLine("__unittest_L607_C1()").should.equal(607);
628 }
629 
630 /// It should find this test
631 unittest
632 {
633   auto testDiscovery = new UnitTestDiscovery;
634 
635   testDiscovery.addModule!(__FILE__, "trial.discovery.unit");
636 
637   testDiscovery.testCases.keys.should.contain("trial.discovery.unit");
638 
639   testDiscovery.testCases["trial.discovery.unit"].values.map!"a.name".should.contain(
640       "It should find this test");
641 }
642 
643 /// It should find this flaky test
644 @Flaky unittest
645 {
646   auto testDiscovery = new UnitTestDiscovery;
647 
648   testDiscovery.addModule!(__FILE__, "trial.discovery.unit");
649 
650   testDiscovery.testCases.keys.should.contain("trial.discovery.unit");
651 
652   auto r = testDiscovery.testCases["trial.discovery.unit"].values.filter!(
653       a => a.name == "It should find this flaky test");
654 
655   r.empty.should.equal(false).because("a flaky test is in this module");
656   r.front.labels.map!(a => a.name).should.equal(["status_details"]);
657   r.front.labels[0].value.should.equal("flaky");
658 }
659 
660 /// It should find the line of this test
661 unittest
662 {
663   enum line = __LINE__ - 2;
664   auto testDiscovery = new UnitTestDiscovery;
665 
666   testDiscovery.addModule!(__FILE__, "trial.discovery.unit");
667 
668   testDiscovery.testCases.keys.should.contain("trial.discovery.unit");
669 
670   auto r = testDiscovery.testCases["trial.discovery.unit"].values.filter!(
671       a => a.name == "It should find the line of this test");
672 
673   r.empty.should.equal(false).because("the location should be present");
674   r.front.location.fileName.should.endWith("unit.d");
675   r.front.location.line.should.equal(line);
676 }
677 
678 /// It should find this test with issues attributes
679 @Issue("1") @Issue("2")
680 unittest
681 {
682   auto testDiscovery = new UnitTestDiscovery;
683 
684   testDiscovery.addModule!(__FILE__, "trial.discovery.unit");
685   testDiscovery.testCases.keys.should.contain("trial.discovery.unit");
686 
687   auto r = testDiscovery.testCases["trial.discovery.unit"].values.filter!(
688       a => a.name == "It should find this test with issues attributes");
689 
690   r.empty.should.equal(false).because("an issue test is in this module");
691   r.front.labels.map!(a => a.name).should.equal(["issue", "issue"]);
692   r.front.labels.map!(a => a.value).should.equal(["1", "2"]);
693 }
694 
695 /// The discoverTestCases should find the test with issues attributes
696 unittest
697 {
698   immutable line = __LINE__ - 2;
699   auto testDiscovery = new UnitTestDiscovery;
700 
701   auto tests = testDiscovery.discoverTestCases(__FILE__);
702   tests.length.should.be.greaterThan(0);
703 
704   auto testFilter = tests.filter!(a => a.name == "It should find this test with issues attributes");
705   testFilter.empty.should.equal(false);
706 
707   auto theTest = testFilter.front;
708 
709   theTest.labels.map!(a => a.name).should.equal(["issue", "issue"]);
710   theTest.labels.map!(a => a.value).should.equal(["1", "2"]);
711 }
712 
713 /// The discoverTestCases should find the test with the flaky attribute
714 unittest
715 {
716   immutable line = __LINE__ - 2;
717   auto testDiscovery = new UnitTestDiscovery;
718 
719   auto tests = testDiscovery.discoverTestCases(__FILE__);
720   tests.length.should.be.greaterThan(0);
721 
722   auto testFilter = tests.filter!(a => a.name == "It should find this flaky test");
723   testFilter.empty.should.equal(false);
724 
725   auto theTest = testFilter.front;
726 
727   theTest.labels.map!(a => a.name).should.equal(["status_details"]);
728   theTest.labels.map!(a => a.value).should.equal(["flaky"]);
729 }
730 
731 @("", "The discoverTestCases should find the test with the string attribute name")
732 unittest
733 {
734   immutable line = __LINE__ - 2;
735   auto testDiscovery = new UnitTestDiscovery;
736 
737   auto tests = testDiscovery.discoverTestCases(__FILE__);
738   tests.length.should.be.greaterThan(0);
739 
740   auto testFilter = tests.filter!(
741       a => a.name == "The discoverTestCases should find the test with the string attribute name");
742   testFilter.empty.should.equal(false);
743 
744   testFilter.front.labels.length.should.equal(0);
745 }
746 
747 /// The discoverTestCases
748 /// should find this test
749 unittest
750 {
751   immutable line = __LINE__ - 2;
752   auto testDiscovery = new UnitTestDiscovery;
753 
754   auto tests = testDiscovery.discoverTestCases(__FILE__);
755 
756   tests.length.should.be.greaterThan(0);
757 
758   auto testFilter = tests.filter!(a => a.name == "The discoverTestCases should find this test");
759   testFilter.empty.should.equal(false);
760 
761   auto thisTest = testFilter.front;
762 
763   thisTest.suiteName.should.equal("trial.discovery.unit");
764   thisTest.location.fileName.should.equal(__FILE__);
765   thisTest.location.line.should.equal(line);
766 }
767 
768 /// discoverTestCases should ignore version(unittest)
769 unittest
770 {
771   auto testDiscovery = new UnitTestDiscovery;
772 
773   auto tests = testDiscovery.discoverTestCases(__FILE__);
774   tests.length.should.be.greaterThan(0);
775 
776   auto testFilter = tests.filter!(a => a.name == "This adds asserts to the module");
777   testFilter.empty.should.equal(true);
778 }
779 
780 unittest
781 {
782   /// discoverTestCases should set the default test names
783   immutable line = __LINE__ - 3;
784   auto testDiscovery = new UnitTestDiscovery;
785 
786   testDiscovery.discoverTestCases(__FILE__).map!(a => a.name)
787       .array.should.contain("unnamed test at line 780");
788 }
789 
790 /// discoverTestCases should find the same tests like testCases
791 unittest
792 {
793   auto testDiscovery = new UnitTestDiscovery;
794 
795   testDiscovery.addModule!(__FILE__, "trial.discovery.unit");
796 
797   auto allTests = testDiscovery
798     .getTestCases
799     .sort!((a, b) => a.location.line < b.location.line)
800     .array;
801 
802   testDiscovery
803     .discoverTestCases(__FILE__).map!(a => a.toString).join("\n")
804     .should.equal(allTests.map!(a => a.toString).join("\n"));
805 }