mutation_tester 1.2.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +83 -0
- data/LICENSE.txt +19 -0
- data/Rakefile +57 -0
- data/docs/ci.md +158 -0
- data/docs/execution-runners.md +163 -0
- data/docs/json-schema.md +171 -0
- data/docs/mutation-types.md +207 -0
- data/examples/calculator.rb +35 -0
- data/examples/calculator_100_perc_spec.rb +121 -0
- data/examples/calculator_minitest.rb +53 -0
- data/examples/calculator_spec.rb +105 -0
- data/examples/github_actions/ai_mutation_gate.yml +75 -0
- data/examples/github_actions/mutation_test.yml +87 -0
- data/examples/hooks/pre-push +41 -0
- data/examples/run_example.rb +31 -0
- data/examples/run_example_minitest.rb +38 -0
- data/examples/run_example_parallel_minitest.rb +48 -0
- data/examples/run_example_parallel_rspec.rb +48 -0
- data/examples/run_example_rspec.rb +38 -0
- data/examples/spec_helper.rb +21 -0
- data/exe/mutation_test +345 -0
- data/lib/mutation_tester/batch_runner.rb +286 -0
- data/lib/mutation_tester/configuration.rb +105 -0
- data/lib/mutation_tester/core.rb +193 -0
- data/lib/mutation_tester/fork_runner/worker.rb +202 -0
- data/lib/mutation_tester/fork_runner.rb +382 -0
- data/lib/mutation_tester/framework_detector.rb +25 -0
- data/lib/mutation_tester/in_memory_loader.rb +25 -0
- data/lib/mutation_tester/mutation_runner.rb +644 -0
- data/lib/mutation_tester/mutator.rb +874 -0
- data/lib/mutation_tester/progress_display.rb +130 -0
- data/lib/mutation_tester/railtie.rb +7 -0
- data/lib/mutation_tester/rake_task.rb +18 -0
- data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
- data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
- data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
- data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
- data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
- data/lib/mutation_tester/test_command.rb +165 -0
- data/lib/mutation_tester/version.rb +3 -0
- data/lib/mutation_tester.rb +52 -0
- data/lib/tasks/mutation_tester.rake +80 -0
- data/readme.md +925 -0
- metadata +213 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module MutationTester
|
|
2
|
+
class ProgressDisplay
|
|
3
|
+
attr_reader :total, :current, :current_mutation
|
|
4
|
+
|
|
5
|
+
def initialize(total, config, output_stream: $stdout.clone)
|
|
6
|
+
@output_stream = output_stream
|
|
7
|
+
@total = total
|
|
8
|
+
@current = 0
|
|
9
|
+
@current_mutation = nil
|
|
10
|
+
@config = config
|
|
11
|
+
@start_time = Time.now
|
|
12
|
+
@spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
13
|
+
@spinner_index = 0
|
|
14
|
+
@lock = Mutex.new
|
|
15
|
+
@spinner_thread = nil
|
|
16
|
+
@stop_spinner = false
|
|
17
|
+
|
|
18
|
+
start_spinner if @config.show_progress
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def update(mutation, index)
|
|
22
|
+
@lock.synchronize do
|
|
23
|
+
@current = index
|
|
24
|
+
@current_mutation = mutation
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def finish
|
|
29
|
+
@lock.synchronize do
|
|
30
|
+
@stop_spinner = true
|
|
31
|
+
if @config.show_progress
|
|
32
|
+
clear_line
|
|
33
|
+
elapsed = Time.now - @start_time
|
|
34
|
+
@output_stream.puts " #{Rainbow("Completed in #{format_duration(elapsed)}").green}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
stop_spinner
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def render(already_locked = false)
|
|
43
|
+
return unless @config.show_progress
|
|
44
|
+
|
|
45
|
+
if already_locked
|
|
46
|
+
_render_internal
|
|
47
|
+
else
|
|
48
|
+
@lock.synchronize { _render_internal }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def _render_internal
|
|
53
|
+
clear_line
|
|
54
|
+
|
|
55
|
+
progress = (@current.to_f / @total * 100).round(1)
|
|
56
|
+
progress_bar = create_progress_bar(progress)
|
|
57
|
+
spinner = @spinner_frames[@spinner_index]
|
|
58
|
+
|
|
59
|
+
mutation_info = @current_mutation ? " | #{@current_mutation[:type]} | line #{@current_mutation[:line]}" : ''
|
|
60
|
+
|
|
61
|
+
line = "#{Rainbow(spinner).magenta} [#{progress_bar}] #{Rainbow(progress.to_s + "%").bright} | " \
|
|
62
|
+
"#{Rainbow("#{@current}/#{@total}").cyan} mutations processed#{mutation_info}"
|
|
63
|
+
|
|
64
|
+
@output_stream.print line
|
|
65
|
+
@output_stream.flush
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def create_progress_bar(percentage)
|
|
69
|
+
width = 20
|
|
70
|
+
filled = (percentage / 100.0 * width).round
|
|
71
|
+
empty = width - filled
|
|
72
|
+
|
|
73
|
+
Rainbow('█' * filled).green + Rainbow('░' * empty).white
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clear_line
|
|
77
|
+
@output_stream.print "\r"
|
|
78
|
+
@output_stream.print ' ' * 100
|
|
79
|
+
@output_stream.print "\r"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def format_duration(seconds)
|
|
83
|
+
if seconds < 60
|
|
84
|
+
"#{seconds.round(1)}s"
|
|
85
|
+
elsif seconds < 3600
|
|
86
|
+
minutes = (seconds / 60).floor
|
|
87
|
+
secs = (seconds % 60).round
|
|
88
|
+
"#{minutes}m #{secs}s"
|
|
89
|
+
else
|
|
90
|
+
hours = (seconds / 3600).floor
|
|
91
|
+
minutes = ((seconds % 3600) / 60).floor
|
|
92
|
+
"#{hours}h #{minutes}m"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def start_spinner
|
|
97
|
+
return if @spinner_thread
|
|
98
|
+
|
|
99
|
+
@stop_spinner = false
|
|
100
|
+
@spinner_thread = Thread.new do
|
|
101
|
+
loop do
|
|
102
|
+
should_stop = @lock.synchronize do
|
|
103
|
+
if @stop_spinner
|
|
104
|
+
true
|
|
105
|
+
else
|
|
106
|
+
@spinner_index = (@spinner_index + 1) % @spinner_frames.length
|
|
107
|
+
render(true) if @config.show_progress
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
break if should_stop
|
|
113
|
+
|
|
114
|
+
sleep 0.1
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@spinner_thread.abort_on_exception = false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def stop_spinner
|
|
122
|
+
return unless @spinner_thread
|
|
123
|
+
|
|
124
|
+
@stop_spinner = true
|
|
125
|
+
@spinner_thread.wakeup if @spinner_thread.status == 'sleep'
|
|
126
|
+
@spinner_thread.join
|
|
127
|
+
@spinner_thread = nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require 'rake'
|
|
2
|
+
|
|
3
|
+
module MutationTester
|
|
4
|
+
RAKE_TASKS_PATH = File.expand_path('../tasks/mutation_tester.rake', __dir__)
|
|
5
|
+
BENCH_TASKS_PATH = File.expand_path('../tasks/bench.rake', __dir__)
|
|
6
|
+
|
|
7
|
+
@rake_tasks_loaded = false
|
|
8
|
+
|
|
9
|
+
def self.load_rake_tasks
|
|
10
|
+
return if @rake_tasks_loaded
|
|
11
|
+
|
|
12
|
+
@rake_tasks_loaded = true
|
|
13
|
+
load RAKE_TASKS_PATH
|
|
14
|
+
load BENCH_TASKS_PATH if File.exist?(BENCH_TASKS_PATH)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
MutationTester.load_rake_tasks
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
module MutationTester
|
|
2
|
+
module Reporters
|
|
3
|
+
class BaseReporter
|
|
4
|
+
attr_reader :results, :source_file, :spec_file, :config
|
|
5
|
+
|
|
6
|
+
def self.score(results)
|
|
7
|
+
effective_killed = results.count { |r| %i[killed timeout].include?(status_for(r)) }
|
|
8
|
+
scored = results.count { |r| %i[killed timeout survived].include?(status_for(r)) }
|
|
9
|
+
return 0.0 if scored.zero?
|
|
10
|
+
|
|
11
|
+
(effective_killed.to_f / scored * 100).round(2)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.status_for(result)
|
|
15
|
+
result[:status] || (result[:killed] ? :killed : :survived)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(results, source_file, spec_file, config, interrupted: false)
|
|
19
|
+
@results = results
|
|
20
|
+
@source_file = source_file
|
|
21
|
+
@spec_file = spec_file
|
|
22
|
+
@config = config
|
|
23
|
+
@interrupted = interrupted
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def interrupted?
|
|
27
|
+
@interrupted
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def generate
|
|
31
|
+
raise NotImplementedError, 'Subclasses must implement #generate'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
protected
|
|
35
|
+
|
|
36
|
+
def status_of(result)
|
|
37
|
+
self.class.status_for(result)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def count_with_status(status)
|
|
41
|
+
@results.count { |r| status_of(r) == status }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def killed_count
|
|
45
|
+
count_with_status(:killed)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def survived_count
|
|
49
|
+
count_with_status(:survived)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def timeout_count
|
|
53
|
+
count_with_status(:timeout)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stillborn_count
|
|
57
|
+
count_with_status(:stillborn)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def error_count
|
|
61
|
+
count_with_status(:error)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def effective_killed_count
|
|
65
|
+
killed_count + timeout_count
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def total_count
|
|
69
|
+
@results.size
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mutation_score
|
|
73
|
+
self.class.score(@results)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def quality_rating
|
|
77
|
+
score = mutation_score
|
|
78
|
+
if score >= 90 then 'Excellent 🌟'
|
|
79
|
+
elsif score >= 75 then 'Good 👍'
|
|
80
|
+
elsif score >= 60 then 'Fair 😐'
|
|
81
|
+
elsif score >= 40 then 'Poor 😟'
|
|
82
|
+
else 'Critical ⚠️'
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def survived_mutations
|
|
87
|
+
@results.select { |r| status_of(r) == :survived }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
DIFF_CONTEXT_LINES = 2
|
|
91
|
+
DIFF_DETAIL_STATUSES = %i[survived timeout].freeze
|
|
92
|
+
|
|
93
|
+
def survivor_groups
|
|
94
|
+
survived_mutations
|
|
95
|
+
.group_by { |r| [r[:file_path], r[:line]] }
|
|
96
|
+
.map { |(file_path, line), mutations| { file_path: file_path, line: line, mutations: mutations } }
|
|
97
|
+
.sort_by { |group| group[:line].to_i }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def diff_lines(mutations)
|
|
101
|
+
primary = mutations.first
|
|
102
|
+
context = source_context_for(primary)
|
|
103
|
+
return diff_without_context(mutations) unless context
|
|
104
|
+
|
|
105
|
+
lines, first, last = context
|
|
106
|
+
hunk = "@@ -#{first},#{last - first + 1} +#{first},#{last - first + mutations.size} @@"
|
|
107
|
+
(first..last).each_with_object([[:hunk, hunk, nil]]) do |number, out|
|
|
108
|
+
if number == primary[:line]
|
|
109
|
+
out << [:removed, lines[number - 1], nil]
|
|
110
|
+
indent = lines[number - 1][/\A\s*/]
|
|
111
|
+
mutations.each { |m| out << [:added, indent + replacement_text(m), m] }
|
|
112
|
+
else
|
|
113
|
+
out << [:context, lines[number - 1], nil]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def diff_without_context(mutations)
|
|
121
|
+
primary = mutations.first
|
|
122
|
+
removed = primary[:source_line] || primary[:original].to_s
|
|
123
|
+
[[:removed, removed, nil]] + mutations.map { |m| [:added, replacement_text(m), m] }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def replacement_text(mutation)
|
|
127
|
+
mutation[:mutated_line] || mutation[:mutated].to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def source_context_for(mutation)
|
|
131
|
+
line = mutation[:line]
|
|
132
|
+
return nil unless line.is_a?(Integer) && line >= 1 && mutation[:source_line]
|
|
133
|
+
|
|
134
|
+
lines = cached_source_lines(mutation[:file_path] || @source_file)
|
|
135
|
+
return nil unless lines && lines[line - 1] && lines[line - 1].strip == mutation[:source_line].strip
|
|
136
|
+
|
|
137
|
+
first = [line - DIFF_CONTEXT_LINES, 1].max
|
|
138
|
+
last = [line + DIFF_CONTEXT_LINES, lines.size].min
|
|
139
|
+
[lines, first, last]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def cached_source_lines(path)
|
|
143
|
+
@cached_source_lines ||= {}
|
|
144
|
+
return @cached_source_lines[path] if @cached_source_lines.key?(path)
|
|
145
|
+
|
|
146
|
+
@cached_source_lines[path] = begin
|
|
147
|
+
File.read(path).lines.map(&:chomp)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module MutationTester
|
|
2
|
+
module Reporters
|
|
3
|
+
class BatchJsonReporter
|
|
4
|
+
SCHEMA_VERSION = 1
|
|
5
|
+
|
|
6
|
+
def initialize(result, config, passed:)
|
|
7
|
+
@result = result
|
|
8
|
+
@config = config
|
|
9
|
+
@passed = passed
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render
|
|
13
|
+
JSON.pretty_generate(envelope)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def envelope
|
|
19
|
+
{
|
|
20
|
+
schema_version: SCHEMA_VERSION,
|
|
21
|
+
summary: {
|
|
22
|
+
files: @result.processed.size + @result.skipped.size,
|
|
23
|
+
processed: @result.processed.size,
|
|
24
|
+
skipped: skipped_entries,
|
|
25
|
+
score: aggregate_score,
|
|
26
|
+
passed: @passed,
|
|
27
|
+
interrupted: @result.interrupted?
|
|
28
|
+
},
|
|
29
|
+
survivors: survivor_entries,
|
|
30
|
+
files: file_reports
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def skipped_entries
|
|
35
|
+
@result.skipped.map do |entry|
|
|
36
|
+
{
|
|
37
|
+
file: entry.source_file,
|
|
38
|
+
reason: BatchRunner::SKIP_REASONS.fetch(entry.reason || :no_spec)
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def survivor_entries
|
|
44
|
+
@result.survivors.map do |survivor|
|
|
45
|
+
{
|
|
46
|
+
file: File.expand_path(survivor[:file]),
|
|
47
|
+
line: survivor[:line],
|
|
48
|
+
type: survivor[:type],
|
|
49
|
+
original: survivor[:original],
|
|
50
|
+
mutated: survivor[:mutated]
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def aggregate_score
|
|
56
|
+
BaseReporter.score(@result.processed.flat_map { |entry| entry.results || [] })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def file_reports
|
|
60
|
+
@result.processed.map do |entry|
|
|
61
|
+
JsonReporter.new(
|
|
62
|
+
entry.results || [],
|
|
63
|
+
File.expand_path(entry.source_file),
|
|
64
|
+
File.expand_path(entry.spec_file),
|
|
65
|
+
@config,
|
|
66
|
+
interrupted: !!entry.interrupted
|
|
67
|
+
).report_data
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module MutationTester
|
|
2
|
+
module Reporters
|
|
3
|
+
class ConsoleReporter < BaseReporter
|
|
4
|
+
def generate
|
|
5
|
+
puts "\n" + Rainbow('=' * 80).bright
|
|
6
|
+
puts Rainbow('🧬 MUTATION TESTING REPORT').bright.cyan
|
|
7
|
+
puts Rainbow('=' * 80).bright
|
|
8
|
+
|
|
9
|
+
print_summary
|
|
10
|
+
print_survived_mutations if survived_count > 0
|
|
11
|
+
|
|
12
|
+
puts "\n" + Rainbow('=' * 80).bright
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def print_summary
|
|
18
|
+
puts "\n📊 Summary:"
|
|
19
|
+
puts " Total Mutations: #{total_count}"
|
|
20
|
+
puts " #{Rainbow("Killed: " + killed_count.to_s).green} ✅"
|
|
21
|
+
puts " #{Rainbow("Survived: " + survived_count.to_s).red} ❌"
|
|
22
|
+
puts " #{Rainbow("Timeout: " + timeout_count.to_s).yellow} ⏱️"
|
|
23
|
+
puts " #{Rainbow("Stillborn: " + stillborn_count.to_s).yellow} 🧬"
|
|
24
|
+
puts " #{Rainbow("Errors: " + error_count.to_s).yellow} 💥"
|
|
25
|
+
print_excluded_summary
|
|
26
|
+
print_selection_summary
|
|
27
|
+
puts " Mutation Score: #{Rainbow(mutation_score.to_s + "%").bright}"
|
|
28
|
+
puts " Quality: #{quality_rating}"
|
|
29
|
+
puts "\n " + progress_bar
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def print_excluded_summary
|
|
33
|
+
count = excluded_line_count
|
|
34
|
+
return unless count.positive?
|
|
35
|
+
|
|
36
|
+
puts " #{Rainbow("Excluded: " + count.to_s + " line(s) (mutation_tester:disable)").blue} 🚫"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def print_selection_summary
|
|
40
|
+
return unless selection_stats_available?
|
|
41
|
+
|
|
42
|
+
puts " #{Rainbow("Selection: #{subset_kill_count} kill(s) by test subset, #{full_kill_count} by full file").cyan} ⚡"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def selection_stats_available?
|
|
46
|
+
return false unless @config.test_selection && FrameworkDetector.detect(@spec_file) == :rspec
|
|
47
|
+
|
|
48
|
+
@config.runner != :in_memory || @results.any? { |result| result.key?(:kill_phase) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def subset_kill_count
|
|
52
|
+
kill_phase_count(:subset)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def full_kill_count
|
|
56
|
+
kill_phase_count(:full)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def kill_phase_count(phase)
|
|
60
|
+
@results.count { |r| r[:kill_phase] == phase }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def excluded_line_count
|
|
64
|
+
MutationTester::Mutator.disabled_lines(File.read(@source_file)).size
|
|
65
|
+
rescue StandardError
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def progress_bar
|
|
70
|
+
filled = (mutation_score / 5).to_i
|
|
71
|
+
empty = 20 - filled
|
|
72
|
+
bar = Rainbow('█' * filled).green + Rainbow('░' * empty).white
|
|
73
|
+
"[#{bar}] #{mutation_score}%"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def print_survived_mutations
|
|
77
|
+
puts "\n" + Rainbow('⚠️ Survived Mutations (Need Improvement):').yellow
|
|
78
|
+
puts Rainbow('-' * 80).yellow
|
|
79
|
+
|
|
80
|
+
survivor_groups.each { |group| print_survivor_group(group) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def print_survivor_group(group)
|
|
84
|
+
mutations = group[:mutations]
|
|
85
|
+
puts "\n #{format_location(mutations.first)}#{variant_count_suffix(mutations)}"
|
|
86
|
+
mutations.each do |mutation|
|
|
87
|
+
puts " #{Rainbow("##{mutation[:id]}").bright} [#{mutation[:type]}] #{mutation[:description]}"
|
|
88
|
+
end
|
|
89
|
+
puts
|
|
90
|
+
diff_lines(mutations).each do |kind, text, mutation|
|
|
91
|
+
puts render_diff_line(kind, text, mutation, mutations.size)
|
|
92
|
+
end
|
|
93
|
+
puts " 💡 Suggestion: #{suggestion_for(mutations)}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def variant_count_suffix(mutations)
|
|
97
|
+
mutations.size > 1 ? " (#{mutations.size} variants)" : ''
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def suggestion_for(mutations)
|
|
101
|
+
if mutations.size == 1
|
|
102
|
+
"Add test to verify behavior when #{mutations.first[:description].downcase}"
|
|
103
|
+
else
|
|
104
|
+
"Add tests to verify behavior for each of the #{mutations.size} variants above"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_diff_line(kind, text, mutation, variant_count)
|
|
109
|
+
case kind
|
|
110
|
+
when :hunk then " #{Rainbow(text).cyan}"
|
|
111
|
+
when :removed then " #{Rainbow('-').red} #{text}"
|
|
112
|
+
when :added then " #{Rainbow('+').green} #{text}#{variant_marker(mutation, variant_count)}"
|
|
113
|
+
else " #{text}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def variant_marker(mutation, variant_count)
|
|
118
|
+
variant_count > 1 ? " #{Rainbow("(##{mutation[:id]})").bright}" : ''
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def format_location(mutation)
|
|
122
|
+
if @config.show_file_path
|
|
123
|
+
"Location: #{Rainbow("#{mutation[:file_path]}:#{mutation[:line]}").cyan}"
|
|
124
|
+
else
|
|
125
|
+
"Line: #{Rainbow(mutation[:line].to_s).cyan}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|