tryouts 3.1.2 → 3.2.1

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.
@@ -0,0 +1,138 @@
1
+ # lib/tryouts/cli/formatters/live_status_manager.rb
2
+
3
+ require_relative 'test_run_state'
4
+ require_relative 'tty_status_display'
5
+
6
+ class Tryouts
7
+ class CLI
8
+ # Centralized manager for live status display across all formatters
9
+ # Replaces the decorator pattern with native integration
10
+ class LiveStatusManager
11
+ def initialize(formatter, options = {})
12
+ @formatter = formatter
13
+ @enabled = should_enable_live_status?(formatter, options)
14
+ @show_debug = options.fetch(:debug, false)
15
+
16
+ return unless @enabled
17
+
18
+ # Initialize state tracking and display
19
+ @state = TestRunState.empty
20
+ @display = TTYStatusDisplay.new(@formatter.stdout, options)
21
+ @status_reserved = false
22
+
23
+ debug_log('LiveStatusManager: Enabled with native integration')
24
+ end
25
+
26
+ def enabled?
27
+ @enabled
28
+ end
29
+
30
+ # Check if formatter and environment support live status
31
+ def should_enable_live_status?(formatter, options)
32
+ # Must be explicitly requested
33
+ return false unless options[:live_status] || options[:live]
34
+
35
+ # Check formatter capabilities
36
+ capabilities = formatter.live_status_capabilities
37
+ return false unless capabilities[:supports_coordination]
38
+
39
+ # Check TTY availability
40
+ require_relative '../tty_detector'
41
+ tty_check = TTYDetector.check_tty_support(debug: options[:debug])
42
+
43
+ unless tty_check[:available]
44
+ debug_log("Live status disabled: #{tty_check[:reason]}")
45
+ return false
46
+ end
47
+
48
+ true
49
+ end
50
+
51
+ # Main event handling - called by OutputManager for each formatter event
52
+ def handle_event(event_type, *args, **)
53
+ return unless @enabled
54
+
55
+ # Update state based on the event
56
+ @state = @state.update_from_event(event_type, *args, **)
57
+
58
+ # Handle special events that need display coordination
59
+ case event_type
60
+ when :phase_header
61
+ message, file_count, level = args
62
+ if level == 0 && message.include?('PROCESSING') && file_count
63
+ reserve_status_area
64
+ end
65
+ when :file_start, :file_end, :test_result
66
+ update_display
67
+ when :batch_summary
68
+ # Clear status area before showing batch summary to avoid interference
69
+ clear_status_area
70
+ when :grand_total
71
+ # Ensure status area is cleared (redundant safety check)
72
+ clear_status_area if @status_reserved
73
+ end
74
+ end
75
+
76
+ # Allow formatter to directly update live status (optional integration point)
77
+ def update_status(state_updates = {})
78
+ return unless @enabled
79
+
80
+ @state = @state.with(**state_updates) unless state_updates.empty?
81
+ update_display
82
+ end
83
+
84
+ # Output coordination methods
85
+ def write_output
86
+ return yield unless @enabled
87
+
88
+ # If status area is reserved, coordinate the output
89
+ if @status_reserved
90
+ @display.write_scrolling(yield)
91
+ else
92
+ yield
93
+ end
94
+ end
95
+
96
+ def write_string(text)
97
+ return @formatter.stdout.print(text) unless @enabled
98
+
99
+ if @status_reserved
100
+ @display.write_scrolling(text)
101
+ else
102
+ @formatter.stdout.print(text)
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def reserve_status_area
109
+ return unless @enabled && @display.available?
110
+
111
+ debug_log('Reserving status area for live display')
112
+ @display.reserve_status_area
113
+ @status_reserved = true
114
+ update_display
115
+ end
116
+
117
+ def update_display
118
+ return unless @enabled && @status_reserved
119
+
120
+ @display.update_status(@state)
121
+ end
122
+
123
+ def clear_status_area
124
+ return unless @enabled && @status_reserved
125
+
126
+ debug_log('Clearing status area for final output')
127
+ @display.clear_status_area
128
+ @status_reserved = false
129
+ end
130
+
131
+ def debug_log(message)
132
+ return unless @show_debug
133
+
134
+ @formatter.stderr.puts "DEBUG: #{message}"
135
+ end
136
+ end
137
+ end
138
+ end
@@ -1,91 +1,114 @@
1
1
  # lib/tryouts/cli/formatters/output_manager.rb
2
2
 
3
+ require_relative 'live_status_manager'
4
+
3
5
  class Tryouts
4
6
  class CLI
5
7
  # Output manager that coordinates all output through formatters
6
8
  class OutputManager
7
- attr_reader :formatter
9
+ attr_reader :formatter, :live_status_manager
10
+
11
+ def initialize(formatter, options = {})
12
+ @formatter = formatter
13
+ @live_status_manager = LiveStatusManager.new(formatter, options)
8
14
 
9
- def initialize(formatter)
10
- @formatter = formatter
11
- @indent_level = 0
15
+ # Connect the formatter to the live status manager
16
+ @formatter.set_live_status_manager(@live_status_manager)
12
17
  end
13
18
 
14
19
  # Phase-level methods
15
- def processing_phase(file_count, level = 0)
16
- @formatter.phase_header("PROCESSING #{file_count} FILES", file_count, level)
20
+ def processing_phase(file_count)
21
+ message = "PROCESSING #{file_count} FILES"
22
+ @live_status_manager.handle_event(:phase_header, message, file_count, 0)
23
+ @formatter.phase_header(message, file_count: file_count)
17
24
  end
18
25
 
19
- def execution_phase(test_count, level = 1)
20
- @formatter.phase_header("EXECUTING #{test_count} TESTS", test_count, level)
26
+ def execution_phase(test_count)
27
+ message = "EXECUTING #{test_count} TESTS"
28
+ @live_status_manager.handle_event(:phase_header, message, test_count, 1)
29
+ @formatter.phase_header(message, file_count: test_count)
21
30
  end
22
31
 
23
- def error_phase(level = 1)
24
- @formatter.phase_header('ERROR DETAILS', level)
32
+ def error_phase
33
+ message = 'ERROR DETAILS'
34
+ @live_status_manager.handle_event(:phase_header, message, nil, 2)
35
+ @formatter.phase_header(message)
25
36
  end
26
37
 
27
38
  # File-level methods
28
39
  def file_start(file_path, framework: :direct, context: :fresh)
29
40
  context_info = { framework: framework, context: context }
30
- @formatter.file_start(file_path, context_info)
41
+ @live_status_manager.handle_event(:file_start, file_path, context_info)
42
+ @formatter.file_start(file_path, context_info: context_info)
31
43
  end
32
44
 
33
45
  def file_end(file_path, framework: :direct, context: :fresh)
34
46
  context_info = { framework: framework, context: context }
35
- @formatter.file_end(file_path, context_info)
47
+ @live_status_manager.handle_event(:file_end, file_path, context_info)
48
+ @formatter.file_end(file_path, context_info: context_info)
36
49
  end
37
50
 
38
51
  def file_parsed(file_path, test_count, setup_present: false, teardown_present: false)
39
- with_indent(1) do
40
- @formatter.file_parsed(file_path, test_count,
41
- setup_present: setup_present,
42
- teardown_present: teardown_present
43
- )
44
- end
52
+ @formatter.file_parsed(
53
+ file_path,
54
+ test_count: test_count,
55
+ setup_present: setup_present,
56
+ teardown_present: teardown_present
57
+ )
45
58
  end
46
59
 
47
60
  def file_execution_start(file_path, test_count, context_mode)
48
- @formatter.file_execution_start(file_path, test_count, context_mode)
61
+ @formatter.file_execution_start(
62
+ file_path,
63
+ test_count: test_count,
64
+ context_mode: context_mode
65
+ )
49
66
  end
50
67
 
51
68
  def file_success(file_path, total_tests, failed_count, error_count, elapsed_time)
52
- with_indent(1) do
53
- @formatter.file_result(file_path, total_tests, failed_count, error_count, elapsed_time)
54
- end
69
+ @formatter.file_result(
70
+ file_path,
71
+ total_tests: total_tests,
72
+ failed_count: failed_count,
73
+ error_count: error_count,
74
+ elapsed_time: elapsed_time
75
+ )
55
76
  end
56
77
 
57
78
  def file_failure(file_path, error_message, backtrace = nil)
58
- with_indent(1) do
59
- @formatter.error_message("#{Console.pretty_path(file_path)}: #{error_message}", backtrace)
60
- end
79
+ @formatter.error_message(
80
+ "#{Console.pretty_path(file_path)}: #{error_message}",
81
+ backtrace: backtrace
82
+ )
61
83
  end
62
84
 
63
85
  # Test-level methods
64
86
  def test_start(test_case, index, total)
65
- with_indent(2) do
66
- @formatter.test_start(test_case, index, total)
67
- end
87
+ @live_status_manager.handle_event(:test_start, test_case, index, total)
88
+ @formatter.test_start(test_case: test_case, index: index, total: total)
68
89
  end
69
90
 
70
91
  def test_end(test_case, index, total)
71
- with_indent(2) do
72
- @formatter.test_end(test_case, index, total)
73
- end
92
+ @live_status_manager.handle_event(:test_end, test_case, index, total)
93
+ @formatter.test_end(test_case: test_case, index: index, total: total)
74
94
  end
75
95
 
76
96
  def test_result(result_packet)
97
+ @live_status_manager.handle_event(:test_result, result_packet)
77
98
  @formatter.test_result(result_packet)
78
99
  end
79
100
 
80
- def test_output(test_case, output_text)
81
- @formatter.test_output(test_case, output_text)
101
+ def test_output(test_case, output_text, result_packet)
102
+ @formatter.test_output(
103
+ test_case: test_case,
104
+ output_text: output_text,
105
+ result_packet: result_packet
106
+ )
82
107
  end
83
108
 
84
109
  # Setup/teardown methods
85
110
  def setup_start(line_range)
86
- with_indent(2) do
87
- @formatter.setup_start(line_range)
88
- end
111
+ @formatter.setup_start(line_range: line_range)
89
112
  end
90
113
 
91
114
  def setup_output(output_text)
@@ -93,9 +116,7 @@ class Tryouts
93
116
  end
94
117
 
95
118
  def teardown_start(line_range)
96
- with_indent(2) do
97
- @formatter.teardown_start(line_range)
98
- end
119
+ @formatter.teardown_start(line_range: line_range)
99
120
  end
100
121
 
101
122
  def teardown_output(output_text)
@@ -103,48 +124,39 @@ class Tryouts
103
124
  end
104
125
 
105
126
  # Summary methods
106
- def batch_summary(total_tests, failed_count, elapsed_time)
107
- @formatter.batch_summary(total_tests, failed_count, elapsed_time)
127
+ def batch_summary(failure_collector)
128
+ @live_status_manager.handle_event(:batch_summary, failure_collector)
129
+ @formatter.batch_summary(failure_collector)
108
130
  end
109
131
 
110
132
  def grand_total(total_tests, failed_count, error_count, successful_files, total_files, elapsed_time)
111
- @formatter.grand_total(total_tests, failed_count, error_count, successful_files, total_files, elapsed_time)
133
+ @live_status_manager.handle_event(:grand_total, total_tests, failed_count, error_count, successful_files, total_files, elapsed_time)
134
+ @formatter.grand_total(
135
+ total_tests: total_tests,
136
+ failed_count: failed_count,
137
+ error_count: error_count,
138
+ successful_files: successful_files,
139
+ total_files: total_files,
140
+ elapsed_time: elapsed_time
141
+ )
112
142
  end
113
143
 
114
144
  # Debug methods
115
145
  def info(message, level = 0)
116
- with_indent(level) do
117
- @formatter.debug_info(message, level)
118
- end
146
+ @formatter.debug_info(message, level: level)
119
147
  end
120
148
 
121
149
  def trace(message, level = 0)
122
- with_indent(level) do
123
- @formatter.trace_info(message, level)
124
- end
150
+ @formatter.trace_info(message, level: level)
125
151
  end
126
152
 
127
153
  def error(message, backtrace = nil)
128
- @formatter.error_message(message, backtrace)
129
- end
130
-
131
- # Utility methods
132
- def raw(text)
133
- @formatter.raw_output(text)
154
+ @formatter.error_message(message, backtrace: backtrace)
134
155
  end
135
156
 
136
- def separator(style = :light)
137
- @formatter.separator(style)
138
- end
139
-
140
- private
141
-
142
- def with_indent(level)
143
- old_level = @indent_level
144
- @indent_level = level
145
- yield
146
- ensure
147
- @indent_level = old_level
157
+ # Raw output method (bypasses formatting)
158
+ def raw(message)
159
+ @formatter.stdout.puts(message)
148
160
  end
149
161
  end
150
162
  end
@@ -7,102 +7,50 @@ class Tryouts
7
7
  include FormatterInterface
8
8
 
9
9
  def initialize(options = {})
10
+ super
10
11
  @show_errors = options.fetch(:show_errors, true)
11
12
  @show_final_summary = options.fetch(:show_final_summary, true)
12
13
  @current_file = nil
13
14
  end
14
15
 
15
- # Phase-level output - silent
16
- def phase_header(message, file_count = nil, level = nil)
17
- # Silent in quiet mode
18
- end
19
-
20
- # File-level operations - minimal
21
- def file_start(file_path, context_info = {})
22
- # Silent in quiet mode
23
- end
24
-
25
- def file_end(_file_path, _context_info = {}, io = $stderr)
26
- io.puts # add newline after all dots
27
- end
28
-
29
- def file_parsed(file_path, test_count, setup_present: false, teardown_present: false)
30
- # Silent in quiet mode
31
- end
32
-
33
- def file_execution_start(file_path, _test_count, _context_mode)
16
+ def file_execution_start(file_path, test_count:, context_mode:)
34
17
  @current_file = file_path
35
18
  end
36
19
 
37
- def file_result(file_path, total_tests, failed_count, error_count, elapsed_time)
38
- # Silent in quiet mode - results shown in batch_summary
39
- end
40
-
41
- # Test-level operations - dot notation
42
- def test_start(test_case, index, total)
43
- # Silent in quiet mode
44
- end
45
-
46
- def test_end(test_case, index, total, io = $stderr)
47
- # Silent in quiet mode
48
- end
49
-
50
- def test_result(result_packet, io = $stderr)
51
- case result_packet.status
52
- when :passed
53
- io.print Console.color(:green, '.')
54
- when :failed
55
- io.print Console.color(:red, 'F')
56
- when :error
57
- io.print Console.color(:red, 'E')
58
- when :skipped
59
- io.print Console.color(:yellow, 'S')
60
- else
61
- io.print '?'
62
- end
63
- io.flush
64
- end
65
-
66
- def test_output(test_case, output_text)
67
- # Silent in quiet mode - could optionally show output for failed tests only
68
- # For now, keeping it completely silent
69
- end
70
-
71
- # Setup/teardown operations - silent
72
- def setup_start(line_range)
73
- # Silent in quiet mode
74
- end
75
-
76
- def setup_output(output_text)
77
- # Silent in quiet mode
78
- end
79
-
80
- def teardown_start(line_range)
81
- # Silent in quiet mode
82
- end
83
-
84
- def teardown_output(output_text)
85
- # Silent in quiet mode
86
- end
87
-
88
- # Summary operations - show results
89
- def batch_summary(total_tests, failed_count, elapsed_time, io = $stderr)
90
- return unless @show_final_summary
91
-
92
- if failed_count > 0
93
- passed = total_tests - failed_count
94
- time_str = elapsed_time ? " (#{elapsed_time.round(2)}s)" : ''
95
- io.puts "#{failed_count} failed, #{passed} passed#{time_str}"
96
- else
97
- time_str = elapsed_time ? " (#{elapsed_time.round(2)}s)" : ''
98
- io.puts "#{total_tests} passed#{time_str}"
99
- end
20
+ def file_end(_file_path, context_info: {})
21
+ # Always use coordinated output through puts() method
22
+ # puts # add newline after all dots
100
23
  end
101
24
 
102
- def grand_total(total_tests, failed_count, error_count, successful_files, total_files, elapsed_time)
25
+ def test_result(result_packet)
26
+ char = case result_packet.status
27
+ when :passed
28
+ Console.color(:green, '.')
29
+ when :failed
30
+ Console.color(:red, 'F')
31
+ when :error
32
+ Console.color(:red, 'E')
33
+ when :skipped
34
+ Console.color(:yellow, 'S')
35
+ else
36
+ '?'
37
+ end
38
+
39
+ # Always use coordinated output through write() method
40
+ write(char)
41
+ end
42
+
43
+ # Summary operations - quiet mode skips failure summary
44
+ def batch_summary(failure_collector)
45
+ # Quiet formatter defaults to no failure summary
46
+ # Users can override with --failure-summary if needed
47
+ end
48
+
49
+ def grand_total(total_tests:, failed_count:, error_count:, successful_files:, total_files:, elapsed_time:)
103
50
  return unless @show_final_summary
104
51
 
105
52
  puts
53
+ puts # Add newline after dots
106
54
 
107
55
  time_str = if elapsed_time < 2
108
56
  "#{(elapsed_time * 1000).to_i}ms"
@@ -126,29 +74,25 @@ class Tryouts
126
74
  end
127
75
  end
128
76
 
129
- # Debug and diagnostic output - silent unless errors
130
- def debug_info(message, level = 0)
131
- # Silent in quiet mode
132
- end
133
-
134
- def trace_info(message, level = 0)
135
- # Silent in quiet mode
136
- end
137
-
138
- def error_message(message, _details = nil)
77
+ def error_message(message, backtrace: nil)
139
78
  return unless @show_errors
140
79
 
141
- puts
142
- puts Console.color(:red, "ERROR: #{message}")
143
- end
80
+ @stderr.puts
81
+ @stderr.puts Console.color(:red, "ERROR: #{message}")
82
+
83
+ return unless backtrace && @show_debug
144
84
 
145
- # Utility methods
146
- def raw_output(text)
147
- puts text if @show_final_summary
85
+ backtrace.first(3).each do |line|
86
+ @stderr.puts " #{line.chomp}"
87
+ end
148
88
  end
149
89
 
150
- def separator(style = :light)
151
- # Silent in quiet mode
90
+ def live_status_capabilities
91
+ {
92
+ supports_coordination: true, # Quiet can work with coordinated output
93
+ output_frequency: :low, # Very minimal output, mainly dots
94
+ requires_tty: false, # Works without TTY
95
+ }
152
96
  end
153
97
  end
154
98
 
@@ -160,6 +104,14 @@ class Tryouts
160
104
 
161
105
  super
162
106
  end
107
+
108
+ def live_status_capabilities
109
+ {
110
+ supports_coordination: true, # QuietFails can work with coordinated output
111
+ output_frequency: :low, # Very minimal output
112
+ requires_tty: false, # Works without TTY
113
+ }
114
+ end
163
115
  end
164
116
  end
165
117
  end
@@ -0,0 +1,122 @@
1
+ # lib/tryouts/cli/formatters/test_run_state.rb
2
+
3
+ class Tryouts
4
+ class CLI
5
+ # Immutable state tracking for test runs using modern Ruby Data.define
6
+ class TestRunState < Data.define(
7
+ :total_tests,
8
+ :passed,
9
+ :failed,
10
+ :errors,
11
+ :files_completed,
12
+ :total_files,
13
+ :current_file,
14
+ :current_test,
15
+ :start_time,
16
+ )
17
+ def self.empty
18
+ new(
19
+ total_tests: 0,
20
+ passed: 0,
21
+ failed: 0,
22
+ errors: 0,
23
+ files_completed: 0,
24
+ total_files: 0,
25
+ current_file: nil,
26
+ current_test: nil,
27
+ start_time: nil,
28
+ )
29
+ end
30
+
31
+ def self.initial(total_files: 0)
32
+ empty.with(
33
+ total_files: total_files,
34
+ start_time: Time.now,
35
+ )
36
+ end
37
+
38
+ # Update state based on formatter events using pattern matching
39
+ def update_from_event(event_type, *args, **_kwargs)
40
+ case event_type
41
+ in :phase_header
42
+ _, file_count, level = args
43
+ if level == 0 && file_count
44
+ with(total_files: file_count, start_time: Time.now)
45
+ else
46
+ self
47
+ end
48
+
49
+ in :file_start
50
+ file_path = args[0]
51
+ pretty_path = Console.pretty_path(file_path)
52
+ with(current_file: pretty_path)
53
+
54
+ in :file_end
55
+ with(
56
+ files_completed: files_completed + 1,
57
+ current_file: nil,
58
+ )
59
+
60
+ in :test_start
61
+ test_case = args[0]
62
+ desc = test_case.description.to_s
63
+ desc = "test #{args[1]}" if desc.empty?
64
+ with(current_test: desc)
65
+
66
+ in :test_end
67
+ with(current_test: nil)
68
+
69
+ in :test_result
70
+ result_packet = args[0]
71
+ updated_state = with(total_tests: total_tests + 1)
72
+
73
+ case result_packet.status
74
+ when :passed
75
+ updated_state.with(passed: passed + 1)
76
+ when :failed
77
+ updated_state.with(failed: failed + 1)
78
+ when :error
79
+ updated_state.with(errors: errors + 1)
80
+ else
81
+ updated_state
82
+ end
83
+
84
+ else
85
+ # Unknown event, return unchanged state
86
+ self
87
+ end
88
+ end
89
+
90
+ # Computed properties
91
+ def elapsed_time
92
+ start_time ? Time.now - start_time : 0
93
+ end
94
+
95
+ def issues_count
96
+ failed + errors
97
+ end
98
+
99
+ def passed_count
100
+ total_tests - issues_count
101
+ end
102
+
103
+ def has_issues?
104
+ issues_count > 0
105
+ end
106
+
107
+ def tests_run?
108
+ total_tests > 0
109
+ end
110
+
111
+ def files_remaining
112
+ total_files - files_completed
113
+ end
114
+
115
+ def completion_percentage
116
+ return 0 if total_files == 0
117
+
118
+ (files_completed.to_f / total_files * 100).round(1)
119
+ end
120
+ end
121
+ end
122
+ end