1 module unit_threaded.property;
2 
3 public import unit_threaded.should;
4 
5 import unit_threaded.randomized.gen;
6 import unit_threaded.randomized.random;
7 import std.random: Random, unpredictableSeed;
8 import std.traits: isIntegral, isArray;
9 
10 version(unittest) import unit_threaded.asserts;
11 
12 Random gRandom;
13 
14 
15 static this() {
16     gRandom = Random(unpredictableSeed);
17 }
18 
19 
20 class PropertyException : Exception
21 {
22     this(in string msg, string file = __FILE__,
23          size_t line = __LINE__, Throwable next = null) @safe pure nothrow
24     {
25         super(msg, file, line, next);
26     }
27 }
28 
29 void check(alias F)(int numFuncCalls = 100,
30                     in string file = __FILE__, in size_t line = __LINE__) @trusted {
31     import std.traits;
32     import std.conv;
33     import std.typecons;
34     import std.array;
35 
36     static assert(is(ReturnType!F == bool),
37                   text("check only accepts functions that return bool, not ", ReturnType!F.stringof));
38 
39     auto gen = RndValueGen!(Parameters!F)(&gRandom);
40 
41     import unit_threaded.io;
42     foreach(i; 0 .. numFuncCalls) {
43         bool pass;
44 
45         try {
46             gen.genValues;
47         } catch(Throwable t) {
48             throw new PropertyException("Error generating values\n" ~ t.toString, file, line, t);
49         }
50 
51         try {
52             pass = F(gen.values);
53         } catch(Throwable t) {
54             throw new PropertyException("Error calling property function\n" ~ t.toString, file, line, t);
55         }
56 
57         if(!pass) {
58             string[] input;
59 
60             static if(Parameters!F.length == 1 && canShrink!(Parameters!F[0])) {
61                 input ~= gen.values[0].value.shrink!F.to!string;
62                 static if(isSomeString!(Parameters!F[0]))
63                     input[$-1] = `"` ~ input[$-1] ~ `"`;
64             } else
65                 foreach(j, ref valueGen; gen.values) {
66                     input ~= valueGen.to!string;
67                 }
68 
69             throw new UnitTestException(["Property failed with input:", input.join(", ")], file, line);
70         }
71     }
72 }
73 
74 private auto shrinkOne(alias F, int index, T)(T values) {
75     import std.stdio;
76     import std.traits;
77     auto orig = values[index];
78     return shrink!((a) {
79         values[index] = a;
80         return F(values.expand);
81     })(orig);
82 
83 }
84 
85 @("Verify identity property for int[] succeeds")
86 @safe unittest {
87     int numCalls;
88     bool identity(int[] a) pure {
89         ++numCalls;
90         return a == a;
91     }
92 
93     check!identity;
94     numCalls.shouldEqual(100);
95 
96     numCalls = 0;
97     check!identity(10);
98     numCalls.shouldEqual(10);
99 }
100 
101 @("Verify anti-identity property for int[] fails")
102 @safe unittest {
103     int numCalls;
104     bool antiIdentity(int[] a) {
105         ++numCalls;
106         return a != a;
107     }
108 
109     check!antiIdentity.shouldThrow!UnitTestException;
110     // gets called twice due to shrinking
111     numCalls.shouldEqual(2);
112 }
113 
114 @("Verify property that sometimes succeeds")
115 @safe unittest {
116     // 2^100 is ~1.26E30, so the chances that no even length array is generated
117     // is small enough to disconsider even if it were truly random
118     // since Gen!int[] is front-loaded, it'll fail on the second attempt
119     assertExceptionMsg(check!((int[] a) => a.length % 2 == 0),
120                        "    source/unit_threaded/property/package.d:123 - Property failed with input:\n"
121                        "    source/unit_threaded/property/package.d:123 - [0]");
122 }
123 
124 
125 @("Explicit Gen")
126 @safe unittest {
127     check!((Gen!(int, 1, 1) a) => a == 1);
128     check!((Gen!(int, 1, 1) a) => a == 2).shouldThrow!UnitTestException;
129 }
130 
131 private enum canShrink(T) = __traits(compiles, shrink!((T _) => true)(T.init));
132 
133 T shrink(alias F, T)(T value) {
134     import std.conv: text;
135     assert(!F(value), text("Property did not fail for value ", value));
136     return shrinkImpl!F(value, [value]);
137 }
138 
139 private T shrinkImpl(alias F, T)(T value, T[] values) if(isIntegral!T) {
140     import std.algorithm: canFind, minPos, maxPos;
141     import std.traits: isSigned;
142 
143     if(value < T.max && F(value + 1)) return value;
144     if(value > T.min && F(value - 1)) return value;
145 
146     bool try_(T attempt) {
147         if(!F(attempt) && !values.canFind(attempt)) {
148             values ~= attempt;
149             return true;
150         }
151 
152         return false;
153     }
154 
155     T[] attempts;
156     static if(isSigned!T) attempts ~= -value;
157     attempts ~= value / 2;
158     if(value < T.max / 2) attempts ~= cast(T)(value * 2);
159     if(value < T.max) attempts ~= cast(T)(value + 1);
160     if(value > T.min) attempts ~= cast(T)(value - 1);
161 
162     foreach(attempt; attempts)
163         if(try_(attempt))
164             return shrinkImpl!F(attempt, values);
165 
166     auto min = values.minPos[0];
167     auto max = values.maxPos[0];
168 
169     if(!F(min)) return shrinkImpl!F(min, values);
170     if(!F(max)) return shrinkImpl!F(max, values);
171 
172     return values[0];
173 }
174 
175 static assert(canShrink!int);
176 
177 @("shrink int when already shrunk")
178 @safe pure unittest {
179     assertEqual(0.shrink!(a => a != 0), 0);
180 }
181 
182 
183 @("shrink int when not already shrunk going up")
184 @safe pure unittest {
185     assertEqual(0.shrink!(a => a > 3), 3);
186 }
187 
188 @("shrink int when not already shrunk going down")
189 @safe pure unittest {
190     assertEqual(10.shrink!(a => a < -3), -3);
191 }
192 
193 @("shrink int.max")
194 @safe pure unittest {
195     assertEqual(int.max.shrink!(a => a == 0), 1);
196     assertEqual(int.min.shrink!(a => a == 0), -1);
197     assertEqual(int.max.shrink!(a => a < 3), 3);
198 }
199 
200 @("shrink unsigneds")
201 @safe pure unittest {
202     import std.meta;
203     foreach(T; AliasSeq!(ubyte, ushort, uint, ulong)) {
204         T value = 3;
205         assertEqual(value.shrink!(a => a == 0), 1);
206     }
207 }
208 
209 private T shrinkImpl(alias F, T)(T value, T[] values) if(isArray!T) {
210     if(value == []) return value;
211 
212     if(value.length == 1) {
213         T empty;
214         return !F(empty) ? empty : value;
215     }
216 
217     auto fst = value[0 .. $ / 2];
218     auto snd = value[$ / 2 .. $];
219     if(!F(fst)) return shrinkImpl!F(fst, values);
220     if(!F(snd)) return shrinkImpl!F(snd, values);
221 
222     if(F(value[0 .. $ - 1])) return value[0 .. $ - 1];
223     if(F(value[1 .. $])) return value[1 .. $];
224 
225     if(!F(value[0 .. $ - 1])) return shrinkImpl!F(value[0 .. $ - 1], values);
226     if(!F(value[1 .. $])) return shrinkImpl!F(value[1 .. $], values);
227     return values[0];
228 }
229 
230 @("shrink empty int array")
231 @safe pure unittest {
232     int[] value;
233     assertEqual(value.shrink!(a => a != []), value);
234 }
235 
236 @("shrink int array")
237 @safe pure unittest {
238     assertEqual([1, 2, 3].shrink!(a => a == []), [1]);
239 }
240 
241 @("shrink string")
242 @safe pure unittest {
243     import std.algorithm: canFind;
244     assertEqual("abcdef".shrink!(a => !a.canFind("e")), "e");
245 }
246 
247 @("shrink one item with check")
248 unittest {
249     assertEqual("ǭĶƶØľĶĄÔ0".shrink!((s) => s.length < 3 || s[2] == 'e'), "ǭ");
250 }
251 
252 @("shrink one item with check")
253 unittest {
254     assertExceptionMsg(check!((int i) => i < 3),
255                        "    source/unit_threaded/property/package.d:123 - Property failed with input:\n"
256                        "    source/unit_threaded/property/package.d:123 - 3");
257 }