tryouts 2.4.1 → 3.0.0.pre2

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.
@@ -1,74 +1,314 @@
1
- # frozen_string_literal: true
2
- #
1
+ # lib/tryouts/testbatch.rb
2
+
3
+ require 'stringio'
4
+
3
5
  class Tryouts
4
- class TestBatch < Array
5
- class Container
6
- def metaclass
7
- class << self; end
8
- end
6
+ # Modern TestBatch using Ruby 3.4+ patterns and formatter system
7
+ class TestBatch
8
+ attr_reader :testrun, :failed_count, :container, :status, :results, :formatter, :output_manager
9
+
10
+ def initialize(testrun, **options)
11
+ @testrun = testrun
12
+ @container = Object.new
13
+ @options = options
14
+ @formatter = Tryouts::CLI::FormatterFactory.create_formatter(options)
15
+ @output_manager = options[:output_manager]
16
+ @global_tally = options[:global_tally]
17
+ @failed_count = 0
18
+ @status = :pending
19
+ @results = []
20
+ @start_time = nil
9
21
  end
10
- attr_reader :path, :failed, :lines
11
-
12
- def initialize(path, lines)
13
- @path = path
14
- @lines = lines
15
- @container = Container.new.metaclass
16
- @run = false
17
- #super
18
- end
19
-
20
- def run(before_test, &after_test)
21
- return if empty?
22
- testcase_score = nil
23
-
24
- setup
25
- failed_tests = self.select do |tc|
26
- before_test.call(tc) unless before_test.nil?
27
- begin
28
- testcase_score = tc.run # returns -1 for failed, 0 for skipped, 1 for passed
29
- rescue StandardError => e
30
- testcase_score = -1
31
- warn Console.color(:red, "Error in test: #{tc.inspect}")
32
- warn Console.color(:red, e.message)
33
- warn e.backtrace.join($/), $/
34
- end
35
- after_test.call(tc) # runs the tallying code
36
- testcase_score.negative? # select failed tests
22
+
23
+ # Main execution pipeline using functional composition
24
+ def run(before_test_hook = nil, &)
25
+ return false if empty?
26
+
27
+ @start_time = Time.now
28
+ @output_manager&.execution_phase(test_cases.size)
29
+ @output_manager&.info("Context: #{@options[:shared_context] ? 'shared' : 'fresh'}", 1)
30
+ @output_manager&.file_start(path, context: @options[:shared_context] ? :shared : :fresh)
31
+
32
+ if shared_context?
33
+ @output_manager&.info('Running global setup...', 2)
34
+ execute_global_setup
37
35
  end
38
36
 
39
- warn Console.color(:red, "Failed tests: #{failed_tests.size}") if Tryouts.debug?
40
- @failed = failed_tests.size
41
- @run = true
42
- clean
37
+ idx = 0
38
+ execution_results = test_cases.map do |test_case|
39
+ @output_manager&.trace("Test #{idx + 1}/#{test_cases.size}: #{test_case.description}", 2)
40
+ idx += 1
41
+ result = execute_single_test(test_case, before_test_hook, &) # runs the test code
42
+ result
43
+ end
44
+
45
+ execute_global_teardown
46
+ finalize_results(execution_results)
47
+
48
+ @status = :completed
43
49
  !failed?
50
+ end
51
+
52
+ def empty?
53
+ @testrun.empty?
54
+ end
55
+
56
+ def size
57
+ @testrun.total_tests
58
+ end
44
59
 
45
- rescue StandardError => e
46
- @failed = 1 # so that failed? returns true
47
- warn e.message, e.backtrace.join($/), $/
60
+ def test_cases
61
+ @testrun.test_cases
62
+ end
63
+
64
+ def path
65
+ @testrun.source_file
48
66
  end
49
67
 
50
68
  def failed?
51
- !@failed.nil? && @failed.positive?
69
+ @failed_count > 0
70
+ end
71
+
72
+ def completed?
73
+ @status == :completed
74
+ end
75
+
76
+ private
77
+
78
+ # Pattern matching for execution strategy selection
79
+ def execute_single_test(test_case, before_test_hook = nil)
80
+ before_test_hook&.call(test_case)
81
+
82
+ # Capture output during test execution
83
+ result = nil
84
+ captured_output = capture_output do
85
+ result = case @options[:shared_context]
86
+ when true
87
+ execute_with_shared_context(test_case)
88
+ when false, nil
89
+ execute_with_fresh_context(test_case)
90
+ else
91
+ raise 'Invalid execution context configuration'
92
+ end
93
+ end
94
+
95
+ # Add captured output to the result
96
+ result[:captured_output] = captured_output if captured_output && !captured_output.empty?
97
+
98
+ process_test_result(result)
99
+ yield(test_case) if block_given?
100
+ result
52
101
  end
53
102
 
54
- def setup
55
- return if empty?
103
+ # Shared context execution - setup runs once, all tests share state
104
+ def execute_with_shared_context(test_case)
105
+ code = test_case.code
106
+ path = test_case.path
107
+ range = test_case.line_range
56
108
 
57
- start = first.desc.nil? ? first.test.first : first.desc.first - 1
58
- Tryouts.eval lines[0..start - 1].join, path, 0 if start.positive?
109
+ result_value = @container.instance_eval(code, path, range.first + 1)
110
+ expectations_result = evaluate_expectations(test_case, result_value, @container)
111
+
112
+ build_test_result(test_case, result_value, expectations_result)
113
+ rescue StandardError => ex
114
+ build_error_result(test_case, ex.message, ex)
59
115
  end
60
116
 
61
- def clean
62
- return if empty?
117
+ # Fresh context execution - setup runs per test, isolated state
118
+ def execute_with_fresh_context(test_case)
119
+ fresh_container = Object.new
120
+
121
+ # Execute setup in fresh context if present
122
+ setup = @testrun.setup
123
+ if setup && !setup.code.empty?
124
+ fresh_container.instance_eval(setup.code, setup.path, 1)
125
+ end
63
126
 
64
- last_line = last.exps.last + 1
65
- return unless last_line < lines.size
127
+ # Execute test in same fresh context
128
+ code = test_case.code
129
+ path = test_case.path
130
+ range = test_case.line_range
66
131
 
67
- Tryouts.eval lines[last_line..-1].join, path, last_line
132
+ result_value = fresh_container.instance_eval(code, path, range.first + 1)
133
+ expectations_result = evaluate_expectations(test_case, result_value, fresh_container)
134
+
135
+ build_test_result(test_case, result_value, expectations_result)
136
+ rescue StandardError => ex
137
+ build_error_result(test_case, ex.message, ex)
68
138
  end
69
139
 
70
- def run?
71
- @run
140
+ # Evaluate expectations using pattern matching for clean result handling
141
+ def evaluate_expectations(test_case, actual_result, context)
142
+ if test_case.expectations.empty?
143
+ { passed: true, actual_results: [], expected_results: [] }
144
+ else
145
+ evaluation_results = test_case.expectations.map do |expectation|
146
+ evaluate_single_expectation(expectation, actual_result, context, test_case)
147
+ end
148
+
149
+ {
150
+ passed: evaluation_results.all? { |r| r[:passed] },
151
+ actual_results: evaluation_results.map { |r| r[:actual] },
152
+ expected_results: evaluation_results.map { |r| r[:expected] },
153
+ }
154
+ end
155
+ end
156
+
157
+ def evaluate_single_expectation(expectation, actual_result, context, test_case)
158
+ path = test_case.path
159
+ range = test_case.line_range
160
+
161
+ expected_value = context.instance_eval(expectation, path, range.first + 1)
162
+
163
+ {
164
+ passed: actual_result == expected_value,
165
+ actual: actual_result,
166
+ expected: expected_value,
167
+ expectation: expectation,
168
+ }
169
+ rescue StandardError => ex
170
+ {
171
+ passed: false,
172
+ actual: actual_result,
173
+ expected: "EXPECTED: #{ex.message}",
174
+ expectation: expectation,
175
+ }
176
+ end
177
+
178
+ # Build structured test results using pattern matching
179
+ def build_test_result(test_case, result_value, expectations_result)
180
+ if expectations_result[:passed]
181
+ {
182
+ test_case: test_case,
183
+ status: :passed,
184
+ result_value: result_value,
185
+ actual_results: expectations_result[:actual_results],
186
+ error: nil,
187
+ }
188
+ else
189
+ {
190
+ test_case: test_case,
191
+ status: :failed,
192
+ result_value: result_value,
193
+ actual_results: expectations_result[:actual_results],
194
+ error: nil,
195
+ }
196
+ end
197
+ end
198
+
199
+ def build_error_result(test_case, message, exception = nil)
200
+ {
201
+ test_case: test_case,
202
+ status: :error,
203
+ result_value: nil,
204
+ actual_results: ["ACTUAL: #{message}"],
205
+ error: exception,
206
+ }
207
+ end
208
+
209
+ # Process and display test results using formatter
210
+ def process_test_result(result)
211
+ @results << result
212
+
213
+ if [:failed, :error].include?(result[:status])
214
+ @failed_count += 1
215
+ end
216
+
217
+ show_test_result(result)
218
+
219
+ # Show captured output if any exists
220
+ if result[:captured_output] && !result[:captured_output].empty?
221
+ @output_manager&.test_output(result[:test_case], result[:captured_output])
222
+ end
223
+ end
224
+
225
+ # Global setup execution for shared context mode
226
+ def execute_global_setup
227
+ setup = @testrun.setup
228
+
229
+ if setup && !setup.code.empty? && @options[:shared_context]
230
+ @output_manager&.setup_start(setup.line_range)
231
+
232
+ # Capture setup output instead of letting it print directly
233
+ captured_output = capture_output do
234
+ @container.instance_eval(setup.code, setup.path, setup.line_range.first + 1)
235
+ end
236
+
237
+ @output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
238
+ end
239
+ rescue StandardError => ex
240
+ @global_tally[:total_errors] += 1 if @global_tally
241
+ raise "Global setup failed: #{ex.message}"
242
+ end
243
+
244
+ # Global teardown execution
245
+ def execute_global_teardown
246
+ teardown = @testrun.teardown
247
+
248
+ if teardown && !teardown.code.empty?
249
+ @output_manager&.teardown_start(teardown.line_range)
250
+
251
+ # Capture teardown output instead of letting it print directly
252
+ captured_output = capture_output do
253
+ @container.instance_eval(teardown.code, teardown.path, teardown.line_range.first + 1)
254
+ end
255
+
256
+ @output_manager&.teardown_output(captured_output) if captured_output && !captured_output.empty?
257
+ end
258
+ rescue StandardError => ex
259
+ @global_tally[:total_errors] += 1 if @global_tally
260
+ @output_manager&.error("Teardown failed: #{ex.message}")
261
+ end
262
+
263
+ # Result finalization and summary display
264
+ def finalize_results(_execution_results)
265
+ @status = :completed
266
+ elapsed_time = Time.now - @start_time
267
+ show_summary(elapsed_time)
268
+ end
269
+
270
+
271
+ def show_test_result(result)
272
+ test_case = result[:test_case]
273
+ status = result[:status]
274
+ actuals = result[:actual_results]
275
+
276
+ @output_manager&.test_result(test_case, status, actuals)
277
+ end
278
+
279
+ def show_summary(elapsed_time)
280
+ @output_manager&.batch_summary(size, @failed_count, elapsed_time)
281
+ end
282
+
283
+ # Helper methods using pattern matching
284
+
285
+ def shared_context?
286
+ @options[:shared_context] == true
287
+ end
288
+
289
+ def capture_output
290
+ old_stdout = $stdout
291
+ old_stderr = $stderr
292
+ $stdout = StringIO.new
293
+ $stderr = StringIO.new
294
+
295
+ yield
296
+
297
+ captured = $stdout.string + $stderr.string
298
+ captured.empty? ? nil : captured
299
+ ensure
300
+ $stdout = old_stdout
301
+ $stderr = old_stderr
302
+ end
303
+
304
+ def handle_batch_error(exception)
305
+ @status = :error
306
+ @failed_count = 1
307
+
308
+ error_message = "Batch execution failed: #{exception.message}"
309
+ backtrace = exception.respond_to?(:backtrace) ? exception.backtrace : nil
310
+
311
+ @output_manager&.error(error_message, backtrace)
72
312
  end
73
313
  end
74
314
  end
@@ -1,110 +1,51 @@
1
- # frozen_string_literal: true
1
+ # lib/tryouts/testcase.rb
2
2
 
3
+ # Modern data structures using Ruby 3.2+ Data classes
3
4
  class Tryouts
4
- class TestCase
5
- attr_reader :desc, :test, :exps, :path, :testrunner_output, :test_result, :console_output
6
-
7
- def initialize(d, t, e)
8
- @desc, @test, @exps, @path = d, t, e
9
- @testrunner_output = []
10
- @console_output = StringIO.new
5
+ # Core data structures
6
+ TestCase = Data.define(:description, :code, :expectations, :line_range, :path, :source_lines) do
7
+ def empty?
8
+ code.empty?
11
9
  end
12
10
 
13
- def inspect
14
- [@desc.inspect, @test.inspect, @exps.inspect].join
11
+ def expectations?
12
+ !expectations.empty?
15
13
  end
14
+ end
16
15
 
17
- def to_s
18
- [@desc.to_s, @test.to_s, @exps.to_s].join
16
+ Setup = Data.define(:code, :line_range, :path) do
17
+ def empty?
18
+ code.empty?
19
19
  end
20
+ end
20
21
 
21
- def run
22
- Tryouts.debug format('%s:%d', @test.path, @test.first)
23
- Tryouts.debug inspect, $/
24
-
25
- $stdout = @console_output
26
- expectations = exps.collect do |exp, _idx|
27
- exp =~ /\A\#?\s*=>\s*(.+)\Z/
28
- ::Regexp.last_match(1) # this will be nil if the expectation is commented out
29
- end
30
-
31
- Tryouts.info 'Capturing STDOUT for tryout'
32
- Tryouts.info 'vvvvvvvvvvvvvvvvvvv'
33
- # Evaluate test block only if there are valid expectations
34
- unless expectations.compact.empty? # TODO: fast-fail if no expectations
35
- test_value = Tryouts.eval @test.to_s, @test.path, @test.first
36
- @has_run = true
37
- end
38
- Tryouts.info '^^^^^^^^^^^^^^^^^^^'
39
-
40
- Tryouts.info "Capturing STDOUT for expectations"
41
- Tryouts.info 'vvvvvvvvvvvvvvvvvvv'
42
- expectations.each_with_index do |exp, idx|
43
- if exp.nil?
44
- @testrunner_output << ' [skipped]'
45
- @test_result = 0
46
- else
47
- # Evaluate expectation
48
-
49
- exp_value = Tryouts.eval(exp, @exps.path, @exps.first + idx)
50
-
51
- test_passed = test_value.eql?(exp_value)
52
- @test_result = test_passed ? 1 : -1
53
- @testrunner_output << test_value.inspect
54
- end
55
- end
56
- Tryouts.info '^^^^^^^^^^^^^^^^^^^'
57
- $stdout = STDOUT # restore stdout
58
-
59
- Tryouts.debug # extra newline
60
- failed?
61
-
62
- @test_result
63
- rescue StandardError => e
64
- Tryouts.debug "[testcaste.run] #{e.message}", e.backtrace.join($/), $/
65
- # Continue raising the exception
66
- raise e
67
- ensure
68
- $stdout = STDOUT # restore stdout
69
- @test_result
22
+ Teardown = Data.define(:code, :line_range, :path) do
23
+ def empty?
24
+ code.empty?
70
25
  end
26
+ end
71
27
 
72
- def run?
73
- @has_run.eql?(true)
28
+ Testrun = Data.define(:setup, :test_cases, :teardown, :source_file, :metadata) do
29
+ def total_tests
30
+ test_cases.size
74
31
  end
75
32
 
76
- def skipped?
77
- @test_result == 0
33
+ def empty?
34
+ test_cases.empty?
78
35
  end
36
+ end
79
37
 
80
- def passed?
81
- @test_result == 1
82
- end
83
- def failed?
84
- @test_result == -1
85
- end
38
+ # Enhanced error with context
39
+ class TryoutSyntaxError < StandardError
40
+ attr_reader :line_number, :context, :source_file
86
41
 
87
- def color
88
- case @test_result
89
- when 1
90
- :green
91
- when 0
92
- :white
93
- else
94
- :red
95
- end
96
- end
42
+ def initialize(message, line_number:, context:, source_file: nil)
43
+ @line_number = line_number
44
+ @context = context
45
+ @source_file = source_file
97
46
 
98
- def adjective
99
- case @test_result
100
- when 1
101
- 'PASSED'
102
- when 0
103
- 'SKIPPED'
104
- else
105
- 'FAILED'
106
- end
47
+ location = source_file ? "#{source_file}:#{line_number}" : "line #{line_number}"
48
+ super("#{message} at #{location}: #{context}")
107
49
  end
108
-
109
50
  end
110
51
  end
@@ -0,0 +1,106 @@
1
+ # lib/tryouts/translators/minitest_translator.rb
2
+
3
+ class Tryouts
4
+ module Translators
5
+ class MinitestTranslator
6
+ def initialize
7
+ require 'minitest/test'
8
+ rescue LoadError
9
+ raise 'Minitest gem is required for Minitest translation'
10
+ end
11
+
12
+ def translate(testrun)
13
+ file_basename = File.basename(testrun.source_file, '.rb')
14
+ class_name = "Test#{file_basename.gsub(/[^A-Za-z0-9]/, '')}"
15
+
16
+ test_class = Class.new(Minitest::Test) do
17
+ # Setup method
18
+ if testrun.setup && !testrun.setup.empty?
19
+ define_method(:setup) do
20
+ instance_eval(testrun.setup.code)
21
+ end
22
+ end
23
+
24
+ # Generate test methods
25
+ testrun.test_cases.each_with_index do |test_case, index|
26
+ next if test_case.empty? || !test_case.expectations?
27
+
28
+ method_name = "test_#{index.to_s.rjust(3, '0')}_#{parameterize(test_case.description)}"
29
+ define_method(method_name) do
30
+ result = instance_eval(test_case.code) unless test_case.code.strip.empty?
31
+
32
+ test_case.expectations.each do |expectation|
33
+ expected_value = instance_eval(expectation)
34
+ assert_equal expected_value, result
35
+ end
36
+ end
37
+ end
38
+
39
+ # Teardown method
40
+ if testrun.teardown && !testrun.teardown.empty?
41
+ define_method(:teardown) do
42
+ instance_eval(testrun.teardown.code)
43
+ end
44
+ end
45
+ end
46
+
47
+ # Set the class name dynamically
48
+ Object.const_set(class_name, test_class) unless Object.const_defined?(class_name)
49
+ test_class
50
+ end
51
+
52
+ def generate_code(testrun)
53
+ file_basename = File.basename(testrun.source_file, '.rb')
54
+ class_name = "Test#{file_basename.gsub(/[^A-Za-z0-9]/, '')}"
55
+ lines = []
56
+
57
+ lines << ''
58
+ lines << "require 'minitest/test'"
59
+ lines << "require 'minitest/autorun'"
60
+ lines << ''
61
+ lines << "class #{class_name} < Minitest::Test"
62
+
63
+ if testrun.setup && !testrun.setup.empty?
64
+ lines << ' def setup'
65
+ testrun.setup.code.lines.each { |line| lines << " #{line.chomp}" }
66
+ lines << ' end'
67
+ lines << ''
68
+ end
69
+
70
+ testrun.test_cases.each_with_index do |test_case, index|
71
+ next if test_case.empty? || !test_case.expectations?
72
+
73
+ method_name = "test_#{index.to_s.rjust(3, '0')}_#{parameterize(test_case.description)}"
74
+ lines << " def #{method_name}"
75
+ unless test_case.code.strip.empty?
76
+ lines << ' result = begin'
77
+ test_case.code.lines.each { |line| lines << " #{line.chomp}" }
78
+ lines << ' end'
79
+ end
80
+
81
+ test_case.expectations.each do |expectation|
82
+ lines << " assert_equal #{expectation}, result"
83
+ end
84
+ lines << ' end'
85
+ lines << ''
86
+ end
87
+
88
+ if testrun.teardown && !testrun.teardown.empty?
89
+ lines << ' def teardown'
90
+ testrun.teardown.code.lines.each { |line| lines << " #{line.chomp}" }
91
+ lines << ' end'
92
+ end
93
+
94
+ lines << 'end'
95
+ lines.join("\n")
96
+ end
97
+
98
+ private
99
+
100
+ # Simple string parameterization for method names
101
+ def parameterize(string)
102
+ string.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_|_$/, '')
103
+ end
104
+ end
105
+ end
106
+ end