tryouts 3.7.0 → 4.0.0.pre1

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -2
  3. data/lib/tryouts/cli/formatters/agent.rb +12 -16
  4. data/lib/tryouts/cli/formatters/base.rb +2 -0
  5. data/lib/tryouts/cli/formatters/compact.rb +2 -0
  6. data/lib/tryouts/cli/formatters/factory.rb +2 -0
  7. data/lib/tryouts/cli/formatters/live_status_manager.rb +2 -0
  8. data/lib/tryouts/cli/formatters/output_manager.rb +2 -0
  9. data/lib/tryouts/cli/formatters/quiet.rb +2 -0
  10. data/lib/tryouts/cli/formatters/test_run_state.rb +2 -0
  11. data/lib/tryouts/cli/formatters/token_budget.rb +2 -0
  12. data/lib/tryouts/cli/formatters/tty_status_display.rb +2 -0
  13. data/lib/tryouts/cli/formatters/verbose.rb +2 -0
  14. data/lib/tryouts/cli/formatters.rb +2 -0
  15. data/lib/tryouts/cli/line_spec_parser.rb +2 -0
  16. data/lib/tryouts/cli/modes/generate.rb +2 -0
  17. data/lib/tryouts/cli/modes/inspect.rb +15 -3
  18. data/lib/tryouts/cli/opts.rb +8 -6
  19. data/lib/tryouts/cli/tty_detector.rb +2 -0
  20. data/lib/tryouts/cli.rb +2 -0
  21. data/lib/tryouts/console.rb +2 -0
  22. data/lib/tryouts/expectation_evaluators/base.rb +2 -0
  23. data/lib/tryouts/expectation_evaluators/boolean.rb +2 -0
  24. data/lib/tryouts/expectation_evaluators/exception.rb +2 -0
  25. data/lib/tryouts/expectation_evaluators/expectation_result.rb +2 -0
  26. data/lib/tryouts/expectation_evaluators/false.rb +2 -0
  27. data/lib/tryouts/expectation_evaluators/intentional_failure.rb +2 -0
  28. data/lib/tryouts/expectation_evaluators/non_nil.rb +2 -0
  29. data/lib/tryouts/expectation_evaluators/output.rb +2 -0
  30. data/lib/tryouts/expectation_evaluators/performance_time.rb +2 -0
  31. data/lib/tryouts/expectation_evaluators/regex_match.rb +2 -0
  32. data/lib/tryouts/expectation_evaluators/registry.rb +2 -0
  33. data/lib/tryouts/expectation_evaluators/regular.rb +2 -0
  34. data/lib/tryouts/expectation_evaluators/result_type.rb +2 -0
  35. data/lib/tryouts/expectation_evaluators/true.rb +2 -0
  36. data/lib/tryouts/failure_collector.rb +2 -0
  37. data/lib/tryouts/file_processor.rb +3 -4
  38. data/lib/tryouts/parser_warning.rb +2 -0
  39. data/lib/tryouts/parsers/base_parser.rb +3 -3
  40. data/lib/tryouts/parsers/enhanced_parser.rb +57 -3
  41. data/lib/tryouts/parsers/shared_methods.rb +67 -17
  42. data/lib/tryouts/test_batch.rb +155 -43
  43. data/lib/tryouts/test_case.rb +54 -6
  44. data/lib/tryouts/test_executor.rb +2 -0
  45. data/lib/tryouts/test_result_aggregator.rb +2 -0
  46. data/lib/tryouts/test_runner.rb +2 -1
  47. data/lib/tryouts/translators/minitest_translator.rb +36 -12
  48. data/lib/tryouts/translators/rspec_translator.rb +19 -0
  49. data/lib/tryouts/version.rb +3 -1
  50. data/lib/tryouts.rb +2 -1
  51. metadata +1 -2
  52. data/lib/tryouts/parsers/legacy_parser.rb +0 -254
@@ -1,4 +1,6 @@
1
1
  # lib/tryouts/parsers/shared_methods.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require_relative '../parser_warning'
4
6
 
@@ -208,12 +210,21 @@ class Tryouts
208
210
  # Process classified test blocks into domain objects
209
211
  def process_test_blocks(classified_blocks)
210
212
  setup_blocks = classified_blocks.filter { |block| block[:type] == :setup }
211
- test_blocks = classified_blocks.filter { |block| block[:type] == :test }
212
213
  teardown_blocks = classified_blocks.filter { |block| block[:type] == :teardown }
213
214
 
215
+ # Test cases and orphan blocks stay interleaved in source order: the
216
+ # executor runs them sequentially and an orphan executed out of position
217
+ # would corrupt the shared-context local-variable timeline.
218
+ test_cases = classified_blocks.filter_map do |block|
219
+ case block[:type]
220
+ when :test then build_test_case(block)
221
+ when :orphan then build_orphan_block(block)
222
+ end
223
+ end
224
+
214
225
  testrun = Testrun.new(
215
226
  setup: build_setup(setup_blocks),
216
- test_cases: test_blocks.map { |block| build_test_case(block) },
227
+ test_cases: test_cases,
217
228
  teardown: build_teardown(teardown_blocks),
218
229
  source_file: @source_path,
219
230
  metadata: { parsed_at: @parsed_at, parser: parser_type },
@@ -233,6 +244,7 @@ class Tryouts
233
244
  code: extract_pure_code_from_blocks(setup_blocks),
234
245
  line_range: calculate_block_range(setup_blocks),
235
246
  path: @source_path,
247
+ first_code_line: first_code_line(collect_code_tokens(setup_blocks)),
236
248
  )
237
249
  end
238
250
 
@@ -243,22 +255,29 @@ class Tryouts
243
255
  code: extract_pure_code_from_blocks(teardown_blocks),
244
256
  line_range: calculate_block_range(teardown_blocks),
245
257
  path: @source_path,
258
+ first_code_line: first_code_line(collect_code_tokens(teardown_blocks)),
259
+ )
260
+ end
261
+
262
+ # Orphan blocks (code with no description and no expectations, sandwiched
263
+ # between real test cases) are collected for execution instead of being
264
+ # silently dropped. Comment-only blocks carry no executable code and
265
+ # produce nothing.
266
+ def build_orphan_block(block)
267
+ code = extract_pure_code_from_blocks([block])
268
+ return nil if code.strip.empty?
269
+
270
+ OrphanBlock.new(
271
+ code: code,
272
+ line_range: calculate_block_range([block]),
273
+ path: @source_path,
274
+ first_code_line: first_code_line(collect_code_tokens([block])),
246
275
  )
247
276
  end
248
277
 
249
278
  # Modern Ruby 3.4+ pattern matching for robust code extraction
250
279
  def extract_pure_code_from_blocks(blocks)
251
- blocks
252
- .flat_map { |block| block[:code] }
253
- .filter_map do |token|
254
- case token
255
- in { type: :code, content: String => content }
256
- content
257
- else
258
- nil
259
- end
260
- end
261
- .join("\n")
280
+ extract_code_content(collect_code_tokens(blocks))
262
281
  end
263
282
 
264
283
  def calculate_block_range(blocks)
@@ -271,17 +290,47 @@ class Tryouts
271
290
  line_ranges.first.first..line_ranges.last.last
272
291
  end
273
292
 
293
+ # Code keeps the spacing it had in the source: every comment or blank line
294
+ # between two statements becomes an empty line here. Paired with
295
+ # `first_code_line` as the eval start line, that makes each statement in a
296
+ # multi-line block report its true source location in a backtrace. Joining
297
+ # the code lines alone would collapse the gaps and drift by however many
298
+ # lines were dropped.
274
299
  def extract_code_content(code_tokens)
300
+ positioned = positioned_code_tokens(code_tokens)
301
+ return '' if positioned.empty?
302
+
303
+ base = positioned.first.first
304
+ lines = []
305
+
306
+ positioned.each { |line, content| lines[line - base] = content }
307
+ lines.map { |content| content || '' }.join("\n")
308
+ end
309
+
310
+ # 0-based source line of the block's first executable line, which is not
311
+ # the same as its start_line: a block opens at its description or leading
312
+ # comment, and only the code lines are ever evaluated.
313
+ def first_code_line(code_tokens)
314
+ positioned_code_tokens(code_tokens).first&.first
315
+ end
316
+
317
+ def collect_code_tokens(blocks)
318
+ blocks.flat_map { |block| block[:code] }
319
+ end
320
+
321
+ # [line, content] pairs for executable lines only, in source order. The
322
+ # tokenizer emits at most one :code token per physical line.
323
+ def positioned_code_tokens(code_tokens)
275
324
  code_tokens
276
325
  .filter_map do |token|
277
326
  case token
278
- in { type: :code, content: String => content }
279
- content
327
+ in { type: :code, content: String => content, line: Integer => line }
328
+ [line, content]
280
329
  else
281
330
  nil
282
331
  end
283
332
  end
284
- .join("\n")
333
+ .sort_by(&:first)
285
334
  end
286
335
 
287
336
  def parse_ruby_line(line)
@@ -342,7 +391,7 @@ class Tryouts
342
391
  in { expectations: Array => exps } if !exps.empty?
343
392
  :test
344
393
  else
345
- :preamble
394
+ :orphan
346
395
  end
347
396
 
348
397
  block.merge(type: block_type, end_line: calculate_end_line(block))
@@ -375,6 +424,7 @@ class Tryouts
375
424
  TestCase.new(
376
425
  description: desc,
377
426
  code: extract_code_content(code_tokens),
427
+ first_code_line: first_code_line(code_tokens),
378
428
  expectations: exp_tokens.map do |token|
379
429
  type = case token[:type]
380
430
  when :exception_expectation then :exception
@@ -1,5 +1,8 @@
1
- # lib/tryouts/testbatch.rb
1
+ # lib/tryouts/test_batch.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
5
+ require 'monitor'
3
6
  require 'stringio'
4
7
  require_relative 'expectation_evaluators/registry'
5
8
 
@@ -22,6 +25,17 @@ class Tryouts
22
25
 
23
26
  # Modern TestBatch using Ruby 3.4+ patterns and formatter system
24
27
  class TestBatch
28
+ # $stdout/$stderr are process-global, not Fiber- or thread-local. Under
29
+ # --parallel (Concurrent::ThreadPoolExecutor), concurrent redirects would
30
+ # clobber each other and could leave $stdout pointing at a dead StringIO
31
+ # process-wide. Every redirect path (capture_output for setup/teardown and
32
+ # each test, plus execute_with_output_capture) synchronizes on this lock so
33
+ # the redirect/execute/restore window is serialized across threads.
34
+ #
35
+ # A Monitor (reentrant) is required because execute_with_output_capture runs
36
+ # nested inside capture_output; a plain Mutex would self-deadlock.
37
+ OUTPUT_CAPTURE_MONITOR = Monitor.new
38
+
25
39
  attr_reader :testrun, :failed_count, :container, :status, :results, :formatter, :output_manager
26
40
 
27
41
  def initialize(testrun, **options)
@@ -37,6 +51,12 @@ class Tryouts
37
51
  @start_time = nil
38
52
  @test_case_count = 0
39
53
  @setup_failed = false
54
+ @orphan_failed = false
55
+
56
+ # Shared-context mode evaluates every block against this one reused
57
+ # Binding so local variables persist across blocks like a plain Ruby
58
+ # script. Fresh-context mode keeps per-container instance_eval isolation.
59
+ @binding = options[:shared_context] ? acquire_container_binding(@container) : nil
40
60
  @line_spec = options[:line_spec] # For output filtering only
41
61
 
42
62
  # Setup container for fresh context mode - preserves @instance_variables from setup
@@ -60,7 +80,7 @@ class Tryouts
60
80
  return false if empty?
61
81
 
62
82
  @start_time = Time.now
63
- @test_case_count = test_cases.size
83
+ @test_case_count = @testrun.total_tests
64
84
 
65
85
  @output_manager&.execution_phase(@test_case_count)
66
86
  @output_manager&.info("Context: #{@options[:shared_context] ? 'shared' : 'fresh'}", 1)
@@ -93,6 +113,16 @@ class Tryouts
93
113
 
94
114
  idx = 0
95
115
  execution_results = test_cases.map do |test_case|
116
+ # Orphan blocks run for side effects only, in source order. A raise
117
+ # aborts the batch like a setup failure: every subsequent test's
118
+ # context is suspect.
119
+ if test_case.is_a?(OrphanBlock)
120
+ execute_orphan_block(test_case)
121
+ break if @orphan_failed
122
+
123
+ next
124
+ end
125
+
96
126
  @output_manager&.trace("Test #{idx + 1}/#{@test_case_count}: #{test_case.description}", 2)
97
127
  idx += 1
98
128
 
@@ -132,6 +162,14 @@ class Tryouts
132
162
  error_result
133
163
  end
134
164
 
165
+ if @orphan_failed
166
+ @output_manager&.error('Stopping batch execution due to orphan code block failure')
167
+ @status = :failed
168
+ @failed_count += 1
169
+ finalize_results(execution_results || [])
170
+ return false
171
+ end
172
+
135
173
  # Used for a separate purpose then execution_phase.
136
174
  # e.g. the quiet formatter prints a newline after all test dots
137
175
  @output_manager&.file_end(path, context: @options[:shared_context] ? :shared : :fresh)
@@ -171,6 +209,29 @@ class Tryouts
171
209
 
172
210
  private
173
211
 
212
+ # Must be its own method, never inlined at a call site, and must use
213
+ # instance_eval on a STRING, not a block. A block literal fixes
214
+ # Module.nesting to wherever it is lexically written ([Tryouts::TestBatch]
215
+ # here), which would make every container's `class Foo` definitions land in
216
+ # one shared namespace across files. instance_eval(string) instead sets the
217
+ # default definee to the receiver's own singleton class, preserving the
218
+ # per-container class/def isolation that instance_eval(code_string) gives.
219
+ def acquire_container_binding(container)
220
+ container.instance_eval('binding', __FILE__, __LINE__)
221
+ end
222
+
223
+ # Evaluate a block of test code against the given container. Shared-context
224
+ # mode reuses the one Binding acquired at initialization; each block is
225
+ # still its own eval call (individually rescuable and timeoutable), only
226
+ # the state that persists between calls changes.
227
+ def eval_code(container, code, path, line)
228
+ if @binding && container.equal?(@container)
229
+ @binding.eval(code, path, line)
230
+ else
231
+ container.instance_eval(code, path, line)
232
+ end
233
+ end
234
+
174
235
  # Pattern matching for execution strategy selection
175
236
  def execute_single_test(test_case, before_test_hook = nil)
176
237
  before_test_hook&.call(test_case)
@@ -244,8 +305,7 @@ class Tryouts
244
305
  begin
245
306
  code = test_case.code
246
307
  path = test_case.path
247
- range = test_case.line_range
248
- container.instance_eval(code, path, range.first + 1)
308
+ eval_code(container, code, path, test_case.eval_start_line)
249
309
  rescue SystemStackError, NoMemoryError, SecurityError, ScriptError => ex
250
310
  # Handle system-level exceptions that don't inherit from StandardError
251
311
  # ScriptError includes: LoadError, SyntaxError, NotImplementedError
@@ -260,9 +320,9 @@ class Tryouts
260
320
  build_test_result(test_case, caught_exception, expectations_result)
261
321
  else
262
322
  # Regular execution for non-exception tests with timing and output capture
263
- code = test_case.code
264
- path = test_case.path
265
- range = test_case.line_range
323
+ code = test_case.code
324
+ path = test_case.path
325
+ start_line = test_case.eval_start_line
266
326
 
267
327
  # Check if we need output capture for any expectations
268
328
  needs_output_capture = test_case.expectations.any?(&:output?)
@@ -270,9 +330,9 @@ class Tryouts
270
330
  result_value, _, _, _, expectations_result =
271
331
  execute_with_timeout(test_timeout, test_case) do
272
332
  if needs_output_capture
273
- # Execute with output capture using Fiber-local isolation
333
+ # Execute with output capture (serialized process-global redirect)
274
334
  result_value, execution_time_ns, stdout_content, stderr_content =
275
- execute_with_output_capture(container, code, path, range)
335
+ execute_with_output_capture(container, code, path, start_line)
276
336
 
277
337
  expectations_result = evaluate_expectations(
278
338
  test_case, result_value, container, execution_time_ns, stdout_content, stderr_content
@@ -281,7 +341,7 @@ class Tryouts
281
341
  else
282
342
  # Regular execution with timing capture only
283
343
  execution_start_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
284
- result_value = container.instance_eval(code, path, range.first + 1)
344
+ result_value = eval_code(container, code, path, test_case.eval_start_line)
285
345
  execution_end_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
286
346
  execution_time_ns = execution_end_ns - execution_start_ns
287
347
 
@@ -306,37 +366,37 @@ class Tryouts
306
366
  build_error_result(test_case, StandardError.new("Test terminated by #{ex.class}: #{ex.message}"))
307
367
  end
308
368
 
309
- # Execute test code with Fiber-based stdout/stderr capture
310
- def execute_with_output_capture(container, code, path, range)
311
- # Fiber-local storage for output redirection
312
- original_stdout = $stdout
313
- original_stderr = $stderr
314
-
369
+ # Execute test code while capturing its stdout/stderr.
370
+ #
371
+ # $stdout/$stderr are process-global. The redirect below mutates them for the
372
+ # whole process, so OUTPUT_CAPTURE_MONITOR serializes this section to keep
373
+ # concurrent --parallel runs from corrupting each other's output or leaving a
374
+ # dangling StringIO behind. See OUTPUT_CAPTURE_MONITOR for details.
375
+ def execute_with_output_capture(container, code, path, start_line)
315
376
  # Create StringIO objects for capturing output
316
377
  captured_stdout = StringIO.new
317
378
  captured_stderr = StringIO.new
318
379
 
319
- begin
320
- # Redirect output streams using Fiber-local variables
321
- Fiber.new do
380
+ OUTPUT_CAPTURE_MONITOR.synchronize do
381
+ original_stdout = $stdout
382
+ original_stderr = $stderr
383
+
384
+ begin
322
385
  $stdout = captured_stdout
323
386
  $stderr = captured_stderr
324
387
 
325
388
  # Execute with timing capture
326
389
  execution_start_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
327
- result_value = container.instance_eval(code, path, range.first + 1)
390
+ result_value = eval_code(container, code, path, start_line)
328
391
  execution_end_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
329
392
  execution_time_ns = execution_end_ns - execution_start_ns
330
393
 
331
- [result_value, execution_time_ns]
332
- end.resume.tap do |result_value, execution_time_ns|
333
- # Return captured content along with result
334
- return [result_value, execution_time_ns, captured_stdout.string, captured_stderr.string]
394
+ [result_value, execution_time_ns, captured_stdout.string, captured_stderr.string]
395
+ ensure
396
+ # Always restore original streams
397
+ $stdout = original_stdout
398
+ $stderr = original_stderr
335
399
  end
336
- ensure
337
- # Always restore original streams
338
- $stdout = original_stdout
339
- $stderr = original_stderr
340
400
  end
341
401
  end
342
402
 
@@ -426,6 +486,49 @@ class Tryouts
426
486
  end
427
487
  end
428
488
 
489
+ # Orphan blocks are setup-like glue between test cases: silent on success
490
+ # (no pass/fail, not counted in tallies), a file-level error on failure.
491
+ # In fresh-context mode they run against the setup container so their
492
+ # @instance_variables propagate to subsequent tests' fresh copies.
493
+ def execute_orphan_block(orphan)
494
+ return if orphan.code.strip.empty?
495
+
496
+ target = if shared_context?
497
+ @container
498
+ else
499
+ @setup_container ||= Object.new
500
+ end
501
+
502
+ @output_manager&.info("Running orphan code block (#{orphan.path}:#{orphan.line_range.first + 1})", 2)
503
+
504
+ captured_output = capture_output do
505
+ eval_code(target, orphan.code, orphan.path, orphan.eval_start_line)
506
+ end
507
+
508
+ @output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
509
+ rescue StandardError => ex
510
+ @orphan_failed = true
511
+ if @global_tally && @global_tally[:aggregator]
512
+ @global_tally[:aggregator].add_infrastructure_failure(
513
+ :orphan, @testrun.source_file, ex.message, ex
514
+ )
515
+ end
516
+
517
+ Tryouts.debug "Orphan code block failed (#{ex.class}): #{ex.message}"
518
+ Tryouts.trace ex.backtrace
519
+
520
+ @output_manager&.error("Orphan code block failed (#{orphan.path}:#{orphan.line_range.first + 1}): #{ex.message}")
521
+ rescue SystemExit, SignalException => ex
522
+ @orphan_failed = true
523
+ if @global_tally && @global_tally[:aggregator]
524
+ @global_tally[:aggregator].add_infrastructure_failure(
525
+ :orphan, @testrun.source_file, "Orphan block terminated by #{ex.class}: #{ex.message}", ex
526
+ )
527
+ end
528
+
529
+ @output_manager&.error("Orphan code block terminated by #{ex.class}: #{ex.message}")
530
+ end
531
+
429
532
  # Global setup execution for shared context mode
430
533
  def execute_global_setup
431
534
  setup = @testrun.setup
@@ -435,7 +538,7 @@ class Tryouts
435
538
 
436
539
  # Capture setup output instead of letting it print directly
437
540
  captured_output = capture_output do
438
- @container.instance_eval(setup.code, setup.path, setup.line_range.first + 1)
541
+ eval_code(@container, setup.code, setup.path, setup.eval_start_line)
439
542
  end
440
543
 
441
544
  @output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
@@ -487,7 +590,7 @@ class Tryouts
487
590
 
488
591
  # Capture setup output instead of letting it print directly
489
592
  captured_output = capture_output do
490
- @setup_container.instance_eval(setup.code, setup.path, setup.line_range.first + 1)
593
+ @setup_container.instance_eval(setup.code, setup.path, setup.eval_start_line)
491
594
  end
492
595
 
493
596
  @output_manager&.setup_output(captured_output) if captured_output && !captured_output.empty?
@@ -536,7 +639,7 @@ class Tryouts
536
639
 
537
640
  # Capture teardown output instead of letting it print directly
538
641
  captured_output = capture_output do
539
- @container.instance_eval(teardown.code, teardown.path, teardown.line_range.first + 1)
642
+ eval_code(@container, teardown.code, teardown.path, teardown.eval_start_line)
540
643
  end
541
644
 
542
645
  @output_manager&.teardown_output(captured_output) if captured_output && !captured_output.empty?
@@ -612,19 +715,28 @@ class Tryouts
612
715
  Tryouts::CLI::LineSpecParser.matches?(test_case, @line_spec)
613
716
  end
614
717
 
718
+ # Redirects the process-global $stdout/$stderr to StringIO for the duration of
719
+ # the block. This is the outer capture used by setup, teardown, and every test
720
+ # (execute_with_output_capture nests inside it for output-expectation tests).
721
+ # Synchronizes on OUTPUT_CAPTURE_MONITOR so the redirect/restore window is
722
+ # serialized across --parallel threads that share these process-global streams.
615
723
  def capture_output
616
- old_stdout = $stdout
617
- old_stderr = $stderr
618
- $stdout = StringIO.new
619
- $stderr = StringIO.new
620
-
621
- yield
622
-
623
- captured = $stdout.string + $stderr.string
624
- captured.empty? ? nil : captured
625
- ensure
626
- $stdout = old_stdout
627
- $stderr = old_stderr
724
+ OUTPUT_CAPTURE_MONITOR.synchronize do
725
+ old_stdout = $stdout
726
+ old_stderr = $stderr
727
+ $stdout = StringIO.new
728
+ $stderr = StringIO.new
729
+
730
+ begin
731
+ yield
732
+
733
+ captured = $stdout.string + $stderr.string
734
+ captured.empty? ? nil : captured
735
+ ensure
736
+ $stdout = old_stdout
737
+ $stderr = old_stderr
738
+ end
739
+ end
628
740
  end
629
741
 
630
742
  def handle_batch_error(exception)
@@ -1,9 +1,28 @@
1
- # lib/tryouts/testcase.rb
1
+ # lib/tryouts/test_case.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  # Modern data structures using Ruby 3.2+ Data classes
4
6
  class Tryouts
7
+ # A block is evaluated starting at its first executable line, not at its
8
+ # line_range.first: a block opens at its description or a leading comment,
9
+ # while the extracted code begins at the first statement. Getting this wrong
10
+ # misattributes every backtrace line in the block. Hand-built blocks (e.g.
11
+ # translator fixtures) may omit first_code_line and fall back to the start.
12
+ module EvalAnchor
13
+ def eval_start_line
14
+ (first_code_line || line_range.first) + 1
15
+ end
16
+ end
17
+
5
18
  # Core data structures
6
- TestCase = Data.define(:description, :code, :expectations, :line_range, :path, :source_lines, :first_expectation_line) do
19
+ TestCase = Data.define(:description, :code, :expectations, :line_range, :path, :source_lines, :first_expectation_line, :first_code_line) do
20
+ include EvalAnchor
21
+
22
+ def initialize(first_code_line: nil, **rest)
23
+ super
24
+ end
25
+
7
26
  def empty?
8
27
  code.empty?
9
28
  end
@@ -55,25 +74,54 @@ class Tryouts
55
74
  def stderr? = pipe == 2
56
75
  end
57
76
 
58
- Setup = Data.define(:code, :line_range, :path) do
77
+ Setup = Data.define(:code, :line_range, :path, :first_code_line) do
78
+ include EvalAnchor
79
+
80
+ def initialize(first_code_line: nil, **rest)
81
+ super
82
+ end
83
+
84
+ def empty?
85
+ code.empty?
86
+ end
87
+ end
88
+
89
+ # Code with no description and no expectations that sits between test cases.
90
+ # Executed in source order for its side effects, but never evaluated against
91
+ # expectations and never counted in test tallies.
92
+ OrphanBlock = Data.define(:code, :line_range, :path, :first_code_line) do
93
+ include EvalAnchor
94
+
95
+ def initialize(first_code_line: nil, **rest)
96
+ super
97
+ end
98
+
59
99
  def empty?
60
100
  code.empty?
61
101
  end
62
102
  end
63
103
 
64
- Teardown = Data.define(:code, :line_range, :path) do
104
+ Teardown = Data.define(:code, :line_range, :path, :first_code_line) do
105
+ include EvalAnchor
106
+
107
+ def initialize(first_code_line: nil, **rest)
108
+ super
109
+ end
110
+
65
111
  def empty?
66
112
  code.empty?
67
113
  end
68
114
  end
69
115
 
116
+ # test_cases is a single ordered array of TestCase | OrphanBlock — the
117
+ # interleaving order is load-bearing for shared-context local-variable state.
70
118
  Testrun = Data.define(:setup, :test_cases, :teardown, :source_file, :metadata, :warnings) do
71
119
  def total_tests
72
- test_cases.size
120
+ test_cases.count { |tc| tc.is_a?(TestCase) }
73
121
  end
74
122
 
75
123
  def empty?
76
- test_cases.empty?
124
+ total_tests.zero?
77
125
  end
78
126
  end
79
127
 
@@ -1,4 +1,6 @@
1
1
  # lib/tryouts/test_executor.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require_relative 'test_batch'
4
6
 
@@ -1,4 +1,6 @@
1
1
  # lib/tryouts/test_result_aggregator.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require_relative 'failure_collector'
4
6
  require 'concurrent'
@@ -1,7 +1,8 @@
1
1
  # lib/tryouts/test_runner.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require 'concurrent'
4
- require_relative 'parsers/legacy_parser'
5
6
  require_relative 'parsers/enhanced_parser'
6
7
  require_relative 'test_batch'
7
8
  require_relative 'translators/rspec_translator'
@@ -1,4 +1,6 @@
1
1
  # lib/tryouts/translators/minitest_translator.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  class Tryouts
4
6
  module Translators
@@ -41,6 +43,15 @@ class Tryouts
41
43
  # Recommendation: Write tryouts tests that work in fresh context mode
42
44
  # if you plan to use Minitest translation.
43
45
  class MinitestTranslator
46
+ # Simple string parameterization for method names.
47
+ #
48
+ # Defined on the class because `translate` calls it from inside a
49
+ # `Class.new(Minitest::Test) do ... end` block, where `self` is the
50
+ # anonymous test class rather than the translator instance.
51
+ def self.parameterize(string)
52
+ string.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_|_$/, '')
53
+ end
54
+
44
55
  def initialize
45
56
  require 'minitest/test'
46
57
  rescue LoadError
@@ -61,18 +72,29 @@ class Tryouts
61
72
 
62
73
  # Generate test methods
63
74
  testrun.test_cases.each_with_index do |test_case, index|
75
+ # Orphan blocks become plain statements in the class body, in source order
76
+ if test_case.is_a?(Tryouts::OrphanBlock)
77
+ unless test_case.empty?
78
+ class_eval(test_case.code, testrun.source_file, test_case.eval_start_line)
79
+ end
80
+ next
81
+ end
82
+
64
83
  next if test_case.empty? || !test_case.expectations?
65
84
 
66
- method_name = "test_#{index.to_s.rjust(3, '0')}_#{parameterize(test_case.description)}"
85
+ method_name = "test_#{index.to_s.rjust(3, '0')}_#{MinitestTranslator.parameterize(test_case.description)}"
67
86
  define_method(method_name) do
68
87
  if test_case.exception_expectations?
69
- # Handle exception expectations
70
- assert_raises(StandardError) do
88
+ # Handle exception expectations. The raised exception is bound to
89
+ # a local named `error`, which is what #=!> expectations refer to,
90
+ # so the expectation is eval'd against this binding rather than
91
+ # instance_eval'd.
92
+ error = assert_raises(StandardError) do
71
93
  instance_eval(test_case.code) unless test_case.code.strip.empty?
72
94
  end
73
95
 
74
96
  test_case.exception_expectations.each do |expectation|
75
- result = instance_eval(expectation.content)
97
+ result = eval(expectation.content, binding) # rubocop:disable Security/Eval
76
98
  assert result, "Exception expectation failed: #{expectation.content}"
77
99
  end
78
100
  else
@@ -119,9 +141,18 @@ class Tryouts
119
141
  end
120
142
 
121
143
  testrun.test_cases.each_with_index do |test_case, index|
144
+ # Orphan blocks become plain statements in the class body, in source order
145
+ if test_case.is_a?(Tryouts::OrphanBlock)
146
+ unless test_case.empty?
147
+ test_case.code.lines.each { |line| lines << " #{line.chomp}" }
148
+ lines << ''
149
+ end
150
+ next
151
+ end
152
+
122
153
  next if test_case.empty? || !test_case.expectations?
123
154
 
124
- method_name = "test_#{index.to_s.rjust(3, '0')}_#{parameterize(test_case.description)}"
155
+ method_name = "test_#{index.to_s.rjust(3, '0')}_#{MinitestTranslator.parameterize(test_case.description)}"
125
156
  lines << " def #{method_name}"
126
157
 
127
158
  if test_case.exception_expectations?
@@ -161,13 +192,6 @@ class Tryouts
161
192
  lines << 'end'
162
193
  lines.join("\n")
163
194
  end
164
-
165
- private
166
-
167
- # Simple string parameterization for method names
168
- def parameterize(string)
169
- string.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_|_$/, '')
170
- end
171
195
  end
172
196
  end
173
197
  end