minitest-heat 0.0.4 → 0.0.8

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,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