summaryrefslogtreecommitdiff
path: root/spec/support/cycle_analytics_helpers/test_generation.rb
blob: 19b32c84d818650281a978613502aa6cfa40cd53 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# rubocop:disable Metrics/AbcSize

# Note: The ABC size is large here because we have a method generating test cases with
#       multiple nested contexts. This shouldn't count as a violation.
module CycleAnalyticsHelpers
  module TestGeneration
    # Generate the most common set of specs that all cycle analytics phases need to have.
    #
    # Arguments:
    #
    #                  phase: Which phase are we testing? Will call `CycleAnalytics.new.send(phase)` for the final assertion
    #                data_fn: A function that returns a hash, constituting initial data for the test case
    #  start_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with
    #                         `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`).
    #                         Each `condition_fn` is expected to implement a case which consitutes the start of the given cycle analytics phase.
    #    end_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with
    #                         `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`).
    #                         Each `condition_fn` is expected to implement a case which consitutes the end of the given cycle analytics phase.
    #          before_end_fn: This function is run before calling the end time conditions. Used for setup that needs to be run between the start and end conditions.
    #                post_fn: Code that needs to be run after running the end time conditions.

    def generate_cycle_analytics_spec(phase:, data_fn:, start_time_conditions:, end_time_conditions:, before_end_fn: nil, post_fn: nil)
      combinations_of_start_time_conditions = (1..start_time_conditions.size).flat_map { |size| start_time_conditions.combination(size).to_a }
      combinations_of_end_time_conditions = (1..end_time_conditions.size).flat_map { |size| end_time_conditions.combination(size).to_a }

      scenarios = combinations_of_start_time_conditions.product(combinations_of_end_time_conditions)
      scenarios.each do |start_time_conditions, end_time_conditions|
        context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do
          context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do
            it "finds the median of available durations between the two conditions" do
              time_differences = Array.new(5) do |index|
                data = data_fn[self]
                start_time = (index * 10).days.from_now
                end_time = start_time + rand(1..5).days

                start_time_conditions.each do |condition_name, condition_fn|
                  Timecop.freeze(start_time) { condition_fn[self, data] }
                end

                # Run `before_end_fn` at the midpoint between `start_time` and `end_time`
                Timecop.freeze(start_time + (end_time - start_time) / 2) { before_end_fn[self, data] } if before_end_fn

                end_time_conditions.each do |condition_name, condition_fn|
                  Timecop.freeze(end_time) { condition_fn[self, data] }
                end

                Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn

                end_time - start_time
              end

              median_time_difference = time_differences.sort[2]
              expect(subject[phase].median).to be_within(5).of(median_time_difference)
            end

            context "when the data belongs to another project" do
              let(:other_project) { create(:project, :repository) }

              it "returns nil" do
                # Use a stub to "trick" the data/condition functions
                # into using another project. This saves us from having to
                # define separate data/condition functions for this particular
                # test case.
                allow(self).to receive(:project) { other_project }

                data = data_fn[self]
                start_time = Time.now
                end_time = rand(1..10).days.from_now

                start_time_conditions.each do |condition_name, condition_fn|
                  Timecop.freeze(start_time) { condition_fn[self, data] }
                end

                end_time_conditions.each do |condition_name, condition_fn|
                  Timecop.freeze(end_time) { condition_fn[self, data] }
                end

                Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn

                # Turn off the stub before checking assertions
                allow(self).to receive(:project).and_call_original

                expect(subject[phase].median).to be_nil
              end
            end

            context "when the end condition happens before the start condition" do
              it 'returns nil' do
                data = data_fn[self]
                start_time = Time.now
                end_time = start_time + rand(1..5).days

                # Run `before_end_fn` at the midpoint between `start_time` and `end_time`
                Timecop.freeze(start_time + (end_time - start_time) / 2) { before_end_fn[self, data] } if before_end_fn

                end_time_conditions.each do |condition_name, condition_fn|
                  Timecop.freeze(start_time) { condition_fn[self, data] }
                end

                start_time_conditions.each do |condition_name, condition_fn|
                  Timecop.freeze(end_time) { condition_fn[self, data] }
                end

                Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn

                expect(subject[phase].median).to be_nil
              end
            end
          end
        end

        context "start condition NOT PRESENT: #{start_time_conditions.map(&:first).to_sentence}" do
          context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do
            it "returns nil" do
              data = data_fn[self]
              end_time = rand(1..10).days.from_now

              end_time_conditions.each_with_index do |(condition_name, condition_fn), index|
                Timecop.freeze(end_time + index.days) { condition_fn[self, data] }
              end

              Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn

              expect(subject[phase].median).to be_nil
            end
          end
        end

        context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do
          context "end condition NOT PRESENT: #{end_time_conditions.map(&:first).to_sentence}" do
            it "returns nil" do
              data = data_fn[self]
              start_time = Time.now

              start_time_conditions.each do |condition_name, condition_fn|
                Timecop.freeze(start_time) { condition_fn[self, data] }
              end

              post_fn[self, data] if post_fn

              expect(subject[phase].median).to be_nil
            end
          end
        end
      end

      context "when none of the start / end conditions are matched" do
        it "returns nil" do
          expect(subject[phase].median).to be_nil
        end
      end
    end
  end
end