tryouts 2.4.0 → 3.0.0.pre

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,71 +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
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
21
+ end
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
35
+ end
36
+
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
49
+ !failed?
50
+ end
51
+
52
+ def empty?
53
+ @testrun.empty?
54
+ end
55
+
56
+ def size
57
+ @testrun.total_tests
58
+ end
59
+
60
+ def test_cases
61
+ @testrun.test_cases
62
+ end
63
+
64
+ def path
65
+ @testrun.source_file
66
+ end
67
+
68
+ def failed?
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
8
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
9
101
  end
10
- attr_reader :path, :failed, :lines
11
102
 
12
- def initialize(p, l)
13
- @path = p
14
- @lines = l
15
- @container = Container.new.metaclass
16
- @run = false
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
108
+
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)
17
115
  end
18
116
 
19
- def run(before_test, &after_test)
20
- 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
126
+
127
+ # Execute test in same fresh context
128
+ code = test_case.code
129
+ path = test_case.path
130
+ range = test_case.line_range
131
+
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)
138
+ end
21
139
 
22
- setup
23
- ret = self.select do |tc|
24
- before_test.call(tc) unless before_test.nil?
25
- begin
26
- ret = !tc.run # returns true if test failed
27
- rescue StandardError => e
28
- ret = true
29
- $stderr.puts Console.color(:red, "Error in test: #{tc.inspect}")
30
- $stderr.puts Console.color(:red, e.message)
31
- $stderr.puts e.backtrace.join($/), $/
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)
32
147
  end
33
- after_test.call(tc) # runs the tallying code
34
- ret # select failed tests
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
+ }
35
154
  end
155
+ end
36
156
 
37
- @failed = ret.size
38
- @run = true
39
- clean
40
- !failed?
157
+ def evaluate_single_expectation(expectation, actual_result, context, test_case)
158
+ path = test_case.path
159
+ range = test_case.line_range
41
160
 
42
- rescue StandardError => e
43
- @failed = 1
44
- $stderr.puts e.message, e.backtrace.join($/), $/
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
+ }
45
176
  end
46
177
 
47
- def failed?
48
- !@failed.nil? && @failed > 0
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
+ }
49
207
  end
50
208
 
51
- def setup
52
- return if empty?
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)
53
218
 
54
- start = first.desc.nil? ? first.test.first : first.desc.first - 1
55
- Tryouts.eval lines[0..start - 1].join, path, 0 if start > 0
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
56
223
  end
57
224
 
58
- def clean
59
- return if empty?
225
+ # Global setup execution for shared context mode
226
+ def execute_global_setup
227
+ setup = @testrun.setup
60
228
 
61
- last_line = last.exps.last + 1
62
- return unless last_line < lines.size
229
+ if setup && !setup.code.empty? && @options[:shared_context]
230
+ @output_manager&.setup_start(setup.line_range)
63
231
 
64
- Tryouts.eval lines[last_line..-1].join, path, last_line
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}"
65
242
  end
66
243
 
67
- def run?
68
- @run
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)
69
312
  end
70
313
  end
71
314
  end
@@ -1,109 +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
-
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
22
+ Teardown = Data.define(:code, :line_range, :path) do
23
+ def empty?
24
+ code.empty?
69
25
  end
26
+ end
70
27
 
71
- def run?
72
- @has_run.eql?(true)
28
+ Testrun = Data.define(:setup, :test_cases, :teardown, :source_file, :metadata) do
29
+ def total_tests
30
+ test_cases.size
73
31
  end
74
32
 
75
- def skipped?
76
- @test_result == 0
33
+ def empty?
34
+ test_cases.empty?
77
35
  end
36
+ end
78
37
 
79
- def passed?
80
- @test_result == 1
81
- end
82
- def failed?
83
- @test_result == -1
84
- end
38
+ # Enhanced error with context
39
+ class TryoutSyntaxError < StandardError
40
+ attr_reader :line_number, :context, :source_file
85
41
 
86
- def color
87
- case @test_result
88
- when 1
89
- :green
90
- when 0
91
- :white
92
- else
93
- :red
94
- end
95
- end
42
+ def initialize(message, line_number:, context:, source_file: nil)
43
+ @line_number = line_number
44
+ @context = context
45
+ @source_file = source_file
96
46
 
97
- def adjective
98
- case @test_result
99
- when 1
100
- 'PASSED'
101
- when 0
102
- 'SKIPPED'
103
- else
104
- 'FAILED'
105
- end
47
+ location = source_file ? "#{source_file}:#{line_number}" : "line #{line_number}"
48
+ super("#{message} at #{location}: #{context}")
106
49
  end
107
-
108
50
  end
109
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
@@ -0,0 +1,88 @@
1
+ # lib/tryouts/translators/rspec_translator.rb
2
+
3
+ class Tryouts
4
+ module Translators
5
+ class RSpecTranslator
6
+ def initialize
7
+ require 'rspec/core'
8
+ rescue LoadError
9
+ raise 'RSpec gem is required for RSpec translation'
10
+ end
11
+
12
+ def translate(testrun)
13
+ file_basename = File.basename(testrun.source_file, '.rb')
14
+
15
+ RSpec.describe "Tryouts: #{file_basename}" do
16
+ # Setup before all tests
17
+ if testrun.setup && !testrun.setup.empty?
18
+ before(:all) do
19
+ instance_eval(testrun.setup.code)
20
+ end
21
+ end
22
+
23
+ # Generate test cases
24
+ testrun.test_cases.each_with_index do |test_case, _index|
25
+ next if test_case.empty? || !test_case.expectations?
26
+
27
+ it test_case.description do
28
+ result = instance_eval(test_case.code) unless test_case.code.strip.empty?
29
+
30
+ test_case.expectations.each do |expectation|
31
+ expected_value = instance_eval(expectation)
32
+ expect(result).to eq(expected_value)
33
+ end
34
+ end
35
+ end
36
+
37
+ # Teardown after all tests
38
+ if testrun.teardown && !testrun.teardown.empty?
39
+ after(:all) do
40
+ instance_eval(testrun.teardown.code)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def generate_code(testrun)
47
+ file_basename = File.basename(testrun.source_file, '.rb')
48
+ lines = []
49
+
50
+ lines << ''
51
+ lines << "RSpec.describe '#{file_basename}' do"
52
+
53
+ if testrun.setup && !testrun.setup.empty?
54
+ lines << ' before(:all) do'
55
+ testrun.setup.code.lines.each { |line| lines << " #{line.chomp}" }
56
+ lines << ' end'
57
+ lines << ''
58
+ end
59
+
60
+ testrun.test_cases.each_with_index do |test_case, _index|
61
+ next if test_case.empty? || !test_case.expectations?
62
+
63
+ lines << " it '#{test_case.description}' do"
64
+ unless test_case.code.strip.empty?
65
+ lines << ' result = begin'
66
+ test_case.code.lines.each { |line| lines << " #{line.chomp}" }
67
+ lines << ' end'
68
+ end
69
+
70
+ test_case.expectations.each do |expectation|
71
+ lines << " expect(result).to eq(#{expectation})"
72
+ end
73
+ lines << ' end'
74
+ lines << ''
75
+ end
76
+
77
+ if testrun.teardown && !testrun.teardown.empty?
78
+ lines << ' after(:all) do'
79
+ testrun.teardown.code.lines.each { |line| lines << " #{line.chomp}" }
80
+ lines << ' end'
81
+ end
82
+
83
+ lines << 'end'
84
+ lines.join("\n")
85
+ end
86
+ end
87
+ end
88
+ end