356 / 390 lines covered (91.3%)
1unit module BDD::Behave::Expectation;23use BDD::Behave::Failure;4use BDD::Behave::Failures;56sub throw-unless-aggregating(Str $file, Int $line --> Nil) {7 my $aggregating = False;8 try { $aggregating = ?$*BEHAVE-AGGREGATING }9 return if $aggregating;10 die X::BDD::Behave::ExpectationFailed.new(:$file, :$line);11}12use BDD::Behave::Matcher;13use BDD::Behave::Matcher::Core;14use BDD::Behave::Matcher::Collection;15use BDD::Behave::Matcher::Type;16use BDD::Behave::Matcher::Numeric;17use BDD::Behave::Matcher::Boolean;18use BDD::Behave::Matcher::String;19use BDD::Behave::Matcher::Exception;20use BDD::Behave::Matcher::Change;21use BDD::Behave::Matcher::Async;22use BDD::Behave::Matcher::Custom;23use BDD::Behave::Mock::HaveReceived;2425class BetweenExpectation is export {26 has Mu $.given;27 has Bool $.negated = False;28 has Str $.file;29 has Int $.line;30 has $.min;31 has $.max;32 has Bool $!exclusive = False;33 has $!recorded-failure;3435 submethod BUILD(36 Mu :$given is raw, :$!negated, :$!file, :$!line, :$!min, :$!max,37 ) {38 $!given := $given;39 }4041 method validate(--> Nil) {42 my $matcher = BeBetweenMatcher.new(43 :min($!min), :max($!max), :exclusive($!exclusive),44 );45 my $matched = ?$matcher.matches($!given);46 my $passed = $!negated ?? !$matched !! $matched;4748 if $passed {49 self.clear-failure;50 return;51 }5253 my $message = $!negated54 ?? $matcher.failure-message-negated($!given)55 !! $matcher.failure-message($!given);5657 self.replace-failure($message, $matcher);58 }5960 method replace-failure(Str $msg, $matcher --> Nil) {61 self.clear-failure;62 my $f = Failure.new(63 :file($!file),64 :line($!line),65 :given($!given),66 :expected($matcher.expected-value),67 :negated($!negated),68 :message($msg),69 );70 Failures.list.push($f);71 $!recorded-failure = $f;72 }7374 method clear-failure(--> Nil) {75 return unless $!recorded-failure.defined;76 my $list = Failures.list;77 my $idx = $list.first({ $_ === $!recorded-failure }, :k);78 $list.splice($idx, 1) if $idx.defined;79 $!recorded-failure = Nil;80 }8182 method inclusive {83 $!exclusive = False;84 self.validate;85 self;86 }8788 method exclusive {89 $!exclusive = True;90 self.validate;91 self;92 }93}9495class WithinExpectation is export {96 has Mu $.given;97 has Bool $.negated = False;98 has Str $.file;99 has Int $.line;100 has $.delta;101102 submethod BUILD(103 Mu :$given is raw, :$!negated, :$!file, :$!line, :$!delta,104 ) {105 $!given := $given;106 }107108 method of($expected --> Nil) {109 my $matcher = BeWithinMatcher.new(:delta($!delta), :$expected);110 my $matched = ?$matcher.matches($!given);111 my $passed = $!negated ?? !$matched !! $matched;112 return if $passed;113114 my $message = $!negated115 ?? $matcher.failure-message-negated($!given)116 !! $matcher.failure-message($!given);117118 my $f = Failure.new(119 :file($!file),120 :line($!line),121 :given($!given),122 :expected($matcher.expected-value),123 :negated($!negated),124 :message($message),125 );126 Failures.list.push($f);127 throw-unless-aggregating($!file, $!line);128 }129}130131class ChangeExpectation is export {132 has Mu $.given;133 has &.observable;134 has Bool $.negated = False;135 has Str $.file;136 has Int $.line;137 has $!expected-from;138 has Bool $!has-from = False;139 has $!expected-to;140 has Bool $!has-to = False;141 has $!expected-by;142 has Bool $!has-by = False;143 has $!expected-by-at-least;144 has Bool $!has-by-at-least = False;145 has $!expected-by-at-most;146 has Bool $!has-by-at-most = False;147 has $!before-value;148 has $!after-value;149 has Bool $!callable-given = True;150 has Bool $!ran = False;151 has Bool $!passed = False;152 has $!recorded-failure;153154 submethod BUILD(155 Mu :$given is raw, :&observable, :$!negated, :$!file, :$!line,156 ) {157 $!given := $given;158 &!observable = &observable;159 }160161 method !run-block(--> Nil) {162 return if $!ran;163 $!ran = True;164 $!callable-given = ?($!given ~~ Callable);165 return unless $!callable-given;166 $!before-value = &!observable.();167 $!given.();168 $!after-value = &!observable.();169 }170171 method !build-matcher() {172 my $m = ChangeMatcher.new(:&!observable);173 $m.callable-given = $!callable-given;174 $m.action-ran = $!callable-given;175 $m.before-value = $!before-value;176 $m.after-value = $!after-value;177 $m.expected-from = $!expected-from;178 $m.has-from = $!has-from;179 $m.expected-to = $!expected-to;180 $m.has-to = $!has-to;181 $m.expected-by = $!expected-by;182 $m.has-by = $!has-by;183 $m.expected-by-at-least = $!expected-by-at-least;184 $m.has-by-at-least = $!has-by-at-least;185 $m.expected-by-at-most = $!expected-by-at-most;186 $m.has-by-at-most = $!has-by-at-most;187 $m;188 }189190 method validate(--> Nil) {191 self!run-block;192 my $matcher = self!build-matcher;193 my $matched = ?$matcher.check-captured;194 $!passed = $!negated ?? !$matched !! $matched;195196 try {197 if $*BEHAVE-AUTO-MATCHERS.defined {198 $*BEHAVE-AUTO-MATCHERS.push(%(:$matcher, :negated($!negated)));199 }200 }201202 if $!passed {203 self.clear-failure;204 return;205 }206207 my $message = $!negated208 ?? $matcher.failure-message-negated($!given)209 !! $matcher.failure-message($!given);210211 self.replace-failure($message, $matcher);212 }213214 method replace-failure(Str $msg, $matcher --> Nil) {215 self.clear-failure;216 my $f = Failure.new(217 :file($!file),218 :line($!line),219 :given($!given),220 :expected($matcher.expected-value),221 :negated($!negated),222 :message($msg),223 );224 Failures.list.push($f);225 $!recorded-failure = $f;226 }227228 method clear-failure(--> Nil) {229 return unless $!recorded-failure.defined;230 my $list = Failures.list;231 my $idx = $list.first({ $_ === $!recorded-failure }, :k);232 $list.splice($idx, 1) if $idx.defined;233 $!recorded-failure = Nil;234 }235236 method from(Mu \value) {237 $!expected-from := value;238 $!has-from = True;239 self.validate;240 self;241 }242243 method to(Mu \value) {244 $!expected-to := value;245 $!has-to = True;246 self.validate;247 self;248 }249250 method by(Mu \value) {251 $!expected-by := value;252 $!has-by = True;253 self.validate;254 self;255 }256257 method by-at-least(Mu \value) {258 $!expected-by-at-least := value;259 $!has-by-at-least = True;260 self.validate;261 self;262 }263264 method by-at-most(Mu \value) {265 $!expected-by-at-most := value;266 $!has-by-at-most = True;267 self.validate;268 self;269 }270271 method Bool(--> Bool) { $!passed }272}273274class RaiseErrorExpectation is export {275 has Mu $.given;276 has Bool $.negated = False;277 has Str $.file;278 has Int $.line;279 has Mu $.expected-type;280 has Bool $.has-type = False;281 has $!expected-message;282 has Mu $!raised-exception;283 has Bool $!callable-given = True;284 has Bool $!ran = False;285 has Bool $!passed = False;286 has $!recorded-failure;287288 submethod BUILD(289 Mu :$given is raw, :$!negated, :$!file, :$!line,290 Mu :$expected-type, Bool :$has-type, :$expected-message,291 ) {292 $!given := $given;293 $!expected-type := $expected-type;294 $!has-type = ?$has-type;295 $!expected-message = $expected-message;296 }297298 method !run-block(--> Nil) {299 return if $!ran;300 $!ran = True;301 $!callable-given = ?($!given ~~ Callable);302 return unless $!callable-given;303 try {304 $!given.();305 CATCH { default { $!raised-exception = $_; } }306 }307 }308309 method !build-matcher() {310 my $m = RaiseErrorMatcher.new(311 :expected-type($!expected-type),312 :$!has-type,313 :expected-message($!expected-message),314 );315 $m.callable-given = $!callable-given;316 $m.raised-exception = $!raised-exception;317 $m;318 }319320 method validate(--> Nil) {321 self!run-block;322 my $matcher = self!build-matcher;323 my $matched = ?$matcher.check-captured;324 $!passed = $!negated ?? !$matched !! $matched;325326 try {327 if $*BEHAVE-AUTO-MATCHERS.defined {328 $*BEHAVE-AUTO-MATCHERS.push(%(:$matcher, :negated($!negated)));329 }330 }331332 if $!passed {333 self.clear-failure;334 return;335 }336337 my $message = $!negated338 ?? $matcher.failure-message-negated($!given)339 !! $matcher.failure-message($!given);340341 self.replace-failure($message, $matcher);342 }343344 method replace-failure(Str $msg, $matcher --> Nil) {345 self.clear-failure;346 my $f = Failure.new(347 :file($!file),348 :line($!line),349 :given($!given),350 :expected($matcher.expected-value),351 :negated($!negated),352 :message($msg),353 );354 Failures.list.push($f);355 $!recorded-failure = $f;356 }357358 method clear-failure(--> Nil) {359 return unless $!recorded-failure.defined;360 my $list = Failures.list;361 my $idx = $list.first({ $_ === $!recorded-failure }, :k);362 $list.splice($idx, 1) if $idx.defined;363 $!recorded-failure = Nil;364 }365366 proto method with-message(|) {*}367368 multi method with-message(Regex $message) {369 $!expected-message = $message;370 self.validate;371 self;372 }373374 multi method with-message(Str:D $message) {375 $!expected-message = $message;376 self.validate;377 self;378 }379380 method Bool(--> Bool) { $!passed }381}382383multi sub build-be-matcher(Junction:D $expected) {384 BeMatcher.new(:$expected);385}386multi sub build-be-matcher(Matcher:D $expected) {387 $expected;388}389multi sub build-be-matcher($expected) {390 BeMatcher.new(:$expected);391}392393class EventuallyExpectation is export {394 has Mu $.given;395 has Bool $.negated = False;396 has Str $.file;397 has Int $.line;398 has Real $.timeout = 2;399 has Real $.interval = 0.05;400401 submethod BUILD(402 Mu :$given is raw, :$!negated, :$!file, :$!line, :$!timeout, :$!interval,403 ) {404 $!given := $given;405 }406407 method to { self }408409 method not {410 EventuallyExpectation.new(411 :given($!given),412 :negated(True),413 :$!file,414 :$!line,415 :$!timeout,416 :$!interval,417 );418 }419420 method !apply-matcher(Matcher $inner --> Bool) {421 my $matcher = EventuallyMatcher.new(422 :$inner, :$!timeout, :$!interval,423 );424425 try {426 if $*BEHAVE-AUTO-MATCHERS.defined {427 $*BEHAVE-AUTO-MATCHERS.push(%(:$matcher, :negated($!negated)));428 }429 }430431 my $matched = ?$matcher.matches($!given);432 my $passed = $!negated ?? !$matched !! $matched;433434 if !$passed {435 my $message = $!negated436 ?? $matcher.failure-message-negated($!given)437 !! $matcher.failure-message($!given);438439 my $failure = Failure.new(440 :file($!file),441 :line($!line),442 :given($!given),443 :expected($matcher.expected-value),444 :negated($!negated),445 :message($message),446 );447 Failures.list.push($failure);448 throw-unless-aggregating($!file, $!line);449 }450451 $passed;452 }453454 method matches-with(Matcher $matcher) {455 self!apply-matcher($matcher);456 }457458 method be($expected) {459 self!apply-matcher(build-be-matcher($expected));460 }461462 method eq($expected) {463 self!apply-matcher(EqMatcher.new(:$expected));464 }465466 method be-truthy() { self!apply-matcher(BeTruthyMatcher.new) }467 method be-falsy() { self!apply-matcher(BeFalsyMatcher.new) }468 method be-nil() { self!apply-matcher(BeNilMatcher.new) }469470 method match(Regex $expected) {471 self!apply-matcher(MatchMatcher.new(:$expected));472 }473474 method include(**@items, *%pairs) {475 my @expected = @items;476 @expected.append: %pairs.pairs;477 if @expected.elems == 0 {478 die "include requires at least one item";479 }480 self!apply-matcher(IncludeMatcher.new(:expected(@expected)));481 }482483 method contain-exactly(**@items) {484 self!apply-matcher(ContainExactlyMatcher.new(:expected(@items)));485 }486487 method match-array($expected) {488 unless $expected ~~ Positional | Iterable {489 die "match-array requires an array argument";490 }491 self!apply-matcher(ContainExactlyMatcher.new(:expected($expected.list)));492 }493494 method start-with(**@items) {495 if @items.elems == 0 {496 die "start-with requires at least one item";497 }498 self!apply-matcher(StartWithMatcher.new(:expected(@items)));499 }500501 method end-with(**@items) {502 if @items.elems == 0 {503 die "end-with requires at least one item";504 }505 self!apply-matcher(EndWithMatcher.new(:expected(@items)));506 }507508 method be-a(Mu \type) { self!apply-matcher(BeAMatcher.new(:type(type))) }509 method be-an(Mu \type) { self!apply-matcher(BeAMatcher.new(:type(type))) }510511 method be-greater-than($expected) {512 self!apply-matcher(BeGreaterThanMatcher.new(:$expected));513 }514 method be-gt($expected) {515 self!apply-matcher(BeGreaterThanMatcher.new(:$expected));516 }517 method be-greater-than-or-equal-to($expected) {518 self!apply-matcher(BeGreaterThanOrEqualMatcher.new(:$expected));519 }520 method be-gte($expected) {521 self!apply-matcher(BeGreaterThanOrEqualMatcher.new(:$expected));522 }523 method be-less-than($expected) {524 self!apply-matcher(BeLessThanMatcher.new(:$expected));525 }526 method be-lt($expected) {527 self!apply-matcher(BeLessThanMatcher.new(:$expected));528 }529 method be-less-than-or-equal-to($expected) {530 self!apply-matcher(BeLessThanOrEqualMatcher.new(:$expected));531 }532 method be-lte($expected) {533 self!apply-matcher(BeLessThanOrEqualMatcher.new(:$expected));534 }535536 method FALLBACK($name, |c) {537 my $registry = BDD::Behave::Matcher::Custom::registry();538 if $registry.exists($name) {539 my $matcher = $registry.build($name, |c);540 return self!apply-matcher($matcher);541 }542 die X::Method::NotFound.new(543 method => $name,544 typename => self.^name,545 );546 }547}548549class ExpectationBuilder is export {550 has $.given;551 has Bool $.negated is rw = False;552 has Int $.line;553 has Str $.file;554555 method to { self }556557 method not {558 my $new = ExpectationBuilder.new(559 :given($!given),560 :negated(True),561 :line($!line),562 :file($!file)563 );564 $new;565 }566567 method !apply-matcher($matcher) {568 try {569 if $*BEHAVE-AUTO-MATCHERS.defined {570 $*BEHAVE-AUTO-MATCHERS.push(%(:$matcher, :negated($!negated)));571 }572 }573574 my $matched = ?$matcher.matches($!given);575 my $passed = $!negated ?? !$matched !! $matched;576577 if !$passed {578 my $message = $!negated579 ?? $matcher.failure-message-negated($!given)580 !! $matcher.failure-message($!given);581582 my $expected-for-failure = $matcher ~~ BeMatcher583 ?? $matcher.expected584 !! $matcher.expected-value;585586 my $failure = Failure.new(587 :file($!file),588 :line($!line),589 :given($!given),590 :expected($expected-for-failure),591 :negated($!negated),592 :message($message),593 );594 Failures.list.push($failure);595 throw-unless-aggregating($!file, $!line);596 }597598 $passed;599 }600601 method be(|c) {602 my @pos = c.list;603 my %named = c.hash;604605 my $resolved-expected;606 if %named.elems == 1 && @pos.elems == 0 {607 my $key = %named.keys[0];608 try {609 $resolved-expected = $*LET-RUNTIME.value($key) if $*LET-RUNTIME.defined;610 CATCH {611 default {612 die "Unknown let(:$key)";613 }614 }615 }616 } elsif @pos.elems == 1 {617 my $expected = @pos[0];618619 $resolved-expected = $expected;620 if $expected ~~ Pair {621 try {622 $resolved-expected = $*LET-RUNTIME.value($expected.key) if $*LET-RUNTIME.defined;623 }624 }625 } else {626 die "be requires either a positional argument or a single named argument";627 }628629 self!apply-matcher(build-be-matcher($resolved-expected));630 }631632 method include(**@items, *%pairs) {633 my @expected = @items;634 @expected.append: %pairs.pairs;635 if @expected.elems == 0 {636 die "include requires at least one item";637 }638 self!apply-matcher(IncludeMatcher.new(:expected(@expected)));639 }640641 method eq($expected) {642 self!apply-matcher(EqMatcher.new(:expected($expected)));643 }644645 method contain-exactly(**@items) {646 self!apply-matcher(ContainExactlyMatcher.new(:expected(@items)));647 }648649 method match-array($expected) {650 unless $expected ~~ Positional | Iterable {651 die "match-array requires an array argument";652 }653 self!apply-matcher(ContainExactlyMatcher.new(:expected($expected.list)));654 }655656 method start-with(**@items) {657 if @items.elems == 0 {658 die "start-with requires at least one item";659 }660 self!apply-matcher(StartWithMatcher.new(:expected(@items)));661 }662663 method end-with(**@items) {664 if @items.elems == 0 {665 die "end-with requires at least one item";666 }667 self!apply-matcher(EndWithMatcher.new(:expected(@items)));668 }669670 method all(Mu \expected) {671 my $inner = expected ~~ Matcher672 ?? expected673 !! BeMatcher.new(:expected(expected));674 self!apply-matcher(AllMatcher.new(:inner($inner)));675 }676677 method be-a(Mu \type) {678 self!apply-matcher(BeAMatcher.new(:type(type)));679 }680681 method be-an(Mu \type) {682 self!apply-matcher(BeAMatcher.new(:type(type)));683 }684685 method be-an-instance-of(Mu \type) {686 self!apply-matcher(BeAnInstanceOfMatcher.new(:type(type)));687 }688689 method respond-to(**@names) {690 if @names.elems == 0 {691 die "respond-to requires at least one method name";692 }693 self!apply-matcher(RespondToMatcher.new(:expected(@names)));694 }695696 method have-attributes(*%attrs) {697 if %attrs.elems == 0 {698 die "have-attributes requires at least one attribute name => value pair";699 }700 self!apply-matcher(HaveAttributesMatcher.new(:expected(%attrs)));701 }702703 method be-greater-than($expected) {704 self!apply-matcher(BeGreaterThanMatcher.new(:$expected));705 }706707 method be-gt($expected) {708 self!apply-matcher(BeGreaterThanMatcher.new(:$expected));709 }710711 method be-greater-than-or-equal-to($expected) {712 self!apply-matcher(BeGreaterThanOrEqualMatcher.new(:$expected));713 }714715 method be-gte($expected) {716 self!apply-matcher(BeGreaterThanOrEqualMatcher.new(:$expected));717 }718719 method be-less-than($expected) {720 self!apply-matcher(BeLessThanMatcher.new(:$expected));721 }722723 method be-lt($expected) {724 self!apply-matcher(BeLessThanMatcher.new(:$expected));725 }726727 method be-less-than-or-equal-to($expected) {728 self!apply-matcher(BeLessThanOrEqualMatcher.new(:$expected));729 }730731 method be-lte($expected) {732 self!apply-matcher(BeLessThanOrEqualMatcher.new(:$expected));733 }734735 method be-between($min, $max) {736 my $expectation = BetweenExpectation.new(737 :given($!given),738 :negated($!negated),739 :file($!file),740 :line($!line),741 :$min,742 :$max,743 );744 $expectation.validate;745 $expectation;746 }747748 method be-truthy() {749 self!apply-matcher(BeTruthyMatcher.new);750 }751752 method be-falsy() {753 self!apply-matcher(BeFalsyMatcher.new);754 }755756 method be-nil() {757 self!apply-matcher(BeNilMatcher.new);758 }759760 method match(Regex $expected) {761 self!apply-matcher(MatchMatcher.new(:$expected));762 }763764 method !build-raise-expectation(765 Mu :$expected-type, Bool :$has-type, :$expected-message,766 ) {767 my $expectation = RaiseErrorExpectation.new(768 :given($!given),769 :negated($!negated),770 :file($!file),771 :line($!line),772 :$expected-type,773 :$has-type,774 :$expected-message,775 );776 $expectation.validate;777 $expectation;778 }779780 proto method raise-error(|) {*}781782 multi method raise-error() {783 self!build-raise-expectation;784 }785786 multi method raise-error(Regex $message) {787 self!build-raise-expectation(:expected-message($message));788 }789790 multi method raise-error(Mu \type) {791 self!build-raise-expectation(:expected-type(type), :has-type);792 }793794 multi method raise-error(Mu \type, Regex $message) {795 self!build-raise-expectation(796 :expected-type(type),797 :has-type,798 :expected-message($message),799 );800 }801802 proto method throw(|) {*}803804 multi method throw() {805 self!build-raise-expectation;806 }807808 multi method throw(Regex $message) {809 self!build-raise-expectation(:expected-message($message));810 }811812 multi method throw(Mu \type) {813 self!build-raise-expectation(:expected-type(type), :has-type);814 }815816 multi method throw(Mu \type, Regex $message) {817 self!build-raise-expectation(818 :expected-type(type),819 :has-type,820 :expected-message($message),821 );822 }823824 proto method raise(|) {*}825826 multi method raise() {827 self!build-raise-expectation;828 }829830 multi method raise(Regex $message) {831 self!build-raise-expectation(:expected-message($message));832 }833834 multi method raise(Mu \type) {835 self!build-raise-expectation(:expected-type(type), :has-type);836 }837838 multi method raise(Mu \type, Regex $message) {839 self!build-raise-expectation(840 :expected-type(type),841 :has-type,842 :expected-message($message),843 );844 }845846 method change(&observable) {847 my $expectation = ChangeExpectation.new(848 :given($!given),849 :&observable,850 :negated($!negated),851 :file($!file),852 :line($!line),853 );854 $expectation.validate;855 $expectation;856 }857858 method be-within($delta) {859 WithinExpectation.new(860 :given($!given),861 :negated($!negated),862 :file($!file),863 :line($!line),864 :$delta,865 );866 }867868 proto method be-kept(|) {*}869870 multi method be-kept() {871 self!apply-matcher(BeKeptMatcher.new);872 }873874 multi method be-kept(Real:D $timeout) {875 self!apply-matcher(BeKeptMatcher.new(:$timeout));876 }877878 proto method be-broken(|) {*}879880 multi method be-broken() {881 self!apply-matcher(BeBrokenMatcher.new);882 }883884 multi method be-broken(Real:D $timeout) {885 self!apply-matcher(BeBrokenMatcher.new(:$timeout));886 }887888 method complete-within(Real:D $duration) {889 self!apply-matcher(CompleteWithinMatcher.new(:$duration));890 }891892 method emit(**@values, Real :$within = 1) {893 self!apply-matcher(EmitMatcher.new(:expected(@values), :window($within)));894 }895896 method emit-at-least(Int:D $minimum, Real :$within = 1) {897 self!apply-matcher(898 EmitAtLeastMatcher.new(:$minimum, :window($within)),899 );900 }901902 method complete(Real :$within = 1) {903 self!apply-matcher(CompleteMatcher.new(:window($within)));904 }905906 method eventually(Real :$timeout = 2, Real :$interval = 0.05) {907 EventuallyExpectation.new(908 :given($!given),909 :negated($!negated),910 :file($!file),911 :line($!line),912 :$timeout,913 :$interval,914 );915 }916917 method have-received(Str:D $method-name) {918 my $expectation = HaveReceivedExpectation.new(919 :target($!given),920 :$method-name,921 :negated($!negated),922 :file($!file),923 :line($!line),924 );925 $expectation.validate;926 $expectation;927 }928929 method FALLBACK($name, |c) {930 my $registry = BDD::Behave::Matcher::Custom::registry();931 if $registry.exists($name) {932 my $matcher = $registry.build($name, |c);933 return self!apply-matcher($matcher);934 }935 die X::Method::NotFound.new(936 method => $name,937 typename => self.^name,938 );939 }940}