Class: Henitai::Reporter::Terminal

Inherits:
Base
  • Object
show all
Includes:
UnparseHelper
Defined in:
lib/henitai/reporter.rb,
sig/henitai.rbs

Overview

Terminal reporter.

Constant Summary collapse

PROGRESS_GLYPHS =

Returns:

  • (Hash[Symbol, String])
{
  killed: "·",
  survived: "S",
  timeout: "T",
  ignored: "I"
}.freeze

Instance Attribute Summary collapse

Attributes inherited from Base

#config, #history_store

Instance Method Summary collapse

Methods included from UnparseHelper

#fallback_source, #safe_unparse

Methods inherited from Base

#authoritative?, #canonical_path

Constructor Details

#initialize(config:, history_store: nil, color_enabled: !ENV.key?("NO_COLOR"))) ⇒ Terminal

Returns a new instance of Terminal.

Parameters:



87
88
89
90
# File 'lib/henitai/reporter.rb', line 87

def initialize(config:, history_store: nil, color_enabled: !ENV.key?("NO_COLOR"))
  super(config:, history_store:)
  @color_enabled = color_enabled
end

Instance Attribute Details

#color_enabledBoolean (readonly)

Returns the value of attribute color_enabled.

Returns:

  • (Boolean)


112
113
114
# File 'lib/henitai/reporter.rb', line 112

def color_enabled
  @color_enabled
end

Instance Method Details

#append_survivor_stats(lines, result) ⇒ void

This method returns an undefined value.

Parameters:

  • (Array[String])
  • (Object)


186
187
188
189
190
191
192
193
194
195
196
# File 'lib/henitai/reporter.rb', line 186

def append_survivor_stats(lines, result)
  return unless result.respond_to?(:survivor_stats)

  stats = result.survivor_stats # : Hash[Symbol, untyped]?
  return unless stats

  lines << format_row("Matched", stats.fetch(:matched))
  lines << format_row("Skipped", stats.fetch(:skipped_count, 0))
  lines << format_row("Unmatched", stats.fetch(:unmatched_count))
  lines << format_row("Drift warning", stats.fetch(:drift_warning) ? "yes" : "no")
end

#colorize(text, color) ⇒ String

Parameters:

  • (String)
  • (String)

Returns:

  • (String)


309
310
311
312
313
# File 'lib/henitai/reporter.rb', line 309

def colorize(text, color)
  return text unless color_enabled

  "\e[#{color}m#{text}\e[0m"
end

#count_status(result, status) ⇒ Integer

Parameters:

Returns:

  • (Integer)


284
285
286
# File 'lib/henitai/reporter.rb', line 284

def count_status(result, status)
  result.mutants.count { |mutant| mutant.status == status }
end

#display_mutated_source(mutant) ⇒ String

Prefer the mutant's memoized unparse (shared with the JSON reporter); string literals keep the visible-escape rendering below.

Parameters:

  • (Object)

Returns:

  • (String)


232
233
234
235
236
237
238
# File 'lib/henitai/reporter.rb', line 232

def display_mutated_source(mutant)
  node = mutant.mutated_node
  return node.children.first.inspect if str_node?(node)
  return mutant.mutated_source if mutant.respond_to?(:mutated_source)

  display_unparse(node)
end

#display_unparse(node) ⇒ String

Like safe_unparse but makes invisible characters visible in terminal output. For string literal nodes the inner value is shown via #inspect so that e.g. "" vs " " vs "\n" are unambiguous. Nodes parsed from real source render their original source slice — unparsing is expensive and this path runs once per survivor; synthetic nodes unparse as before.

Parameters:

  • (Object)

Returns:

  • (String)


245
246
247
248
249
# File 'lib/henitai/reporter.rb', line 245

def display_unparse(node)
  return node.children.first.inspect if str_node?(node)

  source_slice(node) || safe_unparse(node)
end

#executed_only_score_line(result) ⇒ String?

Cached verdicts make the combined MS/MSI partly synthetic; the executed-only pair keeps this run's own signal visible.

Parameters:

Returns:

  • (String, nil)


163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/henitai/reporter.rb', line 163

def executed_only_score_line(result)
  return nil unless result.respond_to?(:executed_scoring_summary)

  summary = result.executed_scoring_summary # : Hash[Symbol, untyped]?
  return nil unless summary

  format(
    "Executed-only MS %<score>s | MSI %<indicator>s",
    score: format_percent(summary[:mutation_score]),
    indicator: format_percent(summary[:mutation_score_indicator])
  )
end

#flushvoid

This method returns an undefined value.



325
326
327
# File 'lib/henitai/reporter.rb', line 325

def flush
  $stdout.flush
end

#format_duration(duration) ⇒ String

Parameters:

  • (Float)

Returns:

  • (String)


288
289
290
# File 'lib/henitai/reporter.rb', line 288

def format_duration(duration)
  format("%.2fs", duration)
end

#format_percent(value) ⇒ String

Parameters:

  • (Float, nil)

Returns:

  • (String)


292
293
294
# File 'lib/henitai/reporter.rb', line 292

def format_percent(value)
  value.nil? ? "n/a" : format("%.2f%%", value)
end

#format_row(label, value) ⇒ String

Parameters:

  • (String)
  • (Object)

Returns:

  • (String)


280
281
282
# File 'lib/henitai/reporter.rb', line 280

def format_row(label, value)
  format("%<label>-12s %<value>s", label:, value:)
end

#full_summary_lines(result) ⇒ Array[String]

Parameters:

  • (Object)

Returns:

  • (Array[String])


130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/henitai/reporter.rb', line 130

def full_summary_lines(result)
  [
    "Mutation testing summary",
    score_line(result),
    format_row("Killed", count_status(result, :killed)),
    format_row("Survived", count_status(result, :survived)),
    format_row("Timeout", count_status(result, :timeout)),
    format_row("No coverage", count_status(result, :no_coverage)),
    format_row("Duration", format_duration(result.duration)),
    reused_verdicts_line(result),
    executed_only_score_line(result)
  ].compact
end

#mutated_line(mutant) ⇒ String

Parameters:

Returns:

  • (String)


226
227
228
# File 'lib/henitai/reporter.rb', line 226

def mutated_line(mutant)
  format("+ %s", display_mutated_source(mutant))
end

#original_line(mutant) ⇒ String

Parameters:

Returns:

  • (String)


222
223
224
# File 'lib/henitai/reporter.rb', line 222

def original_line(mutant)
  format("- %s", display_unparse(mutant.original_node))
end

#partial_summary_lines(result) ⇒ Array[String]

Parameters:

  • (Object)

Returns:

  • (Array[String])


176
177
178
179
180
181
182
183
184
# File 'lib/henitai/reporter.rb', line 176

def partial_summary_lines(result)
  lines = [
    "Partial survivor rerun",
    format_row("Survived", count_status(result, :survived)),
    format_row("Duration", format_duration(result.duration))
  ]
  append_survivor_stats(lines, result)
  lines
end

#progress(mutant, scenario_result: nil) ⇒ void

This method returns an undefined value.

Parameters:



96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/henitai/reporter.rb', line 96

def progress(mutant, scenario_result: nil)
  glyph = PROGRESS_GLYPHS[mutant.status]
  return unless glyph

  print(glyph)
  return flush unless should_show_logs?(scenario_result)

  output = scenario_output(scenario_result)
  print("\n")
  print("log: #{scenario_result.log_path}\n")
  print(output) unless output.empty?
  $stdout.flush
end

#report(result) ⇒ void

This method returns an undefined value.

Parameters:



92
93
94
# File 'lib/henitai/reporter.rb', line 92

def report(result)
  puts report_lines(result)
end

#report_lines(result) ⇒ Array[String]

Parameters:

Returns:

  • (Array[String])


114
115
116
117
118
119
120
# File 'lib/henitai/reporter.rb', line 114

def report_lines(result)
  lines = summary_lines(result)
  detail_lines = survived_detail_lines(result)
  return lines if detail_lines.empty?

  lines + [""] + detail_lines
end

#reused_mutants(result) ⇒ Array[Mutant]

Parameters:

Returns:



157
158
159
# File 'lib/henitai/reporter.rb', line 157

def reused_mutants(result)
  result.mutants.select { |mutant| mutant.respond_to?(:from_cache?) && mutant.from_cache? }
end

#reused_verdicts_line(result) ⇒ String?

Parameters:

Returns:

  • (String, nil)


144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/henitai/reporter.rb', line 144

def reused_verdicts_line(result)
  reused = reused_mutants(result)
  return nil if reused.empty?

  format(
    "%<reused>d of %<total>d verdicts reused from history (%<killed>d killed, %<survived>d survived)",
    reused: reused.size,
    total: result.mutants.size,
    killed: reused.count { |mutant| mutant.status == :killed },
    survived: reused.count { |mutant| mutant.status == :survived }
  )
end

#scenario_output(scenario_result) ⇒ String

Parameters:

Returns:

  • (String)


321
322
323
# File 'lib/henitai/reporter.rb', line 321

def scenario_output(scenario_result)
  scenario_result.failure_tail(all_logs: config.all_logs)
end

#score_color(score) ⇒ String?

Parameters:

  • (Float, nil)

Returns:

  • (String, nil)


296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/henitai/reporter.rb', line 296

def score_color(score)
  return nil if score.nil?

  thresholds = config.thresholds || {}
  high = thresholds.fetch(:high, 80)
  low = thresholds.fetch(:low, 60)

  return "32" if score >= high
  return "33" if score >= low

  "31"
end

#score_line(result) ⇒ String

Parameters:

Returns:

  • (String)


266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/henitai/reporter.rb', line 266

def score_line(result)
  summary = result.scoring_summary
  line = [
    format("MS %s", format_percent(summary[:mutation_score])),
    format("MSI %s", format_percent(summary[:mutation_score_indicator])),
    format(
      "Equivalence uncertainty %s",
      summary[:equivalence_uncertainty] || "n/a"
    )
  ].join(" | ")
  color = score_color(summary[:mutation_score])
  color ? colorize(line, color) : line
end

#should_show_logs?(scenario_result) ⇒ Boolean

Parameters:

Returns:

  • (Boolean)


315
316
317
318
319
# File 'lib/henitai/reporter.rb', line 315

def should_show_logs?(scenario_result)
  return false unless scenario_result.respond_to?(:failure_tail)

  scenario_result.should_show_logs?(all_logs: config.all_logs)
end

#source_slice(node) ⇒ String?

Single-line original source of a parsed node; nil for synthetic nodes or multi-line slices (those fall back to normalized unparsing).

Parameters:

  • (Object)

Returns:

  • (String, nil)


257
258
259
260
261
262
263
264
# File 'lib/henitai/reporter.rb', line 257

def source_slice(node)
  return nil unless node.respond_to?(:location)

  source = node.location&.expression&.source
  source unless source.nil? || source.include?("\n")
rescue StandardError
  nil
end

#str_node?(node) ⇒ Boolean

Parameters:

  • (Object)

Returns:

  • (Boolean)


251
252
253
# File 'lib/henitai/reporter.rb', line 251

def str_node?(node)
  node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
end

#summary_lines(result) ⇒ Array[String]

Parameters:

  • (Object)

Returns:

  • (Array[String])


122
123
124
125
126
127
128
# File 'lib/henitai/reporter.rb', line 122

def summary_lines(result)
  if result.respond_to?(:partial_rerun?) && result.partial_rerun?
    partial_summary_lines(result)
  else
    full_summary_lines(result)
  end
end

#survived_detail_lines(result) ⇒ Array[String]

Parameters:

Returns:

  • (Array[String])


198
199
200
201
202
203
# File 'lib/henitai/reporter.rb', line 198

def survived_detail_lines(result)
  survivors = result.mutants.select(&:survived?)
  return [] if survivors.empty?

  ["Survived mutants"] + survivors.flat_map { |mutant| survived_mutant_lines(mutant) }
end

#survived_mutant_header(mutant) ⇒ String

Parameters:

Returns:

  • (String)


213
214
215
216
217
218
219
220
# File 'lib/henitai/reporter.rb', line 213

def survived_mutant_header(mutant)
  format(
    "%<file>s:%<line>d %<operator>s",
    file: mutant.location.fetch(:file),
    line: mutant.location.fetch(:start_line),
    operator: mutant.operator
  )
end

#survived_mutant_lines(mutant) ⇒ Array[String]

Parameters:

Returns:

  • (Array[String])


205
206
207
208
209
210
211
# File 'lib/henitai/reporter.rb', line 205

def survived_mutant_lines(mutant)
  [
    survived_mutant_header(mutant),
    original_line(mutant),
    mutated_line(mutant)
  ]
end