221 / 233 lines covered (94.8%)
1unit module BDD::Behave::Matcher::Async;23use BDD::Behave::Matcher;45constant DEFAULT-PROMISE-TIMEOUT = 5;6constant DEFAULT-STREAM-WINDOW = 1;7constant DEFAULT-EVENTUALLY-TIMEOUT = 2;8constant DEFAULT-EVENTUALLY-INTERVAL = 0.05;910sub bounded-wait(Promise:D $promise, Real:D $timeout --> Nil) {11 if $promise.status === Planned {12 await Promise.anyof($promise, Promise.in($timeout));13 }14}1516sub capture-cause(Promise:D $promise --> Mu) {17 my $cause;18 try {19 $promise.result;20 CATCH { default { $cause = $_; } }21 }22 $cause;23}2425sub is-stream-source($actual --> Bool) {26 ?($actual ~~ Supply) || ?($actual ~~ Channel);27}2829sub collect-emissions($source, Real:D $window, Int :$max-count) {30 my @collected;31 my Bool $completed = False;32 my $quit-cause;3334 react {35 whenever Promise.in($window) {36 done;37 }38 whenever $source -> $value {39 @collected.push($value);40 done if $max-count.defined && @collected.elems >= $max-count;41 LAST {42 $completed = True;43 done;44 }45 QUIT {46 $quit-cause = $_;47 done;48 }49 }50 }5152 (@collected, $completed, $quit-cause);53}5455class BeKeptMatcher does Matcher is export {56 has Real $.timeout = DEFAULT-PROMISE-TIMEOUT;57 has Bool $.promise-given is rw = True;58 has Bool $.timed-out is rw = False;59 has Mu $.value is rw;60 has Mu $.cause is rw;6162 method !reset(--> Nil) {63 $!promise-given = True;64 $!timed-out = False;65 $!value = Nil;66 $!cause = Nil;67 }6869 method matches($actual --> Bool) {70 self!reset;71 $!promise-given = ?($actual ~~ Promise);72 return False unless $!promise-given;7374 bounded-wait($actual, $!timeout);7576 given $actual.status {77 when Kept { $!value = $actual.result; True }78 when Broken { $!cause = capture-cause($actual); False }79 default { $!timed-out = True; False }80 }81 }8283 method !cause-detail(--> Str) {84 "{$!cause.^name}: {$!cause.message}";85 }8687 method failure-message($actual --> Str) {88 unless $!promise-given {89 return "expected a Promise for be-kept, but got " ~ $actual.raku;90 }91 if $!timed-out {92 "expected Promise to be kept within {$!timeout}s, but it was still pending";93 } elsif $!cause.defined {94 "expected Promise to be kept, but it was broken ({self!cause-detail})";95 } else {96 "expected Promise to be kept";97 }98 }99100 method failure-message-negated($actual --> Str) {101 "expected Promise not to be kept";102 }103104 method description(--> Str) { "be kept" }105}106107class BeBrokenMatcher does Matcher is export {108 has Real $.timeout = DEFAULT-PROMISE-TIMEOUT;109 has Bool $.promise-given is rw = True;110 has Bool $.timed-out is rw = False;111 has Bool $.was-kept is rw = False;112 has Mu $.value is rw;113 has Mu $.cause is rw;114115 method !reset(--> Nil) {116 $!promise-given = True;117 $!timed-out = False;118 $!was-kept = False;119 $!value = Nil;120 $!cause = Nil;121 }122123 method matches($actual --> Bool) {124 self!reset;125 $!promise-given = ?($actual ~~ Promise);126 return False unless $!promise-given;127128 bounded-wait($actual, $!timeout);129130 given $actual.status {131 when Broken { $!cause = capture-cause($actual); True }132 when Kept { $!was-kept = True; $!value = $actual.result; False }133 default { $!timed-out = True; False }134 }135 }136137 method !cause-detail(--> Str) {138 "{$!cause.^name}: {$!cause.message}";139 }140141 method failure-message($actual --> Str) {142 unless $!promise-given {143 return "expected a Promise for be-broken, but got " ~ $actual.raku;144 }145 if $!timed-out {146 "expected Promise to be broken within {$!timeout}s, but it was still pending";147 } elsif $!was-kept {148 "expected Promise to be broken, but it was kept with " ~ $!value.raku;149 } else {150 "expected Promise to be broken";151 }152 }153154 method failure-message-negated($actual --> Str) {155 if $!cause.defined {156 "expected Promise not to be broken, but it was ({self!cause-detail})";157 } else {158 "expected Promise not to be broken";159 }160 }161162 method description(--> Str) { "be broken" }163}164165class CompleteWithinMatcher does Matcher is export {166 has Real $.duration is required;167 has Bool $.promise-given is rw = True;168 has Bool $.timed-out is rw = False;169 has Mu $.final-status is rw;170 has Mu $.value is rw;171 has Mu $.cause is rw;172173 method !reset(--> Nil) {174 $!promise-given = True;175 $!timed-out = False;176 $!final-status = Nil;177 $!value = Nil;178 $!cause = Nil;179 }180181 method matches($actual --> Bool) {182 self!reset;183 $!promise-given = ?($actual ~~ Promise);184 return False unless $!promise-given;185186 bounded-wait($actual, $!duration);187188 $!final-status = $actual.status;189 given $actual.status {190 when Kept { $!value = $actual.result; True }191 when Broken { $!cause = capture-cause($actual); True }192 default { $!timed-out = True; False }193 }194 }195196 method !cause-detail(--> Str) {197 "{$!cause.^name}: {$!cause.message}";198 }199200 method failure-message($actual --> Str) {201 unless $!promise-given {202 return "expected a Promise for complete-within, but got " ~ $actual.raku;203 }204 "expected Promise to complete within {$!duration}s, but it was still pending";205 }206207 method failure-message-negated($actual --> Str) {208 if $!final-status === Kept {209 "expected Promise not to complete within {$!duration}s, but it was kept with " ~ $!value.raku;210 } elsif $!final-status === Broken {211 "expected Promise not to complete within {$!duration}s, but it was broken ({self!cause-detail})";212 } else {213 "expected Promise not to complete within {$!duration}s";214 }215 }216217 method description(--> Str) { "complete within {$!duration}s" }218219 method expected-value(--> Mu) { $!duration }220}221222class EmitMatcher does Matcher is export {223 has @.expected;224 has Real $.window = DEFAULT-STREAM-WINDOW;225 has Bool $.source-given is rw = True;226 has @.collected is rw;227 has Bool $.completed is rw = False;228 has Mu $.quit-cause is rw;229230 method !reset(--> Nil) {231 $!source-given = True;232 @!collected = ();233 $!completed = False;234 $!quit-cause = Nil;235 }236237 method matches($actual --> Bool) {238 self!reset;239 $!source-given = is-stream-source($actual);240 return False unless $!source-given;241242 my ($vals, $done, $cause) = collect-emissions(243 $actual, $!window, :max-count(@!expected.elems),244 );245 @!collected = $vals.list;246 $!completed = $done;247 $!quit-cause = $cause;248249 return False if @!collected.elems != @!expected.elems;250 for ^@!expected.elems -> $i {251 return False unless @!collected[$i] eqv @!expected[$i];252 }253 True;254 }255256 method failure-message($actual --> Str) {257 unless $!source-given {258 return "expected a Supply or Channel for emit, but got " ~ $actual.raku;259 }260 if $!quit-cause.defined {261 return "expected stream to emit " ~ @!expected.raku262 ~ ", but it quit ({$!quit-cause.^name}: {$!quit-cause.message})";263 }264 "expected stream to emit " ~ @!expected.raku265 ~ " within {$!window}s, but it emitted " ~ @!collected.raku;266 }267268 method failure-message-negated($actual --> Str) {269 "expected stream not to emit " ~ @!expected.raku;270 }271272 method description(--> Str) { "emit " ~ @!expected.raku }273274 method expected-value(--> Mu) { @!expected.list }275}276277class EmitAtLeastMatcher does Matcher is export {278 has Int $.minimum is required;279 has Real $.window = DEFAULT-STREAM-WINDOW;280 has Bool $.source-given is rw = True;281 has @.collected is rw;282 has Bool $.completed is rw = False;283 has Mu $.quit-cause is rw;284285 method !reset(--> Nil) {286 $!source-given = True;287 @!collected = ();288 $!completed = False;289 $!quit-cause = Nil;290 }291292 method !count-phrase(--> Str) {293 "{$!minimum} value" ~ ($!minimum == 1 ?? '' !! 's');294 }295296 method matches($actual --> Bool) {297 self!reset;298 $!source-given = is-stream-source($actual);299 return False unless $!source-given;300301 my ($vals, $done, $cause) = collect-emissions(302 $actual, $!window, :max-count($!minimum),303 );304 @!collected = $vals.list;305 $!completed = $done;306 $!quit-cause = $cause;307308 @!collected.elems >= $!minimum;309 }310311 method failure-message($actual --> Str) {312 unless $!source-given {313 return "expected a Supply or Channel for emit-at-least, but got "314 ~ $actual.raku;315 }316 if $!quit-cause.defined {317 return "expected stream to emit at least {self!count-phrase}"318 ~ ", but it quit ({$!quit-cause.^name}: {$!quit-cause.message})"319 ~ " after {@!collected.elems}";320 }321 "expected stream to emit at least {self!count-phrase} within {$!window}s"322 ~ ", but it emitted {@!collected.elems}";323 }324325 method failure-message-negated($actual --> Str) {326 "expected stream not to emit at least {self!count-phrase}";327 }328329 method description(--> Str) { "emit at least {self!count-phrase}" }330331 method expected-value(--> Mu) { $!minimum }332}333334class CompleteMatcher does Matcher is export {335 has Real $.window = DEFAULT-STREAM-WINDOW;336 has Bool $.source-given is rw = True;337 has @.collected is rw;338 has Bool $.completed is rw = False;339 has Mu $.quit-cause is rw;340341 method !reset(--> Nil) {342 $!source-given = True;343 @!collected = ();344 $!completed = False;345 $!quit-cause = Nil;346 }347348 method matches($actual --> Bool) {349 self!reset;350 $!source-given = is-stream-source($actual);351 return False unless $!source-given;352353 my ($vals, $done, $cause) = collect-emissions($actual, $!window);354 @!collected = $vals.list;355 $!completed = $done;356 $!quit-cause = $cause;357358 $!completed;359 }360361 method !emit-phrase(--> Str) {362 "emitted {@!collected.elems} value" ~ (@!collected.elems == 1 ?? '' !! 's');363 }364365 method failure-message($actual --> Str) {366 unless $!source-given {367 return "expected a Supply or Channel for complete, but got "368 ~ $actual.raku;369 }370 if $!quit-cause.defined {371 return "expected stream to complete within {$!window}s, but it quit"372 ~ " ({$!quit-cause.^name}: {$!quit-cause.message})";373 }374 "expected stream to complete within {$!window}s, but it was still active"375 ~ " ({self!emit-phrase})";376 }377378 method failure-message-negated($actual --> Str) {379 "expected stream not to complete within {$!window}s";380 }381382 method description(--> Str) { "complete within {$!window}s" }383384 method expected-value(--> Mu) { $!window }385}386387class EventuallyMatcher does Matcher is export {388 has Matcher $.inner is required;389 has Real $.timeout = DEFAULT-EVENTUALLY-TIMEOUT;390 has Real $.interval = DEFAULT-EVENTUALLY-INTERVAL;391 has Bool $.callable-given is rw = True;392 has Mu $.last-actual is rw;393 has Mu $.last-exception is rw;394 has Int $.iterations is rw = 0;395 has Real $.elapsed is rw = 0;396397 method !reset(--> Nil) {398 $!callable-given = True;399 $!last-actual = Nil;400 $!last-exception = Nil;401 $!iterations = 0;402 $!elapsed = 0;403 }404405 method matches($actual --> Bool) {406 self!reset;407 $!callable-given = ?($actual ~~ Callable);408 return False unless $!callable-given;409410 my $start = now;411 my $deadline = $start + $!timeout;412413 loop {414 $!iterations++;415 $!last-exception = Nil;416 my $value;417 my $caught = False;418 try {419 $value = $actual.();420 CATCH { default { $!last-exception = $_; $caught = True; } }421 }422 unless $caught {423 $!last-actual = $value;424 if ?$!inner.matches($value) {425 $!elapsed = now - $start;426 return True;427 }428 }429 if now >= $deadline {430 $!elapsed = now - $start;431 return False;432 }433 sleep $!interval if $!interval > 0;434 }435 }436437 method !timing-suffix(--> Str) {438 my $plural = $!iterations == 1 ?? '' !! 's';439 "after {$!iterations} iteration{$plural} in {$!elapsed.fmt('%.2f')}s";440 }441442 method !inner-failure(--> Str) {443 my $msg = $!inner.failure-message($!last-actual);444 return $msg if $msg.defined;445 "did not match {$!inner.description}";446 }447448 method failure-message($actual --> Str) {449 unless $!callable-given {450 return "expected a Callable for eventually, but got " ~ $actual.raku;451 }452 if $!last-exception.defined {453 return "eventually: block threw {$!last-exception.^name}: "454 ~ "{$!last-exception.message} ({self!timing-suffix})";455 }456 "eventually: {self!inner-failure} ({self!timing-suffix})";457 }458459 method failure-message-negated($actual --> Str) {460 unless $!callable-given {461 return "expected a Callable for eventually, but got " ~ $actual.raku;462 }463 "eventually: expected block not to {$!inner.description}, "464 ~ "but it matched (after {$!iterations} iteration"465 ~ ($!iterations == 1 ?? '' !! 's') ~ ")";466 }467468 method description(--> Str) {469 "eventually {$!inner.description}";470 }471472 method expected-value(--> Mu) { $!inner.expected-value }473}