minitest-heat 0.0.4 → 0.0.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ class Results
7
+ extend Forwardable
8
+
9
+ attr_accessor :results, :timer
10
+
11
+ def_delegators :@results, :issues, :errors, :brokens, :failures, :skips, :painfuls, :slows, :problems?
12
+
13
+ def initialize(results, timer)
14
+ @results = results
15
+ @timer = timer
16
+ @tokens = []
17
+ end
18
+
19
+ def tokens
20
+ # Only show the issue type counts if there are issues
21
+ @tokens << [*issue_counts_tokens] if issue_counts_tokens&.any?
22
+
23
+ @tokens << [
24
+ timing_token, spacer_token,
25
+ test_count_token, tests_performance_token, join_token,
26
+ assertions_count_token, assertions_performance_token
27
+ ]
28
+
29
+ @tokens
30
+ end
31
+
32
+ private
33
+
34
+ def pluralize(count, singular)
35
+ singular_style = "#{count} #{singular}"
36
+
37
+ # Given the narrow scope, pluralization can be relatively naive here
38
+ count > 1 ? "#{singular_style}s" : singular_style
39
+ end
40
+
41
+ def issue_counts_tokens
42
+ return unless issues.any?
43
+
44
+ counts = [
45
+ error_count_token,
46
+ broken_count_token,
47
+ failure_count_token,
48
+ skip_count_token,
49
+ painful_count_token,
50
+ slow_count_token
51
+ ].compact
52
+
53
+ # # Create an array of separator tokens one less than the total number of issue count tokens
54
+ spacer_tokens = Array.new(counts.size, spacer_token)
55
+
56
+ counts_with_separators = counts
57
+ .zip(spacer_tokens) # Add separators between the counts
58
+ .flatten(1) # Flatten the zipped separators, but no more
59
+
60
+ counts_with_separators.pop # Remove the final trailing zipped separator that's not needed
61
+
62
+ counts_with_separators
63
+ end
64
+
65
+ def error_count_token
66
+ issue_count_token(:error, errors)
67
+ end
68
+
69
+ def broken_count_token
70
+ issue_count_token(:broken, brokens)
71
+ end
72
+
73
+ def failure_count_token
74
+ issue_count_token(:failure, failures)
75
+ end
76
+
77
+ def skip_count_token
78
+ style = problems? ? :muted : :skipped
79
+ issue_count_token(style, skips, name: 'Skip')
80
+ end
81
+
82
+ def painful_count_token
83
+ style = problems? || skips.any? ? :muted : :painful
84
+ issue_count_token(style, painfuls, name: 'Painfully Slow')
85
+ end
86
+
87
+ def slow_count_token
88
+ style = problems? || skips.any? ? :muted : :slow
89
+ issue_count_token(style, slows, name: 'Slow')
90
+ end
91
+
92
+ def test_count_token
93
+ [:default, "#{pluralize(timer.test_count, 'test')}"]
94
+ end
95
+
96
+ def tests_performance_token
97
+ [:default, " (#{timer.tests_per_second}/s)"]
98
+ end
99
+
100
+ def assertions_count_token
101
+ [:default, "#{pluralize(timer.assertion_count, 'assertion')}"]
102
+ end
103
+
104
+ def assertions_performance_token
105
+ [:default, " (#{timer.assertions_per_second}/s)"]
106
+ end
107
+
108
+ def timing_token
109
+ [:bold, "#{timer.total_time.round(2)}s"]
110
+ end
111
+
112
+ def issue_count_token(type, collection, name: type.capitalize)
113
+ return nil if collection.empty?
114
+
115
+ [type, pluralize(collection.size, name)]
116
+ end
117
+
118
+ def spacer_token
119
+ Output::TOKENS[:spacer]
120
+ end
121
+
122
+ def join_token
123
+ [:default, ' with ']
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ # Builds the collection of tokens representing a specific set of source code lines
7
+ class SourceCode
8
+ DEFAULT_LINE_COUNT = 3
9
+ DEFAULT_INDENTATION_SPACES = 2
10
+ HIGHLIGHT_KEY_LINE = true
11
+
12
+ attr_reader :filename, :line_number, :max_line_count
13
+
14
+ # Provides a collection of tokens representing the output of source code
15
+ # @param filename [String] the absolute path to the file containing the source code
16
+ # @param line_number [Integer, String] the primary line number of interest for the file
17
+ # @param max_line_count: DEFAULT_LINE_COUNT [Integer] maximum total number of lines to
18
+ # retrieve around the target line (including the target line)
19
+ #
20
+ # @return [self]
21
+ def initialize(filename, line_number, max_line_count: DEFAULT_LINE_COUNT)
22
+ @filename = filename
23
+ @line_number = line_number.to_s
24
+ @max_line_count = max_line_count
25
+ @tokens = []
26
+ end
27
+
28
+ # The collection of style content tokens to print
29
+ #
30
+ # @return [Array<Array<Token>>] an array of arrays of tokens where each top-level array
31
+ # represents a line where the first element is the line_number and the second is the line
32
+ # of code to display
33
+ def tokens
34
+ source.lines.each_index do |i|
35
+ current_line_number = source.line_numbers[i]
36
+ current_line_of_code = source.lines[i]
37
+
38
+ number_style, line_style = styles_for(current_line_of_code)
39
+
40
+ @tokens << [
41
+ line_number_token(number_style, current_line_number),
42
+ line_of_code_token(line_style, current_line_of_code)
43
+ ]
44
+ end
45
+ @tokens
46
+ end
47
+
48
+ # The number of digits for the largest line number returned. This is used for formatting and
49
+ # text justification so that line numbers are right-aligned
50
+ #
51
+ # @return [Integer] the number of digits in the longest line number returned
52
+ def max_line_number_digits
53
+ source
54
+ .line_numbers
55
+ .map(&:to_s)
56
+ .map(&:length)
57
+ .max
58
+ end
59
+
60
+ # Whether to visually highlight the target line when displaying the source code. Currently
61
+ # defauls to true, but long-term, this is a likely candidate to be configurable. For
62
+ # example, in the future, highlighting could only be used if the source includes more than
63
+ # three lines. Or it could be something end users could disable in order to reduce noise.
64
+ #
65
+ # @return [Boolean] true if the target line should be highlighted
66
+ def highlight_key_line?
67
+ HIGHLIGHT_KEY_LINE
68
+ end
69
+
70
+ # The number of spaces each line of code should be indented. Currently defaults to 2 in
71
+ # order to provide visual separation between test failures, but in the future, it could
72
+ # be configurable in order to save horizontal space and create more compact output. For
73
+ # example, it could be smart based on line length and total available horizontal terminal
74
+ # space, or there could be higher-level "display" setting that could have a `:compact`
75
+ # option that would reduce the space used.
76
+ #
77
+ # @return [type] [description]
78
+ def indentation
79
+ DEFAULT_INDENTATION_SPACES
80
+ end
81
+
82
+ private
83
+
84
+ # The source instance for retrieving the relevant lines of source code
85
+ #
86
+ # @return [Source] a Minitest::Heat::Source instance
87
+ def source
88
+ @source ||= Minitest::Heat::Source.new(
89
+ filename,
90
+ line_number: line_number,
91
+ max_line_count: max_line_count
92
+ )
93
+ end
94
+
95
+ # Determines how to style a given line of code token. For now, it's only used for
96
+ # highlighting the targeted line of code, but it could also be adjusted to mute the line
97
+ # number or otherwise change the styling of how lines of code are displayed
98
+ # @param line_of_code [String] the content representing the line of code we're currently
99
+ # generating a token for
100
+ #
101
+ # @return [Array<Symbol>] the Token styles for the line number and line of code
102
+ def styles_for(line_of_code)
103
+ if line_of_code == source.line && highlight_key_line?
104
+ %i[default default]
105
+ else
106
+ %i[muted muted]
107
+ end
108
+ end
109
+
110
+ # The token representing a given line number. Adds the appropriate indention and
111
+ # justification to right align the line numbers
112
+ # @param style [Symbol] the symbol representing the style for the line number token
113
+ # @param line_number [Integer,String] the digits representing the line number
114
+ #
115
+ # @return [Array] the style/content token for the current line number
116
+ def line_number_token(style, line_number)
117
+ [style, "#{' ' * indentation}#{line_number.to_s.rjust(max_line_number_digits)} "]
118
+ end
119
+
120
+ # The token representing the content of a given line of code.
121
+ # @param style [Symbol] the symbol representing the style for the line of code token
122
+ # @param line_number [Integer,String] the content of the line of code
123
+ #
124
+ # @return [Array] the style/content token for the current line of code
125
+ def line_of_code_token(style, line_of_code)
126
+ [style, line_of_code]
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ # Friendly API for printing nicely-formatted output to the console
6
+ class Output
7
+ class Token
8
+ class InvalidStyle < ArgumentError; end
9
+
10
+ STYLES = {
11
+ success: %i[default green],
12
+ slow: %i[default green],
13
+ painful: %i[bold green],
14
+ error: %i[bold red],
15
+ broken: %i[bold red],
16
+ failure: %i[default red],
17
+ skipped: %i[default yellow],
18
+ warning_light: %i[light yellow],
19
+ italicized: %i[italic gray],
20
+ bold: %i[bold default],
21
+ default: %i[default default],
22
+ muted: %i[light gray]
23
+ }.freeze
24
+
25
+ attr_accessor :style_key, :content
26
+
27
+ def initialize(style_key, content)
28
+ @style_key = style_key
29
+ @content = content
30
+ end
31
+
32
+ def to_s(format = :styled)
33
+ return content unless format == :styled
34
+
35
+ [
36
+ style_string,
37
+ content,
38
+ reset_string
39
+ ].join
40
+ end
41
+
42
+ def eql?(other)
43
+ style_key == other.style_key && content == other.content
44
+ end
45
+ alias :== eql?
46
+
47
+ private
48
+
49
+ ESC_SEQUENCE = "\e["
50
+ END_SEQUENCE = 'm'
51
+
52
+ WEIGHTS = {
53
+ default: 0,
54
+ bold: 1,
55
+ light: 2,
56
+ italic: 3
57
+ }.freeze
58
+
59
+ COLORS = {
60
+ black: 30,
61
+ red: 31,
62
+ green: 32,
63
+ yellow: 33,
64
+ blue: 34,
65
+ magenta: 35,
66
+ cyan: 36,
67
+ gray: 37,
68
+ default: 39
69
+ }.freeze
70
+
71
+ def style_string
72
+ "#{ESC_SEQUENCE}#{weight};#{color}#{END_SEQUENCE}"
73
+ end
74
+
75
+ def reset_string
76
+ "#{ESC_SEQUENCE}0#{END_SEQUENCE}"
77
+ end
78
+
79
+ def weight_key
80
+ style_components[0]
81
+ end
82
+
83
+ def color_key
84
+ style_components[1]
85
+ end
86
+
87
+ def weight
88
+ WEIGHTS.fetch(weight_key)
89
+ end
90
+
91
+ def color
92
+ COLORS.fetch(color_key)
93
+ end
94
+
95
+ def style_components
96
+ STYLES.fetch(style_key) { raise InvalidStyle, "'#{style_key}' is not a valid style option for tokens" }
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -1,105 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'output/backtrace'
4
+ require_relative 'output/issue'
5
+ require_relative 'output/map'
6
+ require_relative 'output/marker'
7
+ require_relative 'output/results'
8
+ require_relative 'output/source_code'
9
+ require_relative 'output/token'
10
+
3
11
  module Minitest
4
12
  module Heat
5
13
  # Friendly API for printing nicely-formatted output to the console
6
14
  class Output
7
- ESC = "\e["
8
-
9
- Token = Struct.new(:style, :content) do
10
- def to_s
11
- [
12
- style_string,
13
- content,
14
- reset_string
15
- ].join
16
- end
17
-
18
- private
19
-
20
- def style_string
21
- "#{ESC}#{weight};#{color}m"
22
- end
23
-
24
- def reset_string
25
- "#{ESC}0m"
26
- end
27
-
28
- def weight_key
29
- style_components[0]
30
- end
31
-
32
- def color_key
33
- style_components[1]
34
- end
35
-
36
- def weight
37
- {
38
- default: 0,
39
- bold: 1,
40
- light: 2,
41
- italic: 3
42
- }.fetch(weight_key)
43
- end
44
-
45
- def color
46
- {
47
- black: 30,
48
- red: 31,
49
- green: 32,
50
- yellow: 33,
51
- blue: 34,
52
- magenta: 35,
53
- cyan: 36,
54
- gray: 37,
55
- default: 39
56
- }.fetch(color_key)
57
- end
58
-
59
- def style_components
60
- {
61
- success: %i[default green],
62
- slow: %i[bold green],
63
- error: %i[bold red],
64
- broken: %i[bold red],
65
- failure: %i[default red],
66
- skipped: %i[default yellow],
67
- warning_light: %i[light yellow],
68
- source: %i[italic default],
69
- bold: %i[bold default],
70
- default: %i[default default],
71
- muted: %i[light gray]
72
- }.fetch(style)
73
- end
74
- end
75
-
76
- FORMATTERS = {
77
- error: [
78
- [ %i[error label], %i[muted arrow], %i[default test_name] ],
79
- [ %i[default summary], ],
80
- [ %i[default backtrace_summary] ],
81
- ],
82
- broken: [
83
- [ %i[broken label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name] ],
84
- [ %i[default summary], ],
85
- [ %i[default backtrace_summary] ],
86
- ],
87
- failure: [
88
- [ %i[failure label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name], %i[muted spacer], %i[muted class] ],
89
- [ %i[default summary] ],
90
- [ %i[muted location], ],
91
- [ %i[default source_summary], ],
92
- ],
93
- skipped: [
94
- [ %i[skipped label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name] ],
95
- [ %i[default summary] ],
96
- [], # New Line
97
- ],
98
- slow: [
99
- [ %i[slow label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name], %i[muted spacer], %i[muted class], ],
100
- [ %i[bold slowness], %i[muted spacer], %i[default location], ],
101
- [], # New Line
102
- ]
15
+ SYMBOLS = {
16
+ middot: '·',
17
+ arrow: '➜',
18
+ lead: '|',
19
+ }.freeze
20
+
21
+ TOKENS = {
22
+ spacer: [:muted, " #{SYMBOLS[:middot]} "],
23
+ muted_arrow: [:muted, " #{SYMBOLS[:arrow]} "],
24
+ muted_lead: [:muted, "#{SYMBOLS[:lead]} "],
103
25
  }
104
26
 
105
27
  attr_reader :stream
@@ -120,156 +42,50 @@ module Minitest
120
42
  end
121
43
  alias newline puts
122
44
 
123
- def marker(value)
124
- case value
125
- when 'E' then text(:error, value)
126
- when 'B' then text(:failure, value)
127
- when 'F' then text(:failure, value)
128
- when 'S' then text(:skipped, value)
129
- else text(:success, value)
130
- end
131
- end
132
-
133
45
  def issue_details(issue)
134
- formatter = FORMATTERS[issue.type]
135
-
136
- formatter.each do |lines|
137
- lines.each do |tokens|
138
- style, content_method = *tokens
139
-
140
- if issue.respond_to?(content_method)
141
- # If it's an available method on issue, use that to get the content
142
- content = issue.send(content_method)
143
- text(style, content)
144
- else
145
- # Otherwise, fall back to output and pass issue to *it*
146
- send(content_method, issue)
147
- end
148
- end
149
- newline
150
- end
46
+ print_tokens Minitest::Heat::Output::Issue.new(issue).tokens
151
47
  end
152
48
 
153
- def heat_map(map)
154
- # text(:default, "🔥 Hot Spots 🔥\n")
155
- map.files.each do |file|
156
- file = file[0]
157
- values = map.hits[file]
158
-
159
- filename = file.split('/').last
160
- path = file.delete_suffix(filename)
161
-
162
- text(:error, 'E' * values[:error].size) if values[:error]&.any?
163
- text(:broken, 'B' * values[:broken].size) if values[:broken]&.any?
164
- text(:failure, 'F' * values[:failure].size) if values[:failure]&.any?
165
- text(:skipped, 'S' * values[:skipped].size) if values[:skipped]&.any?
166
- text(:muted, ' ') if values[:error]&.any? || values[:broken]&.any? || values[:failure]&.any? || values[:skipped]&.any?
167
-
168
- text(:muted, "#{path.delete_prefix('/')}")
169
- text(:default, "#{filename}")
170
-
171
- text(:muted, ':')
172
-
173
- all_line_numbers = values.fetch(:error, []) + values.fetch(:failure, [])
174
- all_line_numbers += values.fetch(:skipped, [])
175
-
176
- line_numbers = all_line_numbers.compact.uniq.sort
177
- line_numbers.each { |line_number| text(:muted, "#{line_number} ") }
178
- newline
179
- end
180
- newline
49
+ def marker(issue_type)
50
+ print_token Minitest::Heat::Output::Marker.new(issue_type).token
181
51
  end
182
52
 
183
- def compact_summary(results)
184
- error_count = results.errors.size
185
- broken_count = results.brokens.size
186
- failure_count = results.failures.size
187
- slow_count = results.slows.size
188
- skip_count = results.skips.size
189
-
190
- counts = []
191
- counts << pluralize(error_count, 'Error') if error_count.positive?
192
- counts << pluralize(broken_count, 'Broken') if broken_count.positive?
193
- counts << pluralize(failure_count, 'Failure') if failure_count.positive?
194
- counts << pluralize(skip_count, 'Skip') if skip_count.positive?
195
- counts << pluralize(slow_count, 'Slow') if slow_count.positive?
196
- text(:default, counts.join(', '))
197
-
53
+ def compact_summary(results, timer)
198
54
  newline
199
- text(:muted, "#{results.tests_per_second} tests/s and #{results.assertions_per_second} assertions/s ")
200
-
201
- newline
202
- text(:muted, pluralize(results.test_count, 'Test') + ' & ')
203
- text(:muted, pluralize(results.assertion_count, 'Assertion'))
204
- text(:muted, " in #{results.total_time.round(2)}s")
55
+ print_tokens ::Minitest::Heat::Output::Results.new(results, timer).tokens
56
+ end
205
57
 
58
+ def heat_map(map)
206
59
  newline
207
- newline
60
+ print_tokens ::Minitest::Heat::Output::Map.new(map).tokens
208
61
  end
209
62
 
210
63
  private
211
64
 
212
- def test_name_summary(issue)
213
- text(:default, "#{issue.test_class} > #{issue.test_name}")
214
- end
215
-
216
- def backtrace_summary(issue)
217
- backtrace_lines = issue.backtrace.project
218
-
219
- backtrace_line = backtrace_lines.first
220
- filename = "#{backtrace_line.path.delete_prefix(Dir.pwd)}/#{backtrace_line.file}"
221
-
222
- backtrace_lines.take(3).each do |line|
223
- source = Minitest::Heat::Source.new(filename, line_number: line.number, max_line_count: 1)
224
-
225
- text(:muted, " #{line.path.delete_prefix("#{Dir.pwd}/")}/")
226
- text(:muted, "#{line.file}:#{line.number}")
227
- text(:source, " `#{source.line.strip}`")
228
-
229
- newline
230
- end
231
- end
232
-
233
- def source_summary(issue)
234
- filename = issue.location.source_file
235
- line_number = issue.location.source_failure_line
236
-
237
- source = Minitest::Heat::Source.new(filename, line_number: line_number, max_line_count: 3)
238
- show_source(source, highlight_line: true, indentation: 2)
239
- end
240
-
241
- def show_source(source, indentation: 0, highlight_line: false)
242
- max_line_number_length = source.line_numbers.map(&:to_s).map(&:length).max
243
- source.lines.each_index do |i|
244
- line_number = source.line_numbers[i]
245
- line = source.lines[i]
246
-
247
- number_style, line_style = if line == source.line && highlight_line
248
- [:default, :default]
249
- else
250
- [:muted, :muted]
251
- end
252
- text(number_style, "#{' ' * indentation}#{line_number.to_s.rjust(max_line_number_length)} ")
253
- text(line_style, line)
254
- puts
255
- end
256
- end
257
-
258
65
  def style_enabled?
259
66
  stream.tty?
260
67
  end
261
68
 
262
- def pluralize(count, singular)
263
- singular_style = "#{count} #{singular}"
69
+ def text(style, content)
70
+ token = Token.new(style, content)
71
+ print token.to_s(token_format)
72
+ end
264
73
 
265
- # Given the narrow scope, pluralization can be relatively naive here
266
- count > 1 ? "#{singular_style}s" : singular_style
74
+ def token_format
75
+ style_enabled? ? :styled : :unstyled
267
76
  end
268
77
 
269
- def text(style, content)
270
- formatted_content = style_enabled? ? Token.new(style, content).to_s : content
78
+ def print_token(token)
79
+ print Token.new(*token).to_s(token_format)
80
+ end
271
81
 
272
- print formatted_content
82
+ def print_tokens(lines_of_tokens)
83
+ lines_of_tokens.each do |tokens|
84
+ tokens.each do |token|
85
+ print Token.new(*token).to_s(token_format)
86
+ end
87
+ newline
88
+ end
273
89
  end
274
90
  end
275
91
  end