crspec 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,56 @@ module Crspec
16
16
  def failure_message_when_negated
17
17
  "Expected #{@actual.inspect} not to match #{@expected.inspect}"
18
18
  end
19
+
20
+ def and(other)
21
+ CompoundAndMatcher.new(self, other)
22
+ end
23
+
24
+ def or(other)
25
+ CompoundOrMatcher.new(self, other)
26
+ end
27
+ end
28
+
29
+ class CompoundAndMatcher
30
+ def initialize(first, second)
31
+ @first = first
32
+ @second = second
33
+ end
34
+
35
+ def matches?(actual)
36
+ @first_matched = @first.matches?(actual)
37
+ @second_matched = @second.matches?(actual)
38
+ @first_matched && @second_matched
39
+ end
40
+
41
+ def failure_message
42
+ messages = []
43
+ messages << @first.failure_message unless @first_matched
44
+ messages << @second.failure_message unless @second_matched
45
+ messages.join("\n...and:\n")
46
+ end
47
+
48
+ def failure_message_when_negated
49
+ "Expected compound matcher not to match"
50
+ end
51
+
52
+ def and(other)
53
+ CompoundAndMatcher.new(self, other)
54
+ end
55
+
56
+ def or(other)
57
+ CompoundOrMatcher.new(self, other)
58
+ end
59
+ end
60
+
61
+ class CompoundOrMatcher < CompoundAndMatcher
62
+ def matches?(actual)
63
+ @first.matches?(actual) || @second.matches?(actual)
64
+ end
65
+
66
+ def failure_message
67
+ "#{@first.failure_message}\n...or:\n#{@second.failure_message}"
68
+ end
19
69
  end
20
70
 
21
71
  class EqMatcher < BaseMatcher
@@ -87,6 +137,36 @@ module Crspec
87
137
  end
88
138
  end
89
139
 
140
+ class BeAKindOfMatcher < BaseMatcher
141
+ def matches?(actual)
142
+ @actual = actual
143
+ @actual.is_a?(@expected)
144
+ end
145
+
146
+ def failure_message
147
+ "Expected #{@actual.inspect} to be a kind of #{@expected}"
148
+ end
149
+
150
+ def failure_message_when_negated
151
+ "Expected #{@actual.inspect} not to be a kind of #{@expected}"
152
+ end
153
+ end
154
+
155
+ class BeAnInstanceOfMatcher < BaseMatcher
156
+ def matches?(actual)
157
+ @actual = actual
158
+ @actual.instance_of?(@expected)
159
+ end
160
+
161
+ def failure_message
162
+ "Expected #{@actual.inspect} to be an instance of #{@expected}, but was #{@actual.class}"
163
+ end
164
+
165
+ def failure_message_when_negated
166
+ "Expected #{@actual.inspect} not to be an instance of #{@expected}"
167
+ end
168
+ end
169
+
90
170
  class IncludeMatcher < BaseMatcher
91
171
  def matches?(actual)
92
172
  @actual = actual
@@ -219,6 +299,478 @@ module Crspec
219
299
  end
220
300
  end
221
301
 
302
+ class PredicateMatcher < BaseMatcher
303
+ def initialize(predicate, description, *args, **kwargs, &block)
304
+ super(nil)
305
+ @predicate = predicate
306
+ @description = description
307
+ @args = args
308
+ @kwargs = kwargs
309
+ @block = block
310
+ end
311
+
312
+ def matches?(actual)
313
+ @actual = actual
314
+ unless @actual.respond_to?(@predicate)
315
+ raise NoMethodError, "undefined method '#{@predicate}' for #{@actual.inspect}"
316
+ end
317
+
318
+ !!@actual.public_send(@predicate, *@args, **@kwargs, &@block)
319
+ end
320
+
321
+ def failure_message
322
+ "Expected #{@actual.inspect} to #{@description}#{args_description}"
323
+ end
324
+
325
+ def failure_message_when_negated
326
+ "Expected #{@actual.inspect} not to #{@description}#{args_description}"
327
+ end
328
+
329
+ private
330
+
331
+ def args_description
332
+ return "" if @args.empty? && @kwargs.empty?
333
+
334
+ parts = @args.map(&:inspect) + @kwargs.map { |k, v| "#{k}: #{v.inspect}" }
335
+ " with #{parts.join(", ")}"
336
+ end
337
+ end
338
+
339
+ class HaveHttpStatusMatcher < BaseMatcher
340
+ STATUS_GROUPS = {
341
+ success: 200..299,
342
+ successful: 200..299,
343
+ redirect: 300..399,
344
+ error: 500..599,
345
+ server_error: 500..599,
346
+ client_error: 400..499,
347
+ missing: [404]
348
+ }.freeze
349
+
350
+ FALLBACK_CODES = {
351
+ ok: 200, created: 201, accepted: 202, no_content: 204,
352
+ moved_permanently: 301, found: 302, see_other: 303, not_modified: 304,
353
+ bad_request: 400, unauthorized: 401, forbidden: 403, not_found: 404,
354
+ method_not_allowed: 405, not_acceptable: 406, conflict: 409, gone: 410,
355
+ unprocessable_entity: 422, unprocessable_content: 422, too_many_requests: 429,
356
+ internal_server_error: 500, not_implemented: 501, bad_gateway: 502,
357
+ service_unavailable: 503
358
+ }.freeze
359
+
360
+ def initialize(expected)
361
+ super(expected)
362
+ end
363
+
364
+ def matches?(actual)
365
+ @actual = actual
366
+ @actual_status = actual.respond_to?(:status) ? actual.status : actual
367
+ expected_codes.include?(@actual_status)
368
+ end
369
+
370
+ def failure_message
371
+ "Expected response to have HTTP status #{@expected.inspect}, got #{@actual_status.inspect}"
372
+ end
373
+
374
+ def failure_message_when_negated
375
+ "Expected response not to have HTTP status #{@expected.inspect}, but it did (#{@actual_status.inspect})"
376
+ end
377
+
378
+ private
379
+
380
+ def expected_codes
381
+ case @expected
382
+ when Integer then [@expected]
383
+ when Range then @expected
384
+ when Symbol, String
385
+ sym = @expected.to_sym
386
+ group = STATUS_GROUPS[sym]
387
+ return group if group
388
+
389
+ if defined?(::Rack::Utils::SYMBOL_TO_STATUS_CODE)
390
+ code = ::Rack::Utils::SYMBOL_TO_STATUS_CODE[sym]
391
+ return [code] if code
392
+ end
393
+ code = FALLBACK_CODES[sym]
394
+ return [code] if code
395
+
396
+ raise ArgumentError, "Unknown HTTP status: #{@expected.inspect}"
397
+ else
398
+ raise ArgumentError, "Invalid HTTP status: #{@expected.inspect}"
399
+ end
400
+ end
401
+ end
402
+
403
+ class MatchMatcher < BaseMatcher
404
+ def matches?(actual)
405
+ @actual = actual
406
+ case @expected
407
+ when Regexp then @expected.match?(actual.to_s)
408
+ when String then actual.is_a?(Regexp) ? actual.match?(@expected) : actual == @expected
409
+ else values_match?(@expected, actual)
410
+ end
411
+ end
412
+
413
+ def failure_message
414
+ "Expected #{@actual.inspect} to match #{@expected.inspect}"
415
+ end
416
+
417
+ private
418
+
419
+ def values_match?(expected, actual)
420
+ case expected
421
+ when Array
422
+ return false unless actual.is_a?(Array) && actual.size == expected.size
423
+
424
+ expected.zip(actual).all? { |e, a| values_match?(e, a) }
425
+ when Hash
426
+ return false unless actual.is_a?(Hash) && actual.size == expected.size
427
+
428
+ expected.all? { |k, v| actual.key?(k) && values_match?(v, actual[k]) }
429
+ when Regexp
430
+ expected.match?(actual.to_s)
431
+ else
432
+ expected === actual || expected == actual
433
+ end
434
+ end
435
+ end
436
+
437
+ class MatchArrayMatcher < BaseMatcher
438
+ def matches?(actual)
439
+ @actual = actual
440
+ return false unless actual.respond_to?(:to_a)
441
+
442
+ remaining = actual.to_a.dup
443
+ @expected.all? do |item|
444
+ idx = remaining.index { |el| item == el || item === el }
445
+ idx ? remaining.delete_at(idx) && true : false
446
+ end && remaining.empty?
447
+ end
448
+
449
+ def failure_message
450
+ "Expected #{@actual.inspect} to contain exactly #{@expected.inspect} (in any order)"
451
+ end
452
+ end
453
+
454
+ class HaveAttributesMatcher < BaseMatcher
455
+ def matches?(actual)
456
+ @actual = actual
457
+ @mismatches = {}
458
+ @expected.each do |attr, value|
459
+ unless actual.respond_to?(attr)
460
+ @mismatches[attr] = :no_method
461
+ next
462
+ end
463
+ actual_value = actual.public_send(attr)
464
+ @mismatches[attr] = actual_value unless value == actual_value || value === actual_value
465
+ end
466
+ @mismatches.empty?
467
+ end
468
+
469
+ def failure_message
470
+ details = @mismatches.map do |attr, val|
471
+ if val == :no_method
472
+ "#{attr}: (does not respond)"
473
+ else
474
+ "#{attr}: expected #{@expected[attr].inspect}, got #{val.inspect}"
475
+ end
476
+ end
477
+ "Expected #{@actual.inspect} to have attributes #{@expected.inspect}:\n #{details.join("\n ")}"
478
+ end
479
+ end
480
+
481
+ class AllMatcher < BaseMatcher
482
+ def initialize(inner)
483
+ super()
484
+ @inner = inner
485
+ end
486
+
487
+ def matches?(actual)
488
+ @actual = actual
489
+ @failed_index = nil
490
+ actual.each_with_index do |item, idx|
491
+ next if @inner.matches?(item)
492
+
493
+ @failed_index = idx
494
+ @failed_message = @inner.failure_message
495
+ return false
496
+ end
497
+ true
498
+ end
499
+
500
+ def failure_message
501
+ "Expected all elements to match, but element at index #{@failed_index} failed: #{@failed_message}"
502
+ end
503
+ end
504
+
505
+ class SatisfyMatcher < BaseMatcher
506
+ def initialize(description = nil, &block)
507
+ super(nil)
508
+ @description = description
509
+ @block = block
510
+ end
511
+
512
+ def matches?(actual)
513
+ @actual = actual
514
+ !!@block.call(actual)
515
+ end
516
+
517
+ def failure_message
518
+ "Expected #{@actual.inspect} to satisfy #{@description || "the given block"}"
519
+ end
520
+ end
521
+
522
+ class BeWithinMatcher < BaseMatcher
523
+ def initialize(delta)
524
+ super(delta)
525
+ @delta = delta
526
+ end
527
+
528
+ def of(value)
529
+ @center = value
530
+ self
531
+ end
532
+
533
+ def percent_of(value)
534
+ @center = value
535
+ @delta = value.abs * @expected / 100.0
536
+ self
537
+ end
538
+
539
+ def matches?(actual)
540
+ raise ArgumentError, "be_within requires .of(value)" unless defined?(@center)
541
+
542
+ @actual = actual
543
+ (actual - @center).abs <= @delta
544
+ end
545
+
546
+ def failure_message
547
+ "Expected #{@actual.inspect} to be within #{@delta} of #{@center}"
548
+ end
549
+ end
550
+
551
+ class StartWithMatcher < BaseMatcher
552
+ def matches?(actual)
553
+ @actual = actual
554
+ if actual.respond_to?(:start_with?)
555
+ actual.start_with?(@expected)
556
+ elsif actual.is_a?(Array)
557
+ actual.first(Array(@expected).size) == Array(@expected)
558
+ else
559
+ false
560
+ end
561
+ end
562
+
563
+ def failure_message
564
+ "Expected #{@actual.inspect} to start with #{@expected.inspect}"
565
+ end
566
+ end
567
+
568
+ class EndWithMatcher < BaseMatcher
569
+ def matches?(actual)
570
+ @actual = actual
571
+ if actual.respond_to?(:end_with?)
572
+ actual.end_with?(@expected)
573
+ elsif actual.is_a?(Array)
574
+ actual.last(Array(@expected).size) == Array(@expected)
575
+ else
576
+ false
577
+ end
578
+ end
579
+
580
+ def failure_message
581
+ "Expected #{@actual.inspect} to end with #{@expected.inspect}"
582
+ end
583
+ end
584
+
585
+ class OutputMatcher < BaseMatcher
586
+ def initialize(expected = nil)
587
+ super
588
+ @stream = :stdout
589
+ end
590
+
591
+ def to_stdout
592
+ @stream = :stdout
593
+ self
594
+ end
595
+
596
+ def to_stderr
597
+ @stream = :stderr
598
+ self
599
+ end
600
+
601
+ def matches?(block)
602
+ raise ArgumentError, "output matcher requires a block" unless block.respond_to?(:call)
603
+
604
+ @captured = capture(block)
605
+ if @expected.nil?
606
+ !@captured.empty?
607
+ elsif @expected.is_a?(Regexp)
608
+ @expected.match?(@captured)
609
+ else
610
+ @captured.include?(@expected)
611
+ end
612
+ end
613
+
614
+ def failure_message
615
+ "Expected block to output #{@expected ? @expected.inspect : "something"} to #{@stream}, got #{@captured.inspect}"
616
+ end
617
+
618
+ private
619
+
620
+ def capture(block)
621
+ require "stringio"
622
+ captured = StringIO.new
623
+ if @stream == :stdout
624
+ original = $stdout
625
+ $stdout = captured
626
+ begin
627
+ block.call
628
+ ensure
629
+ $stdout = original
630
+ end
631
+ else
632
+ original = $stderr
633
+ $stderr = captured
634
+ begin
635
+ block.call
636
+ ensure
637
+ $stderr = original
638
+ end
639
+ end
640
+ captured.string
641
+ end
642
+ end
643
+
644
+ class YieldControlMatcher < BaseMatcher
645
+ def matches?(block)
646
+ @yielded = 0
647
+ probe = ->(*) { @yielded += 1 }
648
+ block.call(probe)
649
+ @yielded.positive?
650
+ end
651
+
652
+ def failure_message
653
+ "Expected block to yield control, but it did not"
654
+ end
655
+
656
+ def failure_message_when_negated
657
+ "Expected block not to yield control, but it yielded #{@yielded} time(s)"
658
+ end
659
+ end
660
+
661
+ class YieldWithArgsMatcher < BaseMatcher
662
+ def initialize(*expected_args)
663
+ super(expected_args)
664
+ @expected_args = expected_args
665
+ end
666
+
667
+ def matches?(block)
668
+ @yielded_args = nil
669
+ probe = ->(*args) { @yielded_args = args }
670
+ block.call(probe)
671
+ return false if @yielded_args.nil?
672
+ return true if @expected_args.empty?
673
+ return false unless @yielded_args.size == @expected_args.size
674
+
675
+ @expected_args.zip(@yielded_args).all? { |e, a| e == a || e === a }
676
+ end
677
+
678
+ def failure_message
679
+ if @yielded_args.nil?
680
+ "Expected block to yield with args #{@expected_args.inspect}, but it did not yield"
681
+ else
682
+ "Expected block to yield with args #{@expected_args.inspect}, got #{@yielded_args.inspect}"
683
+ end
684
+ end
685
+ end
686
+
687
+ class YieldSuccessiveArgsMatcher < BaseMatcher
688
+ def initialize(*expected_args)
689
+ super(expected_args)
690
+ @expected_args = expected_args
691
+ end
692
+
693
+ def matches?(block)
694
+ @yielded = []
695
+ probe = ->(*args) { @yielded << (args.size == 1 ? args.first : args) }
696
+ block.call(probe)
697
+ return false unless @yielded.size == @expected_args.size
698
+
699
+ @expected_args.zip(@yielded).all? { |e, a| e == a || e === a }
700
+ end
701
+
702
+ def failure_message
703
+ "Expected block to yield successive args #{@expected_args.inspect}, got #{@yielded.inspect}"
704
+ end
705
+ end
706
+
707
+ class CustomMatcher < BaseMatcher
708
+ def initialize(name, expected_args, &definition_block)
709
+ super(expected_args)
710
+ @name = name
711
+ @expected_args = expected_args
712
+ @definition_block = definition_block
713
+ @match_proc = nil
714
+ @failure_message_proc = nil
715
+ @negated_failure_message_proc = nil
716
+ @description_proc = nil
717
+ @chains = {}
718
+ instance_exec(*expected_args, &definition_block) if definition_block
719
+ end
720
+
721
+ def match(&block)
722
+ @match_proc = block
723
+ end
724
+
725
+ # DSL form (with block) stores the message proc; query form (no
726
+ # block) returns the rendered message.
727
+ def failure_message(&block)
728
+ if block
729
+ @failure_message_proc = block
730
+ return
731
+ end
732
+ if @failure_message_proc
733
+ instance_exec(@actual, &@failure_message_proc)
734
+ else
735
+ "Expected #{@actual.inspect} to #{description}"
736
+ end
737
+ end
738
+
739
+ def failure_message_when_negated(&block)
740
+ if block
741
+ @negated_failure_message_proc = block
742
+ return
743
+ end
744
+ if @negated_failure_message_proc
745
+ instance_exec(@actual, &@negated_failure_message_proc)
746
+ else
747
+ "Expected #{@actual.inspect} not to #{description}"
748
+ end
749
+ end
750
+
751
+ def description(&block)
752
+ if block
753
+ @description_proc = block
754
+ return
755
+ end
756
+ @description_proc ? instance_exec(&@description_proc) : @name.to_s.tr("_", " ")
757
+ end
758
+
759
+ def chain(name, &block)
760
+ matcher = self
761
+ define_singleton_method(name) do |*args|
762
+ matcher.instance_variable_get(:@chains)[name] = args
763
+ matcher.instance_exec(*args, &block) if block
764
+ matcher
765
+ end
766
+ end
767
+
768
+ def matches?(actual)
769
+ @actual = actual
770
+ @match_proc ? instance_exec(actual, &@match_proc) : true
771
+ end
772
+ end
773
+
222
774
  def eq(expected)
223
775
  EqMatcher.new(expected)
224
776
  end
@@ -238,6 +790,19 @@ module Crspec
238
790
  def be_falsy
239
791
  BeFalsyMatcher.new
240
792
  end
793
+ alias be_falsey be_falsy
794
+
795
+ def be_a(klass)
796
+ BeAKindOfMatcher.new(klass)
797
+ end
798
+ alias be_an be_a
799
+ alias be_a_kind_of be_a
800
+ alias be_kind_of be_a
801
+
802
+ def be_an_instance_of(klass)
803
+ BeAnInstanceOfMatcher.new(klass)
804
+ end
805
+ alias be_instance_of be_an_instance_of
241
806
 
242
807
  def include(*expected)
243
808
  IncludeMatcher.new(expected.size == 1 ? expected.first : expected)
@@ -254,5 +819,82 @@ module Crspec
254
819
  def change(receiver = nil, message = nil, &block)
255
820
  ChangeMatcher.new(receiver, message, &block)
256
821
  end
822
+
823
+ def have_http_status(expected)
824
+ HaveHttpStatusMatcher.new(expected)
825
+ end
826
+
827
+ def match(expected)
828
+ MatchMatcher.new(expected)
829
+ end
830
+
831
+ def match_array(expected)
832
+ MatchArrayMatcher.new(expected.to_a)
833
+ end
834
+
835
+ def contain_exactly(*items)
836
+ MatchArrayMatcher.new(items)
837
+ end
838
+
839
+ def have_attributes(expected)
840
+ HaveAttributesMatcher.new(expected)
841
+ end
842
+
843
+ def all(matcher)
844
+ AllMatcher.new(matcher)
845
+ end
846
+
847
+ def satisfy(description = nil, &block)
848
+ SatisfyMatcher.new(description, &block)
849
+ end
850
+
851
+ def be_within(delta)
852
+ BeWithinMatcher.new(delta)
853
+ end
854
+
855
+ def start_with(expected)
856
+ StartWithMatcher.new(expected)
857
+ end
858
+
859
+ def end_with(expected)
860
+ EndWithMatcher.new(expected)
861
+ end
862
+
863
+ def output(expected = nil)
864
+ OutputMatcher.new(expected)
865
+ end
866
+
867
+ def yield_control
868
+ YieldControlMatcher.new
869
+ end
870
+
871
+ def yield_with_args(*args)
872
+ YieldWithArgsMatcher.new(*args)
873
+ end
874
+
875
+ def yield_successive_args(*args)
876
+ YieldSuccessiveArgsMatcher.new(*args)
877
+ end
878
+
879
+ def self.define(name, &block)
880
+ define_method(name) do |*args|
881
+ CustomMatcher.new(name, args, &block)
882
+ end
883
+ end
884
+
885
+ def method_missing(name, *args, **kwargs, &block)
886
+ case name
887
+ when /\Abe_(.+)\z/
888
+ PredicateMatcher.new(:"#{Regexp.last_match(1)}?", "be #{Regexp.last_match(1).tr("_", " ")}", *args, **kwargs, &block)
889
+ when /\Ahave_(.+)\z/
890
+ PredicateMatcher.new(:"has_#{Regexp.last_match(1)}?", "have #{Regexp.last_match(1).tr("_", " ")}", *args, **kwargs, &block)
891
+ else
892
+ super
893
+ end
894
+ end
895
+
896
+ def respond_to_missing?(name, include_private = false)
897
+ name.to_s.match?(/\A(be|have)_.+\z/) || super
898
+ end
257
899
  end
258
900
  end