tryouts 3.1.1 → 3.2.0

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/exe/try +3 -3
  3. data/lib/tryouts/cli/formatters/base.rb +108 -48
  4. data/lib/tryouts/cli/formatters/compact.rb +97 -105
  5. data/lib/tryouts/cli/formatters/factory.rb +8 -2
  6. data/lib/tryouts/cli/formatters/live_status_manager.rb +138 -0
  7. data/lib/tryouts/cli/formatters/output_manager.rb +78 -66
  8. data/lib/tryouts/cli/formatters/quiet.rb +54 -102
  9. data/lib/tryouts/cli/formatters/test_run_state.rb +122 -0
  10. data/lib/tryouts/cli/formatters/tty_status_display.rb +273 -0
  11. data/lib/tryouts/cli/formatters/verbose.rb +103 -105
  12. data/lib/tryouts/cli/formatters.rb +3 -0
  13. data/lib/tryouts/cli/opts.rb +17 -8
  14. data/lib/tryouts/cli/tty_detector.rb +92 -0
  15. data/lib/tryouts/console.rb +1 -1
  16. data/lib/tryouts/expectation_evaluators/boolean.rb +1 -1
  17. data/lib/tryouts/expectation_evaluators/exception.rb +2 -2
  18. data/lib/tryouts/expectation_evaluators/expectation_result.rb +3 -3
  19. data/lib/tryouts/expectation_evaluators/false.rb +1 -1
  20. data/lib/tryouts/expectation_evaluators/intentional_failure.rb +2 -2
  21. data/lib/tryouts/expectation_evaluators/output.rb +6 -6
  22. data/lib/tryouts/expectation_evaluators/performance_time.rb +3 -3
  23. data/lib/tryouts/expectation_evaluators/regex_match.rb +2 -2
  24. data/lib/tryouts/expectation_evaluators/regular.rb +1 -1
  25. data/lib/tryouts/expectation_evaluators/result_type.rb +1 -1
  26. data/lib/tryouts/expectation_evaluators/true.rb +2 -2
  27. data/lib/tryouts/failure_collector.rb +109 -0
  28. data/lib/tryouts/prism_parser.rb +17 -17
  29. data/lib/tryouts/test_batch.rb +9 -5
  30. data/lib/tryouts/test_case.rb +4 -4
  31. data/lib/tryouts/test_runner.rb +12 -1
  32. data/lib/tryouts/version.rb +1 -1
  33. data/lib/tryouts.rb +0 -9
  34. metadata +21 -22
@@ -0,0 +1,273 @@
1
+ # lib/tryouts/cli/formatters/tty_status_display.rb
2
+
3
+ require 'tty-cursor'
4
+ require 'tty-screen'
5
+ require 'pastel'
6
+ require 'io/console'
7
+
8
+ class Tryouts
9
+ class CLI
10
+ # Encapsulates TTY manipulation for live status display with fallback
11
+ class TTYStatusDisplay
12
+ STATUS_LINES = 5 # Lines reserved for fixed status display (4 content + 1 separator)
13
+
14
+ def initialize(io = $stdout, options = {})
15
+ @io = io
16
+ @available = check_tty_availability
17
+ @show_debug = options.fetch(:debug, false)
18
+ @cleanup_registered = false
19
+
20
+ return unless @available
21
+
22
+ @cursor = TTY::Cursor
23
+ @pastel = Pastel.new
24
+ @status_active = false
25
+ @original_cursor_position = nil
26
+
27
+ register_cleanup_handlers
28
+ end
29
+
30
+ def available?
31
+ @available
32
+ end
33
+
34
+ def reserve_status_area
35
+ return unless @available && !@status_active
36
+
37
+ with_terminal_safety do
38
+ # Store original cursor position if possible
39
+ @original_cursor_position = get_cursor_position
40
+
41
+ # Simply print empty lines to push content up and make room at bottom
42
+ STATUS_LINES.times { @io.print "\n" }
43
+
44
+ @status_active = true
45
+ end
46
+ end
47
+
48
+ def write_scrolling(text)
49
+ return @io.print(text) unless @available
50
+
51
+ # Always write content normally - the status will be updated separately
52
+ @io.print text
53
+ end
54
+
55
+ def update_status(state)
56
+ return unless @available && @status_active
57
+
58
+ with_terminal_safety do
59
+ # Move to status area (bottom of screen) and update in place
60
+ current_row, current_col = get_cursor_position
61
+
62
+ # Move to status area at bottom
63
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES + 1)
64
+
65
+ # Clear and write status content
66
+ STATUS_LINES.times do
67
+ @io.print @cursor.clear_line
68
+ @io.print @cursor.down(1) unless STATUS_LINES == 1
69
+ end
70
+
71
+ # Move back to start of status area and write content
72
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES + 1)
73
+ write_status_content(state)
74
+
75
+ # Move cursor back to where content should continue (just before status area)
76
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES)
77
+ @io.flush
78
+ end
79
+ end
80
+
81
+ def clear_status_area
82
+ return unless @available && @status_active
83
+
84
+ with_terminal_safety do
85
+ # Move to status area and clear it completely - start from first status line
86
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES + 1)
87
+
88
+ # Clear each line thoroughly
89
+ STATUS_LINES.times do |i|
90
+ @io.print @cursor.clear_line
91
+ @io.print @cursor.down(1) if i < STATUS_LINES - 1 # Don't go down after last line
92
+ end
93
+
94
+ # Move cursor to a clean area for final output - position it well above the cleared area
95
+ # This ensures no interference with the cleared status content
96
+ target_row = TTY::Screen.height - STATUS_LINES - 2 # Leave some buffer space
97
+ @io.print @cursor.move_to(0, target_row)
98
+ @io.print "\n" # Add a clean line for final output to start
99
+ @io.flush
100
+ end
101
+
102
+ @status_active = false
103
+ end
104
+
105
+ # Explicit cleanup method for manual invocation
106
+ def cleanup!
107
+ return unless @available
108
+
109
+ with_terminal_safety do
110
+ if @status_active
111
+ # Clear the status area completely
112
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES + 1)
113
+ STATUS_LINES.times do |i|
114
+ @io.print @cursor.clear_line
115
+ @io.print @cursor.down(1) if i < STATUS_LINES - 1
116
+ end
117
+ end
118
+
119
+ # Reset cursor to a safe position (start of line below cleared area)
120
+ @io.print @cursor.move_to(0, TTY::Screen.height - STATUS_LINES)
121
+ @io.print "\n"
122
+ @io.flush
123
+ end
124
+
125
+ @status_active = false
126
+ end
127
+
128
+ private
129
+
130
+ def get_cursor_position
131
+ # Simple approximation - in a real terminal this would query cursor position
132
+ # For now, return reasonable defaults
133
+ [10, 0]
134
+ end
135
+
136
+ def check_tty_availability
137
+ # Check if we have a real TTY
138
+ return false unless @io.respond_to?(:tty?) && @io.tty?
139
+
140
+ # Check if we can get screen dimensions
141
+ return false unless TTY::Screen.width > 0 && TTY::Screen.height > STATUS_LINES + 5
142
+
143
+ # Check if TERM environment variable suggests terminal capabilities
144
+ term = ENV.fetch('TERM', nil)
145
+ return false if term.nil? || term == 'dumb'
146
+
147
+ # Check if we're likely in a CI environment (common CI env vars)
148
+ ci_vars = %w[CI CONTINUOUS_INTEGRATION BUILD_NUMBER JENKINS_URL GITHUB_ACTIONS]
149
+ return false if ci_vars.any? { |var| ENV[var] }
150
+
151
+ true
152
+ rescue StandardError => ex
153
+ # If any TTY detection fails, assume not available
154
+ if @show_debug
155
+ @io.puts "TTY detection failed: #{ex.message}"
156
+ end
157
+ false
158
+ end
159
+
160
+ def write_status_content(state)
161
+ return unless @available
162
+
163
+ # Line 1: Empty separator line
164
+ @io.print "\n"
165
+
166
+ # Line 2: Current progress
167
+ if state.current_file
168
+ current_info = "Running: #{state.current_file}"
169
+ current_info += " → #{state.current_test}" if state.current_test
170
+ @io.print current_info
171
+ else
172
+ @io.print 'Ready'
173
+ end
174
+ @io.print "\n"
175
+
176
+ # Line 3: Test counts
177
+ parts = []
178
+ parts << @pastel.green("#{state.passed} passed") if state.passed > 0
179
+ parts << @pastel.red("#{state.failed} failed") if state.failed > 0
180
+ parts << @pastel.yellow("#{state.errors} errors") if state.errors > 0
181
+
182
+ if parts.any?
183
+ @io.print "Tests: #{parts.join(', ')}"
184
+ else
185
+ @io.print 'Tests: 0 run'
186
+ end
187
+ @io.print "\n"
188
+
189
+ # Line 4: File progress
190
+ files_info = "Files: #{state.files_completed}"
191
+ files_info += "/#{state.total_files}" if state.total_files > 0
192
+ files_info += ' completed'
193
+ @io.print files_info
194
+ @io.print "\n"
195
+
196
+ # Line 5: Timing
197
+ @io.print "Time: #{format_timing(state.elapsed_time)}"
198
+ end
199
+
200
+ def format_timing(elapsed_time)
201
+ if elapsed_time < 0.001
202
+ "#{(elapsed_time * 1_000_000).round}μs"
203
+ elsif elapsed_time < 1
204
+ "#{(elapsed_time * 1000).round}ms"
205
+ else
206
+ "#{elapsed_time.round(2)}s"
207
+ end
208
+ end
209
+
210
+ # Register cleanup handlers for various termination scenarios
211
+ def register_cleanup_handlers
212
+ return if @cleanup_registered
213
+
214
+ # Handle common termination signals
215
+ %w[INT TERM QUIT HUP].each do |signal|
216
+ begin
217
+ Signal.trap(signal) do
218
+ cleanup!
219
+ # Re-raise the signal with default handler
220
+ Signal.trap(signal, 'DEFAULT')
221
+ Process.kill(signal, Process.pid)
222
+ end
223
+ rescue ArgumentError
224
+ # Signal not supported on this platform, skip it
225
+ debug_log("Signal #{signal} not supported, skipping cleanup handler")
226
+ end
227
+ end
228
+
229
+ # Register at_exit handler as final fallback
230
+ at_exit { cleanup! }
231
+
232
+ @cleanup_registered = true
233
+ debug_log('TTY cleanup handlers registered')
234
+ end
235
+
236
+ # Wrap terminal operations with exception handling
237
+ def with_terminal_safety
238
+ yield
239
+ rescue StandardError => e
240
+ debug_log("Terminal operation failed: #{e.message}")
241
+ # Attempt basic cleanup on any terminal operation failure
242
+ begin
243
+ @io.print "\n" if @io.respond_to?(:print)
244
+ @io.flush if @io.respond_to?(:flush)
245
+ rescue StandardError
246
+ # If even basic cleanup fails, there's nothing more we can do
247
+ end
248
+ @status_active = false
249
+ end
250
+
251
+ def debug_log(message)
252
+ return unless @show_debug
253
+
254
+ # Use stderr to avoid interfering with TTY operations on stdout
255
+ $stderr.puts "TTY DEBUG: #{message}"
256
+ end
257
+ end
258
+
259
+ # No-op implementation for when TTY is not available
260
+ class NoOpStatusDisplay
261
+ def initialize(io = $stdout, _options = {})
262
+ @io = io
263
+ end
264
+
265
+ def available? = false
266
+ def reserve_status_area; end
267
+ def write_scrolling(text) = @io.print(text)
268
+ def update_status(state); end
269
+ def clear_status_area; end
270
+ def cleanup!; end # No-op cleanup for consistency
271
+ end
272
+ end
273
+ end
@@ -7,117 +7,119 @@ class Tryouts
7
7
  include FormatterInterface
8
8
 
9
9
  def initialize(options = {})
10
+ super
10
11
  @line_width = options.fetch(:line_width, 70)
11
12
  @show_passed = options.fetch(:show_passed, true)
12
13
  @show_debug = options.fetch(:debug, false)
13
14
  @show_trace = options.fetch(:trace, false)
14
- @current_indent = 0
15
15
  end
16
16
 
17
17
  # Phase-level output
18
- def phase_header(message, _file_count = nil, level = 0)
19
- return if level.equal?(1)
20
-
21
- separators = [
22
- { char: '=', width: @line_width }, # Major phases
23
- { char: '-', width: @line_width - 10 }, # Sub-phases
24
- { char: '.', width: @line_width - 20 }, # Details
25
- { char: '~', width: @line_width - 30 }, # Minor items
26
- ]
27
-
28
- config = separators[level] || separators.last
18
+ def phase_header(message, file_count: nil)
19
+ return if message.include?('EXECUTING') # Skip execution phase headers
29
20
 
30
- separator_line = config[:char] * config[:width]
31
- header_line = message.center(config[:width])
32
-
33
- output = case level
34
- when 0, 1
35
- [separator_line, header_line, separator_line]
36
- else
37
- [header_line, separator_line]
38
- end
21
+ header_line = message.center(@line_width)
22
+ separator_line = '=' * @line_width
39
23
 
40
- with_indent(level) do
41
- puts output.join("\n")
42
- end
24
+ puts(separator_line)
25
+ puts(header_line)
26
+ puts(separator_line)
43
27
  end
44
28
 
45
29
  # File-level operations
46
- def file_start(file_path, _context_info = {})
47
- puts file_header_visual(file_path)
48
- end
49
-
50
- def file_end(_file_path, _context_info = {})
51
- # No output in verbose mode
30
+ def file_start(file_path, context_info: {})
31
+ puts(file_header_visual(file_path))
52
32
  end
53
33
 
54
- def file_parsed(_file_path, _test_count, setup_present: false, teardown_present: false)
34
+ def file_parsed(_file_path, test_count:, setup_present: false, teardown_present: false)
55
35
  message = ''
56
36
 
57
- extras = []
37
+ extras = []
58
38
  extras << 'setup' if setup_present
59
39
  extras << 'teardown' if teardown_present
60
40
  message += " (#{extras.join(', ')})" unless extras.empty?
61
41
 
62
- puts indent_text(message, 2)
42
+ puts(indent_text(message, 2))
63
43
  end
64
44
 
65
- def file_execution_start(_file_path, test_count, context_mode)
45
+ def file_execution_start(_file_path, test_count:, context_mode:)
66
46
  message = "Running #{test_count} tests with #{context_mode} context"
67
- puts indent_text(message, 1)
47
+ puts(indent_text(message, 1))
68
48
  end
69
49
 
70
- # Summary operations
71
- #
72
- # Called right before file_result
73
- def batch_summary(total_tests, failed_count, elapsed_time)
74
- # No output in verbose mode
50
+ # Summary operations - show detailed failure summary
51
+ def batch_summary(failure_collector)
52
+ return unless failure_collector.any_failures?
53
+
54
+ puts
55
+ write '=' * 50
56
+ puts Console.color(:red, 'Failed Tests:')
57
+
58
+ failure_collector.failures_by_file.each do |file_path, failures|
59
+ failures.each_with_index do |failure, index|
60
+ pretty_path = Console.pretty_path(file_path)
61
+
62
+ # Include line number with file path for easy copying/clicking
63
+ if failure.line_number > 0
64
+ location = "#{pretty_path}:#{failure.line_number}"
65
+ else
66
+ location = pretty_path
67
+ end
68
+
69
+ puts
70
+ puts Console.color(:yellow, location)
71
+ puts " #{index + 1}) #{failure.description}"
72
+ puts " #{Console.color(:red, 'Failure:')} #{failure.failure_reason}"
73
+
74
+ # Show source context in verbose mode
75
+ if failure.source_context.any?
76
+ puts " #{Console.color(:cyan, 'Source:')}"
77
+ failure.source_context.each do |line|
78
+ puts " #{line.strip}"
79
+ end
80
+ end
81
+ puts
82
+ end
83
+ end
75
84
  end
76
85
 
77
- def file_result(_file_path, total_tests, failed_count, error_count, elapsed_time)
86
+ def file_result(_file_path, total_tests:, failed_count:, error_count:, elapsed_time: nil)
78
87
  issues_count = failed_count + error_count
79
88
  passed_count = total_tests - issues_count
80
- details = [
81
- "#{passed_count} passed",
82
- ]
89
+ details = ["#{passed_count} passed"]
83
90
 
91
+ puts
84
92
  if issues_count > 0
85
93
  details << "#{failed_count} failed" if failed_count > 0
86
94
  details << "#{error_count} errors" if error_count > 0
87
95
  details_str = details.join(', ')
88
- color = :red
96
+ color = :red
89
97
 
90
98
  time_str = elapsed_time ? " (#{elapsed_time.round(2)}s)" : ''
91
- message = "✗ Out of #{total_tests} tests: #{details_str}#{time_str}"
99
+ message = "✗ Out of #{total_tests} tests: #{details_str}#{time_str}"
92
100
  puts indent_text(Console.color(color, message), 2)
93
101
  else
94
102
  message = "#{total_tests} tests passed"
95
- color = :green
103
+ color = :green
96
104
  puts indent_text(Console.color(color, "✓ #{message}"), 2)
97
105
  end
98
106
 
99
107
  return unless elapsed_time
100
108
 
101
109
  time_msg = "Completed in #{format_timing(elapsed_time).strip.tr('()', '')}"
102
-
103
110
  puts indent_text(Console.color(:dim, time_msg), 2)
104
111
  end
105
112
 
106
113
  # Test-level operations
107
- def test_start(test_case, index, total)
108
- desc = test_case.description.to_s
109
- desc = 'Unnamed test' if desc.empty?
114
+ def test_start(test_case:, index:, total:)
115
+ desc = test_case.description.to_s
116
+ desc = 'Unnamed test' if desc.empty?
110
117
  message = "Test #{index}/#{total}: #{desc}"
111
118
  puts indent_text(Console.color(:dim, message), 2)
112
119
  end
113
120
 
114
- def test_end(_test_case, _index, _total)
115
- # No output in verbose mode
116
- end
117
-
118
121
  def test_result(result_packet)
119
122
  should_show = @show_passed || !result_packet.passed?
120
-
121
123
  return unless should_show
122
124
 
123
125
  status_line = case result_packet.status
@@ -150,7 +152,7 @@ class Tryouts
150
152
  end
151
153
  end
152
154
 
153
- def test_output(_test_case, output_text)
155
+ def test_output(test_case:, output_text:, result_packet:)
154
156
  return if output_text.nil? || output_text.strip.empty?
155
157
 
156
158
  puts indent_text('Test Output:', 3)
@@ -165,7 +167,7 @@ class Tryouts
165
167
  end
166
168
 
167
169
  # Setup/teardown operations
168
- def setup_start(line_range)
170
+ def setup_start(line_range:)
169
171
  message = "Executing global setup (lines #{line_range.first}..#{line_range.last})"
170
172
  puts indent_text(Console.color(:cyan, message), 2)
171
173
  end
@@ -178,9 +180,10 @@ class Tryouts
178
180
  end
179
181
  end
180
182
 
181
- def teardown_start(line_range)
183
+ def teardown_start(line_range:)
182
184
  message = "Executing teardown (lines #{line_range.first}..#{line_range.last})"
183
185
  puts indent_text(Console.color(:cyan, message), 2)
186
+ puts
184
187
  end
185
188
 
186
189
  def teardown_output(output_text)
@@ -191,21 +194,20 @@ class Tryouts
191
194
  end
192
195
  end
193
196
 
194
- def grand_total(total_tests, failed_count, error_count, successful_files, total_files, elapsed_time)
197
+ def grand_total(total_tests:, failed_count:, error_count:, successful_files:, total_files:, elapsed_time:)
195
198
  puts
196
199
  puts '=' * @line_width
197
200
  puts 'Grand Total:'
198
201
 
199
202
  issues_count = failed_count + error_count
200
- time_str =
201
- if elapsed_time < 2.0
202
- " (#{(elapsed_time * 1000).round}ms)"
203
- else
204
- " (#{elapsed_time.round(2)}s)"
205
- end
203
+ time_str = if elapsed_time < 2.0
204
+ " (#{(elapsed_time * 1000).round}ms)"
205
+ else
206
+ " (#{elapsed_time.round(2)}s)"
207
+ end
206
208
 
207
209
  if issues_count > 0
208
- passed = [total_tests - issues_count, 0].max # Ensure passed never goes negative
210
+ passed = [total_tests - issues_count, 0].max # Ensure passed never goes negative
209
211
  details = []
210
212
  details << "#{failed_count} failed" if failed_count > 0
211
213
  details << "#{error_count} errors" if error_count > 0
@@ -214,12 +216,12 @@ class Tryouts
214
216
  puts "#{total_tests} tests passed#{time_str}"
215
217
  end
216
218
 
217
- puts "Files processed: #{successful_files} of #{total_files} successful"
219
+ puts "Files: #{successful_files} of #{total_files} successful"
218
220
  puts '=' * @line_width
219
221
  end
220
222
 
221
223
  # Debug and diagnostic output
222
- def debug_info(message, level = 0)
224
+ def debug_info(message, level: 0)
223
225
  return unless @show_debug
224
226
 
225
227
  prefix = Console.color(:cyan, 'INFO ')
@@ -227,14 +229,14 @@ class Tryouts
227
229
  puts indent_text("#{prefix} #{message}", level + 1)
228
230
  end
229
231
 
230
- def trace_info(message, level = 0)
232
+ def trace_info(message, level: 0)
231
233
  return unless @show_trace
232
234
 
233
235
  prefix = Console.color(:dim, 'TRACE')
234
236
  puts indent_text("#{prefix} #{message}", level + 1)
235
237
  end
236
238
 
237
- def error_message(message, backtrace = nil)
239
+ def error_message(message, backtrace: nil)
238
240
  error_msg = Console.color(:red, "ERROR: #{message}")
239
241
  puts indent_text(error_msg, 1)
240
242
 
@@ -248,22 +250,12 @@ class Tryouts
248
250
  puts indent_text("... (#{backtrace.length - 10} more lines)", 3) if backtrace.length > 10
249
251
  end
250
252
 
251
- # Utility methods
252
- def raw_output(text)
253
- puts text
254
- end
255
-
256
- def separator(style = :light)
257
- case style
258
- when :heavy
259
- puts '=' * @line_width
260
- when :light
261
- puts '-' * @line_width
262
- when :dotted
263
- puts '.' * @line_width
264
- else # rubocop:disable Lint/DuplicateBranch
265
- puts '-' * @line_width
266
- end
253
+ def live_status_capabilities
254
+ {
255
+ supports_coordination: true, # Verbose can work with coordinated output
256
+ output_frequency: :high, # Outputs frequently for each test
257
+ requires_tty: false, # Works without TTY
258
+ }
267
259
  end
268
260
 
269
261
  private
@@ -295,11 +287,11 @@ class Tryouts
295
287
  start_line = test_case.line_range.first
296
288
 
297
289
  test_case.source_lines.each_with_index do |line_content, index|
298
- line_num = start_line + index
290
+ line_num = start_line + index
299
291
  line_display = format('%3d: %s', line_num + 1, line_content)
300
292
 
301
293
  # Highlight expectation lines by checking if this line contains any expectation syntax
302
- if line_content.match?(/^\s*#\s*=(!|<|=|\/=|\||:|~|%|\d+)?>\s*/)
294
+ if line_content.match?(%r{^\s*#\s*=(!|<|=|/=|\||:|~|%|\d+)?>\s*})
303
295
  line_display = Console.color(:yellow, line_display)
304
296
  end
305
297
 
@@ -312,19 +304,20 @@ class Tryouts
312
304
  return if actual_results.empty?
313
305
 
314
306
  actual_results.each_with_index do |actual, idx|
315
- expected = expected_results[idx] if expected_results && idx < expected_results.length
307
+ expected = expected_results[idx] if expected_results && idx < expected_results.length
316
308
  expected_line = test_case.expectations[idx] if test_case.expectations
317
309
 
318
310
  if !expected.nil?
319
311
  # Use the evaluated expected value from the evaluator
320
312
  puts indent_text("Expected: #{Console.color(:green, expected.inspect)}", 4)
321
313
  puts indent_text("Actual: #{Console.color(:red, actual.inspect)}", 4)
322
- elsif expected_line
323
- # Fallback to raw expectation content
314
+ elsif expected_line && !expected_results.empty?
315
+ # Only show raw expectation content if we have expected_results (non-error case)
324
316
  puts indent_text("Expected: #{Console.color(:green, expected_line.content)}", 4)
325
317
  puts indent_text("Actual: #{Console.color(:red, actual.inspect)}", 4)
326
318
  else
327
- puts indent_text("Actual: #{Console.color(:red, actual.inspect)}", 4)
319
+ # For error cases (empty expected_results), just show the error
320
+ puts indent_text("Error: #{Console.color(:red, actual.inspect)}", 4)
328
321
  end
329
322
 
330
323
  # Show difference if both are strings
@@ -345,10 +338,10 @@ class Tryouts
345
338
  end
346
339
 
347
340
  def file_header_visual(file_path)
348
- pretty_path = Console.pretty_path(file_path)
341
+ pretty_path = Console.pretty_path(file_path)
349
342
  header_content = ">>>>> #{pretty_path} "
350
343
  padding_length = [@line_width - header_content.length, 0].max
351
- padding = '<' * padding_length
344
+ padding = '<' * padding_length
352
345
 
353
346
  [
354
347
  indent_text('-' * @line_width, 1),
@@ -356,16 +349,6 @@ class Tryouts
356
349
  indent_text('-' * @line_width, 1),
357
350
  ].join("\n")
358
351
  end
359
-
360
- def format_timing(elapsed_time)
361
- if elapsed_time < 0.001
362
- " (#{(elapsed_time * 1_000_000).round}μs)"
363
- elsif elapsed_time < 1
364
- " (#{(elapsed_time * 1000).round}ms)"
365
- else
366
- " (#{elapsed_time.round(2)}s)"
367
- end
368
- end
369
352
  end
370
353
 
371
354
  # Verbose formatter that only shows failures and errors
@@ -374,12 +357,27 @@ class Tryouts
374
357
  super(options.merge(show_passed: false))
375
358
  end
376
359
 
360
+ def test_output(test_case:, output_text:, result_packet:)
361
+ # Only show output for failed tests
362
+ return if result_packet.passed?
363
+
364
+ super
365
+ end
366
+
377
367
  def test_result(result_packet)
378
368
  # Only show failed/error tests, but with full source code
379
369
  return if result_packet.passed?
380
370
 
381
371
  super
382
372
  end
373
+
374
+ def live_status_capabilities
375
+ {
376
+ supports_coordination: true, # Verbose can work with coordinated output
377
+ output_frequency: :high, # Outputs frequently for each test
378
+ requires_tty: false, # Works without TTY
379
+ }
380
+ end
383
381
  end
384
382
  end
385
383
  end
@@ -4,3 +4,6 @@ require_relative 'formatters/base'
4
4
  require_relative 'formatters/compact'
5
5
  require_relative 'formatters/quiet'
6
6
  require_relative 'formatters/verbose'
7
+ require_relative 'formatters/test_run_state'
8
+ require_relative 'formatters/tty_status_display'
9
+ require_relative 'formatters/factory'