minitest-heat 0.0.3 → 0.0.7

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,106 +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_bold: %i[bold green],
62
- success: %i[default green],
63
- slow: %i[default green],
64
- error: %i[bold red],
65
- broken: %i[bold red],
66
- failure: %i[default red],
67
- skipped: %i[bold yellow],
68
- warning_light: %i[light yellow],
69
- source: %i[italic default],
70
- bold: %i[bold default],
71
- default: %i[default default],
72
- muted: %i[light gray]
73
- }.fetch(style)
74
- end
75
- end
76
-
77
- FORMATTERS = {
78
- error: [
79
- [ %i[error label], %i[muted arrow], %i[default test_name] ],
80
- [ %i[default summary], ],
81
- [ %i[default backtrace_summary] ],
82
- ],
83
- broken: [
84
- [ %i[broken label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name] ],
85
- [ %i[default summary], ],
86
- [ %i[default backtrace_summary] ],
87
- ],
88
- failure: [
89
- [ %i[failure label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name], %i[muted spacer], %i[muted class] ],
90
- [ %i[default summary] ],
91
- [ %i[muted location], ],
92
- [ %i[default source_summary], ],
93
- ],
94
- skipped: [
95
- [ %i[skipped label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name] ],
96
- [ %i[default summary] ],
97
- [], # New Line
98
- ],
99
- slow: [
100
- [ %i[slow label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name], %i[muted spacer], %i[muted class], ],
101
- [ %i[bold slowness], %i[muted spacer], %i[default location], ],
102
- [], # New Line
103
- ]
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]} "],
104
25
  }
105
26
 
106
27
  attr_reader :stream
@@ -121,157 +42,50 @@ module Minitest
121
42
  end
122
43
  alias newline puts
123
44
 
124
- def marker(value)
125
- case value
126
- when 'E' then text(:error, value)
127
- when 'B' then text(:failure, value)
128
- when 'F' then text(:failure, value)
129
- when 'S' then text(:skipped, value)
130
- when '_' then text(:success, value)
131
- else text(:success, value)
132
- end
133
- end
134
-
135
45
  def issue_details(issue)
136
- formatter = FORMATTERS[issue.type]
137
-
138
- formatter.each do |lines|
139
- lines.each do |tokens|
140
- style, content_method = *tokens
141
-
142
- if issue.respond_to?(content_method)
143
- # If it's an available method on issue, use that to get the content
144
- content = issue.send(content_method)
145
- text(style, content)
146
- else
147
- # Otherwise, fall back to output and pass issue to *it*
148
- send(content_method, issue)
149
- end
150
- end
151
- newline
152
- end
46
+ print_tokens Minitest::Heat::Output::Issue.new(issue).tokens
153
47
  end
154
48
 
155
- def heat_map(map)
156
- # text(:default, "🔥 Hot Spots 🔥\n")
157
- map.files.each do |file|
158
- file = file[0]
159
- values = map.hits[file]
160
-
161
- filename = file.split('/').last
162
- path = file.delete_suffix(filename)
163
-
164
- text(:error, 'E' * values[:error].size) if values[:error]&.any?
165
- text(:broken, 'B' * values[:broken].size) if values[:broken]&.any?
166
- text(:failure, 'F' * values[:failure].size) if values[:failure]&.any?
167
- text(:skipped, 'S' * values[:skipped].size) if values[:skipped]&.any?
168
- text(:muted, ' ')
169
-
170
- text(:muted, "#{path.delete_prefix('/')}")
171
- text(:default, "#{filename}")
172
-
173
- text(:muted, ':')
174
-
175
- all_line_numbers = values.fetch(:error, []) + values.fetch(:failure, [])
176
- all_line_numbers += values.fetch(:skipped, [])
177
-
178
- line_numbers = all_line_numbers.compact.uniq.sort
179
- line_numbers.each { |line_number| text(:muted, "#{line_number} ") }
180
- newline
181
- end
182
- newline
49
+ def marker(issue_type)
50
+ print_token Minitest::Heat::Output::Marker.new(issue_type).token
183
51
  end
184
52
 
185
- def compact_summary(results)
186
- error_count = results.errors.size
187
- broken_count = results.brokens.size
188
- failure_count = results.failures.size
189
- slow_count = results.slows.size
190
- skip_count = results.skips.size
191
-
192
- counts = []
193
- counts << pluralize(error_count, 'Error') if error_count.positive?
194
- counts << pluralize(broken_count, 'Broken') if broken_count.positive?
195
- counts << pluralize(failure_count, 'Failure') if failure_count.positive?
196
- counts << pluralize(skip_count, 'Skip') if skip_count.positive?
197
- counts << pluralize(slow_count, 'Slow') if slow_count.positive?
198
- text(:default, counts.join(', '))
199
-
53
+ def compact_summary(results, timer)
200
54
  newline
201
- text(:muted, "#{results.tests_per_second} tests/s and #{results.assertions_per_second} assertions/s ")
202
-
203
- newline
204
- text(:muted, pluralize(results.test_count, 'Test') + ' & ')
205
- text(:muted, pluralize(results.assertion_count, 'Assertion'))
206
- text(:muted, " in #{results.total_time.round(2)}s")
55
+ print_tokens ::Minitest::Heat::Output::Results.new(results, timer).tokens
56
+ end
207
57
 
58
+ def heat_map(map)
208
59
  newline
209
- newline
60
+ print_tokens ::Minitest::Heat::Output::Map.new(map).tokens
210
61
  end
211
62
 
212
63
  private
213
64
 
214
- def test_name_summary(issue)
215
- text(:default, "#{issue.test_class} > #{issue.test_name}")
216
- end
217
-
218
- def backtrace_summary(issue)
219
- backtrace_lines = issue.backtrace.project
220
-
221
- backtrace_line = backtrace_lines.first
222
- filename = "#{backtrace_line.path.delete_prefix(Dir.pwd)}/#{backtrace_line.file}"
223
-
224
- backtrace_lines.take(3).each do |line|
225
- source = Minitest::Heat::Source.new(filename, line_number: line.number, max_line_count: 1)
226
-
227
- text(:muted, " #{line.path.delete_prefix("#{Dir.pwd}/")}/")
228
- text(:muted, "#{line.file}:#{line.number}")
229
- text(:source, " `#{source.line.strip}`")
230
-
231
- newline
232
- end
233
- end
234
-
235
- def source_summary(issue)
236
- filename = issue.location.source_file
237
- line_number = issue.location.source_failure_line
238
-
239
- source = Minitest::Heat::Source.new(filename, line_number: line_number, max_line_count: 3)
240
- show_source(source, highlight_line: true, indentation: 2)
241
- end
242
-
243
- def show_source(source, indentation: 0, highlight_line: false)
244
- max_line_number_length = source.line_numbers.map(&:to_s).map(&:length).max
245
- source.lines.each_index do |i|
246
- line_number = source.line_numbers[i]
247
- line = source.lines[i]
248
-
249
- number_style, line_style = if line == source.line && highlight_line
250
- [:default, :default]
251
- else
252
- [:muted, :muted]
253
- end
254
- text(number_style, "#{' ' * indentation}#{line_number.to_s.rjust(max_line_number_length)} ")
255
- text(line_style, line)
256
- puts
257
- end
258
- end
259
-
260
65
  def style_enabled?
261
66
  stream.tty?
262
67
  end
263
68
 
264
- def pluralize(count, singular)
265
- singular_style = "#{count} #{singular}"
69
+ def text(style, content)
70
+ token = Token.new(style, content)
71
+ print token.to_s(token_format)
72
+ end
266
73
 
267
- # Given the narrow scope, pluralization can be relatively naive here
268
- count > 1 ? "#{singular_style}s" : singular_style
74
+ def token_format
75
+ style_enabled? ? :styled : :unstyled
269
76
  end
270
77
 
271
- def text(style, content)
272
- 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
273
81
 
274
- 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
275
89
  end
276
90
  end
277
91
  end