minitest-heat 0.0.3 → 0.0.7

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