← Back to index

lib/BDD/Behave/Matcher/Async.rakumod

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}