minitest-heat 0.0.2 → 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -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