minitest-heat 0.0.2 → 0.0.6

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,118 @@
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
10
+
11
+ def_delegators :@results, :errors, :brokens, :failures, :skips, :painfuls, :slows, :problems?, :slows?
12
+
13
+ def initialize(results)
14
+ @results = results
15
+ @tokens = []
16
+ end
17
+
18
+ def tokens
19
+ @tokens << [*issue_counts_tokens] if issue_counts_tokens&.any?
20
+ @tokens << [assertions_performance_token, tests_performance_token, timing_token]
21
+ @tokens << [assertions_count_token, test_count_token]
22
+
23
+ @tokens
24
+ end
25
+
26
+ private
27
+
28
+ def pluralize(count, singular)
29
+ singular_style = "#{count} #{singular}"
30
+
31
+ # Given the narrow scope, pluralization can be relatively naive here
32
+ count > 1 ? "#{singular_style}s" : singular_style
33
+ end
34
+
35
+ def issue_counts_tokens
36
+ return unless problems? || slows?
37
+
38
+ counts = [
39
+ error_count_token,
40
+ broken_count_token,
41
+ failure_count_token,
42
+ skip_count_token,
43
+ painful_count_token,
44
+ slow_count_token
45
+ ].compact
46
+
47
+ # # Create an array of separator tokens one less than the total number of issue count tokens
48
+ separator_tokens = Array.new(counts.size, separator_token)
49
+
50
+ counts_with_separators = counts
51
+ .zip(separator_tokens) # Add separators between the counts
52
+ .flatten(1) # Flatten the zipped separators, but no more
53
+
54
+ counts_with_separators.pop # Remove the final trailing zipped separator that's not needed
55
+
56
+ counts_with_separators
57
+ end
58
+
59
+ def error_count_token
60
+ issue_count_token(:error, errors)
61
+ end
62
+
63
+ def broken_count_token
64
+ issue_count_token(:broken, brokens)
65
+ end
66
+
67
+ def failure_count_token
68
+ issue_count_token(:failure, failures)
69
+ end
70
+
71
+ def skip_count_token
72
+ style = problems? ? :muted : :skipped
73
+ issue_count_token(style, skips, name: 'Skip')
74
+ end
75
+
76
+ def painful_count_token
77
+ style = problems? ? :muted : :painful
78
+ issue_count_token(style, painfuls, name: 'Painfully Slow')
79
+ end
80
+
81
+ def slow_count_token
82
+ style = problems? ? :muted : :slow
83
+ issue_count_token(style, slows, name: 'Slow')
84
+ end
85
+
86
+ def assertions_performance_token
87
+ [:bold, "#{results.assertions_per_second} assertions/s"]
88
+ end
89
+
90
+ def tests_performance_token
91
+ [:default, " and #{results.tests_per_second} tests/s"]
92
+ end
93
+
94
+ def timing_token
95
+ [:default, " in #{results.total_time.round(2)}s"]
96
+ end
97
+
98
+ def assertions_count_token
99
+ [:muted, pluralize(results.assertion_count, 'Assertion')]
100
+ end
101
+
102
+ def test_count_token
103
+ [:muted, " across #{pluralize(results.test_count, 'Test')}"]
104
+ end
105
+
106
+ def issue_count_token(type, collection, name: type.capitalize)
107
+ return nil if collection.empty?
108
+
109
+ [type, pluralize(collection.size, name)]
110
+ end
111
+
112
+ def separator_token
113
+ [:muted, ' · ']
114
+ end
115
+ end
116
+ end
117
+ end
118
+ 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,99 +1,17 @@
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
- Token = Struct.new(:style, :content) do
8
- STYLES = {
9
- error: %i[bold red],
10
- broken: %i[bold red],
11
- failure: %i[default red],
12
- skipped: %i[bold yellow],
13
- success: %i[default green],
14
- slow: %i[bold green],
15
- source: %i[italic default],
16
- bold: %i[bold default],
17
- default: %i[default default],
18
- subtle: %i[light white],
19
- muted: %i[light gray],
20
- }.freeze
21
-
22
- WEIGHTS = {
23
- default: 0,
24
- bold: 1,
25
- light: 2,
26
- italic: 3,
27
- underline: 4,
28
- frame: 51,
29
- encircle: 52,
30
- overline: 53,
31
- }.freeze
32
-
33
- COLORS = {
34
- black: 30,
35
- red: 31,
36
- green: 32,
37
- yellow: 33,
38
- blue: 34,
39
- magenta: 35,
40
- cyan: 36,
41
- gray: 37, white: 97,
42
- default: 39,
43
- }.freeze
44
-
45
- def to_s
46
- "\e[#{weight};#{color}m#{content}#{reset}"
47
- end
48
-
49
- private
50
-
51
- def weight
52
- WEIGHTS.fetch(style_components[0])
53
- end
54
-
55
- def color
56
- COLORS.fetch(style_components[1])
57
- end
58
-
59
- def reset
60
- "\e[0m"
61
- end
62
-
63
- def style_components
64
- STYLES[style]
65
- end
66
- end
67
-
68
- FORMATTERS = {
69
- error: [
70
- [ %i[error label], %i[muted spacer], %i[error class], %i[muted arrow], %i[error test_name] ],
71
- [ %i[default summary], ],
72
- [ %i[default backtrace_summary] ],
73
- ],
74
- broken: [
75
- [ %i[broken label], %i[muted spacer], %i[broken test_class], %i[muted arrow], %i[broken test_name] ],
76
- [ %i[default summary], ],
77
- [ %i[default backtrace_summary] ],
78
- ],
79
- failure: [
80
- [ %i[failure label], %i[muted spacer], %i[failure test_class], %i[muted arrow], %i[failure test_name], %i[muted spacer], %i[muted class] ],
81
- [ %i[default summary] ],
82
- [ %i[subtle location], ],
83
- [ %i[default source_summary], ],
84
- ],
85
- skipped: [
86
- [ %i[skipped label], %i[muted spacer], %i[skipped test_class], %i[muted arrow], %i[skipped test_name] ],
87
- [ %i[default summary], %i[muted spacer], %i[default class] ],
88
- [], # New Line
89
- ],
90
- slow: [
91
- [ %i[slow label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name], %i[muted spacer], %i[muted class], ],
92
- [ %i[bold slowness], %i[muted spacer], %i[default location], ],
93
- [], # New Line
94
- ]
95
- }
96
-
97
15
  attr_reader :stream
98
16
 
99
17
  def initialize(stream = $stdout)
@@ -112,159 +30,50 @@ module Minitest
112
30
  end
113
31
  alias newline puts
114
32
 
115
- def marker(value)
116
- case value
117
- when 'E' then text(:error, value)
118
- when 'B' then text(:failure, value)
119
- when 'F' then text(:failure, value)
120
- when 'S' then text(:skipped, value)
121
- when 'T' then text(:slow, value)
122
- else text(:success, value)
123
- end
124
- end
125
-
126
33
  def issue_details(issue)
127
- formatter = FORMATTERS[issue.type]
128
-
129
- formatter.each do |lines|
130
- lines.each do |tokens|
131
- style, content_method = *tokens
132
-
133
- if issue.respond_to?(content_method)
134
- # If it's an available method on issue, use that to get the content
135
- content = issue.send(content_method)
136
- text(style, content)
137
- else
138
- # Otherwise, fall back to output and pass issue to *it*
139
- send(content_method, issue)
140
- end
141
- end
142
- newline
143
- end
34
+ print_tokens Minitest::Heat::Output::Issue.new(issue).tokens
144
35
  end
145
36
 
146
- def heat_map(map)
147
- # text(:default, "🔥 Hot Spots 🔥\n")
148
- map.files.each do |file|
149
- file = file[0]
150
- values = map.hits[file]
151
-
152
- filename = file.split('/').last
153
- path = file.delete_suffix(filename)
154
-
155
- text(:error, 'E' * values[:error].size) if values[:error]&.any?
156
- text(:broken, 'B' * values[:broken].size) if values[:broken]&.any?
157
- text(:failure, 'F' * values[:failure].size) if values[:failure]&.any?
158
- text(:skipped, 'S' * values[:skipped].size) if values[:skipped]&.any?
159
- text(:slow, 'S' * values[:skipped].size) if values[:skipped]&.any?
160
-
161
- text(:muted, ' ')
162
-
163
- text(:muted, "#{path.delete_prefix('/')}")
164
- text(:default, "#{filename}")
165
-
166
- text(:muted, ':')
167
-
168
- all_line_numbers = values.fetch(:error, []) + values.fetch(:failure, [])
169
- all_line_numbers += values.fetch(:skipped, [])
170
-
171
- line_numbers = all_line_numbers.compact.uniq.sort
172
- line_numbers.each { |line_number| text(:subtle, "#{line_number} ") }
173
- newline
174
- end
175
- newline
37
+ def marker(issue_type)
38
+ print_token Minitest::Heat::Output::Marker.new(issue_type).token
176
39
  end
177
40
 
178
41
  def compact_summary(results)
179
- error_count = results.errors.size
180
- broken_count = results.brokens.size
181
- failure_count = results.failures.size
182
- slow_count = results.slows.size
183
- skip_count = results.skips.size
184
-
185
- counts = []
186
- counts << pluralize(error_count, 'Error') if error_count.positive?
187
- counts << pluralize(broken_count, 'Broken') if broken_count.positive?
188
- counts << pluralize(failure_count, 'Failure') if failure_count.positive?
189
- counts << pluralize(skip_count, 'Skip') if skip_count.positive?
190
- counts << pluralize(slow_count, 'Slow') if slow_count.positive?
191
- text(:default, counts.join(', '))
192
-
193
42
  newline
194
- text(:subtle, "#{results.tests_per_second} tests/s and #{results.assertions_per_second} assertions/s ")
195
-
196
- newline
197
- text(:muted, pluralize(results.test_count, 'Test') + ' & ')
198
- text(:muted, pluralize(results.assertion_count, 'Assertion'))
199
- text(:muted, " in #{results.total_time.round(2)}s")
43
+ print_tokens ::Minitest::Heat::Output::Results.new(results).tokens
44
+ end
200
45
 
46
+ def heat_map(map)
201
47
  newline
202
- newline
48
+ print_tokens ::Minitest::Heat::Output::Map.new(map).tokens
203
49
  end
204
50
 
205
51
  private
206
52
 
207
- def test_name_summary(issue)
208
- text(:default, "#{issue.test_class} > #{issue.test_name}")
209
- end
210
-
211
- def backtrace_summary(issue)
212
- backtrace_lines = issue.backtrace.project
213
-
214
- backtrace_line = backtrace_lines.first
215
- filename = "#{backtrace_line.path.delete_prefix(Dir.pwd)}/#{backtrace_line.file}"
216
-
217
- backtrace_lines.take(3).each do |line|
218
- source = Minitest::Heat::Source.new(filename, line_number: line.number, max_line_count: 1)
219
-
220
- text(:muted, " #{line.path.delete_prefix("#{Dir.pwd}/")}/")
221
- text(:subtle, "#{line.file}:#{line.number}")
222
- text(:source, " `#{source.line.strip}`")
223
-
224
- newline
225
- end
226
- end
227
-
228
- def source_summary(issue)
229
- filename = issue.location.source_file
230
- line_number = issue.location.source_failure_line
231
-
232
- source = Minitest::Heat::Source.new(filename, line_number: line_number, max_line_count: 3)
233
- show_source(source, highlight_line: true, indentation: 2)
234
- end
235
-
236
- def show_source(source, indentation: 0, highlight_line: false)
237
- max_line_number_length = source.line_numbers.map(&:to_s).map(&:length).max
238
- source.lines.each_index do |i|
239
- line_number = source.line_numbers[i]
240
- line = source.lines[i]
241
-
242
- number_style, line_style = if line == source.line && highlight_line
243
- [:default, :default]
244
- else
245
- [:subtle, :subtle]
246
- end
247
- text(number_style, "#{' ' * indentation}#{line_number.to_s.rjust(max_line_number_length)} ")
248
- text(line_style, line)
249
- puts
250
- end
251
- end
252
-
253
53
  def style_enabled?
254
54
  stream.tty?
255
55
  end
256
56
 
257
- def pluralize(count, singular)
258
- singular_style = "#{count} #{singular}"
57
+ def text(style, content)
58
+ token = Token.new(style, content)
59
+ print token.to_s(token_format)
60
+ end
259
61
 
260
- # Given the narrow scope, pluralization can be relatively naive here
261
- count > 1 ? "#{singular_style}s" : singular_style
62
+ def token_format
63
+ style_enabled? ? :styled : :unstyled
262
64
  end
263
65
 
264
- def text(style, content)
265
- formatted_content = style_enabled? ? Token.new(style, content).to_s : content
66
+ def print_token(token)
67
+ print Token.new(*token).to_s(token_format)
68
+ end
266
69
 
267
- print formatted_content
70
+ def print_tokens(lines_of_tokens)
71
+ lines_of_tokens.each do |tokens|
72
+ tokens.each do |token|
73
+ print Token.new(*token).to_s(token_format)
74
+ end
75
+ newline
76
+ end
268
77
  end
269
78
  end
270
79
  end