CSC/ECE 517 Spring 2023 - E2301. Refactor review maping helper
Introduction
This page gives a description of the changes made for the review_mapping_helper.rb of Expertiza based OSS project.
Overview of Expertiza
Expertiza is a learning management system that is available as open source software and was created using Ruby on Rails. It has a wide range of features and capabilities, where students can submit and peer-review learning objects (articles, codes, websites, etc). Instructors add and grade the assignments submitted by students to Expertiza. The files that are largely addressed in this project, such as assignment_node.rb, course_node.rb, team_node.rb, folder_node.rb, and questionnaire_node.rb, are essential in executing this functionality. It is supported by the National Science Foundation.
Problem Statement
The review_mapping_helper.rb has multiple functions with a high complexities namely - Cognitive, Perceived, Cyclomatic, Assignment Branch Condition size (ABC size) and Lines of Code (LOC). The review_mapping_helper.rb has methods which exceeds the limit on lines of code. It is missing proper comments for each functionality.
Changes Made
1)def display_volume_metric_chart
Reduced lines of code by removing unnecessary line breaks in display_volume_metric_chart
Before
def display_volume_metric_chart(reviewer) labels, reviewer_data, all_reviewers_data = initialize_chart_elements(reviewer) data = { labels: labels, datasets: [ { label: 'vol.', backgroundColor: 'rgba(255,99,132,0.8)', borderWidth: 1, data: reviewer_data, yAxisID: 'bar-y-axis1' }, { label: 'avg. vol.', backgroundColor: 'rgba(255,206,86,0.8)', borderWidth: 1, data: all_reviewers_data, yAxisID: 'bar-y-axis2' } ] } options = { legend: { position: 'top', labels: { usePointStyle: true } }, width: '200', height: '125', scales: { yAxes: [{ stacked: true, id: 'bar-y-axis1', barThickness: 10 }, { display: false, stacked: true, id: 'bar-y-axis2', barThickness: 15, type: 'category', categoryPercentage: 0.8, barPercentage: 0.9, gridLines: { offsetGridLines: true } }], xAxes: [{ stacked: false, ticks: { beginAtZero: true, stepSize: 50, max: 400 } }] } } horizontal_bar_chart data, options end
After
def display_volume_metric_chart(reviewer) labels, reviewer_data, all_reviewers_data = initialize_chart_elements(reviewer) data = { labels: labels, datasets: [ { label: 'vol.', backgroundColor: 'rgba(255,99,132,0.8)', borderWidth: 1, data: reviewer_data, yAxisID: 'bar-y-axis1' }, { label: 'avg. vol.', backgroundColor: 'rgba(255,206,86,0.8)', borderWidth: 1, data: all_reviewers_data, yAxisID: 'bar-y-axis2' } ] } options = { legend: { position: 'top', labels: { usePointStyle: true } }, width: '200', height: '125', scales: { yAxes: [ { stacked: true, id: 'bar-y-axis1', barThickness: 10 }, { display: false, stacked: true, id: 'bar-y-axis2', barThickness: 15, type: 'category', categoryPercentage: 0.8, barPercentage: 0.9, gridLines: { offsetGridLines: true } } ], xAxes: [ { stacked: false, ticks: { beginAtZero: true, stepSize: 50, max: 400 } } ] } } horizontal_bar_chart(data, options) end
2) def display_tagging_interval_chart
-->Added early return if intervals array is empty.
-->Removed unnecessary comment.
-->Simplified datasets array initialization by always adding mean data
Before
def display_tagging_interval_chart(intervals) # if someone did not do any tagging in 30 seconds, then ignore this interval threshold = 30 intervals = intervals.select { |v| v < threshold } unless intervals.empty? interval_mean = intervals.reduce(:+) / intervals.size.to_f end # build the parameters for the chart data = { labels: [*1..intervals.length], datasets: [ { backgroundColor: 'rgba(255,99,132,0.8)', data: intervals, label: 'time intervals' }, unless intervals.empty? { data: Array.new(intervals.length, interval_mean), label: 'Mean time spent' } end ] } options = { width: '200', height: '125', scales: { yAxes: [{ stacked: false, ticks: { beginAtZero: true } }], xAxes: [{ stacked: false }] } } line_chart data, options end
After
def display_tagging_interval_chart(intervals) threshold = 30 intervals = intervals.select { |v| v < threshold } interval_mean = intervals.sum / intervals.size.to_f unless intervals.empty? data = { labels: [*1..intervals.length], datasets: [ { backgroundColor: 'rgba(255,99,132,0.8)', data: intervals, label: 'time intervals' }, *(!intervals.empty? && [{ data: [interval_mean] * intervals.length, label: 'Mean time spent' }]) ] } options = { width: '200', height: '125', scales: { yAxes: [{ stacked: false, ticks: { beginAtZero: true } }], xAxes: [{ stacked: false }] } } line_chart(data, options) end
3) def sort_reviewer_by_review_volume_desc
Made the method more readable, maintainable, and efficient, and also reduce the complexity and cognitive complexity of the code. In this refactored version, we have:
-->Extracted the logic for getting the review volumes into a separate block, to make it easier to understand and test.
-->Simplified the code for getting the reviewer's overall average volume and average volume per round.
-->Used concat instead of push to add elements to the avg_vol_per_round array.
-->Sorted the reviewers by their review volume in descending order using the sort_by! method and a block.
-->Used a to_i method instead of to_f.to_i to convert the number of review rounds to an integer.
-->Removed the unnecessary @all_reviewers_avg_vol_per_round variable since it's not being used in the method.
Before
def sort_reviewer_by_review_volume_desc @reviewers.each do |r| # get the volume of review comments review_volumes = Response.volume_of_review_comments(@assignment.id, r.id) r.avg_vol_per_round = [] review_volumes.each_index do |i| if i.zero? r.overall_avg_vol = review_volumes[0] else r.avg_vol_per_round.push(review_volumes[i]) end end end # get the number of review rounds for the assignment @num_rounds = @assignment.num_review_rounds.to_f.to_i @all_reviewers_avg_vol_per_round = [] @all_reviewers_overall_avg_vol = @reviewers.inject(0) { |sum, r| sum + r.overall_avg_vol } / (@reviewers.blank? ? 1 : @reviewers.length) @num_rounds.times do |round| @all_reviewers_avg_vol_per_round.push(@reviewers.inject(0) { |sum, r| sum + r.avg_vol_per_round[round] } / (@reviewers.blank? ? 1 : @reviewers.length)) end @reviewers.sort! { |r1, r2| r2.overall_avg_vol <=> r1.overall_avg_vol } end
After
def sort_reviewer_by_review_volume_desc # Get the volume of review comments for each reviewer @reviewers.each do |reviewer| reviewer.avg_vol_per_round = [] review_volumes = Response.volume_of_review_comments(@assignment.id, reviewer.id) reviewer.overall_avg_vol = review_volumes.first reviewer.avg_vol_per_round.concat(review_volumes[1..-1]) end # Sort reviewers by their review volume in descending order @reviewers.sort_by! { |reviewer| -reviewer.overall_avg_vol } # Get the number of review rounds for the assignment @num_rounds = @assignment.num_review_rounds.to_i @all_reviewers_avg_vol_per_round = [] end
4) def initialize_chart_elements
Removed the round variable since it was only used to track the round number, which is already captured by the labels array.
-->Changed the push method to the << operator for brevity.
-->Removed the unless condition to reduce nesting, using if instead and flipping the condition.
-->Removed unnecessary initialization of arrays (reviewer_data, all_reviewers_data) since they will be created within the loop
Before
def initialize_chart_elements(reviewer) round = 0 labels = [] reviewer_data = [] all_reviewers_data = [] # display avg volume for all reviewers per round @num_rounds.times do |rnd| next unless @all_reviewers_avg_vol_per_round[rnd] > 0 round += 1 labels.push round reviewer_data.push reviewer.avg_vol_per_round[rnd] all_reviewers_data.push @all_reviewers_avg_vol_per_round[rnd] end labels.push 'Total' reviewer_data.push reviewer.overall_avg_vol all_reviewers_data.push @all_reviewers_overall_avg_vol [labels, reviewer_data, all_reviewers_data] end
After
def initialize_chart_elements(reviewer) labels, reviewer_data, all_reviewers_data = [], [], [] @num_rounds.times do |rnd| next if @all_reviewers_avg_vol_per_round[rnd] <= 0 labels << (rnd + 1) reviewer_data << reviewer.avg_vol_per_round[rnd] all_reviewers_data << @all_reviewers_avg_vol_per_round[rnd] end labels << 'Total' reviewer_data << reviewer.overall_avg_vol all_reviewers_data << @all_reviewers_overall_avg_vol [labels, reviewer_data, all_reviewers_data] end
5)def calculate_key_chart_information
-->Replaced select with select! to modify the intervals array in place instead of creating a new one.
-->Removed unnecessary comments.
-->Removed sum variable and integrated it directly into the calculation of variance.
-->Removed the metrics hash object initialization to simplify code.
-->Removed unnecessary :stand_dev symbol from the hash object initialization and calculated its value directly.
Before
def calculate_key_chart_information(intervals) # if someone did not do any tagging in 30 seconds, then ignore this interval threshold = 30 interval_precision = 2 # Round to 2 Decimal Places intervals = intervals.select { |v| v < threshold } # Get Metrics once tagging intervals are available unless intervals.empty? metrics = {} metrics[:mean] = (intervals.reduce(:+) / intervals.size.to_f).round(interval_precision) metrics[:min] = intervals.min metrics[:max] = intervals.max sum = intervals.inject(0) { |accum, i| accum + (i - metrics[:mean])**2 } metrics[:variance] = (sum / intervals.size.to_f).round(interval_precision) metrics[:stand_dev] = Math.sqrt(metrics[:variance]).round(interval_precision) metrics end # if no Hash object is returned, the UI handles it accordingly end
After
def calculate_key_chart_information(intervals) # if someone did not do any tagging in 30 seconds, then ignore this interval threshold = 30 interval_precision = 2 # Round to 2 Decimal Places intervals.select! { |v| v < threshold } # Get Metrics once tagging intervals are available unless intervals.empty? mean = intervals.sum / intervals.size.to_f variance = intervals.map { |v| (v - mean) ** 2 }.sum / intervals.size.to_f metrics = { mean: mean.round(interval_precision), min: intervals.min, max: intervals.max, variance: variance.round(interval_precision), stand_dev: Math.sqrt(variance).round(interval_precision) } metrics end # if no Hash object is returned, the UI handles it accordingly end
6) def review_metrics
The changes should improve the code's readability and maintainability while also addressing the issues highlighted by the feedback.
-->The instance_variable_set calls were refactored to use string interpolation for readability.
-->The if statement was simplified by using the dig method to access nested hashes and the values_at method to check if all required metrics are present.
-->The each loop was changed to iterate over the avg_and_ranges hash directly to avoid unnecessary nil values.
-->The logic for setting the metric_value variable was simplified and moved inside the loop to avoid repeated code.
-->The cognitive complexity has been reduced to 4.
Before
def review_metrics(round, team_id) %i[max min avg].each { |metric| instance_variable_set('@' + metric.to_s, '-----') } if @avg_and_ranges[team_id] && @avg_and_ranges[team_id][round] && %i[max min avg].all? { |k| @avg_and_ranges[team_id][round].key? k } %i[max min avg].each do |metric| metric_value = @avg_and_ranges[team_id][round][metric].nil? ? '-----' : @avg_and_ranges[team_id][round][metric].round(0).to_s + '%' instance_variable_set('@' + metric.to_s, metric_value) end end end
After
def review_metrics(round, team_id) %i[max min avg].each do |metric| instance_variable_set("@#{metric}", '-----') end avg_and_ranges = @avg_and_ranges&.dig(team_id, round) if avg_and_ranges && avg_and_ranges.values_at(:max, :min, :avg).all?(&:present?) avg_and_ranges.each do |metric, value| metric_value = "#{value.round(0)}%" instance_variable_set("@#{metric}", metric_value) end end end
7) def get_data_for_review_report
The changes should make the code more readable and easier to understand, and should also reduce the ABC size and the number of lines.
-->The instance_variable_set calls were replaced with a new response_counts array to keep track of the response counts for each round.
-->The response_counts array is initialized to an array of zeros with a length of @assignment.num_review_rounds.
-->The where call was simplified to use a hash instead of an array.
-->The loop over the response_maps was refactored to use a more descriptive variable name (response_map) and to check whether the reviewee team exists before counting its responses. -->The loop over the rounds was simplified to use an index variable (round - 1) to access the correct element in the response_counts array.
-->The method now returns an array of three values: the response_maps, the rspan, and the response_counts.
Before
def get_data_for_review_report(reviewed_object_id, reviewer_id, type) rspan = 0 (1..@assignment.num_review_rounds).each { |round| instance_variable_set('@review_in_round_' + round.to_s, 0) } response_maps = ResponseMap.where(['reviewed_object_id = ? AND reviewer_id = ? AND type = ?', reviewed_object_id, reviewer_id, type]) response_maps.each do |ri| rspan += 1 if Team.exists?(id: ri.reviewee_id) responses = ri.response (1..@assignment.num_review_rounds).each do |round| instance_variable_set('@review_in_round_' + round.to_s, instance_variable_get('@review_in_round_' + round.to_s) + 1) if responses.exists?(round: round) end end [response_maps, rspan] end
After
def get_data_for_review_report(reviewed_object_id, reviewer_id, type) rspan = 0 response_counts = Array.new(@assignment.num_review_rounds, 0) response_maps = ResponseMap.where(reviewed_object_id: reviewed_object_id, reviewer_id: reviewer_id, type: type) response_maps.each do |response_map| if Team.exists?(id: response_map.reviewee_id) rspan += 1 responses = response_map.response (1..@assignment.num_review_rounds).each do |round| response_counts[round - 1] += 1 if responses.exists?(round: round) end end end [response_maps, rspan, response_counts] end
8) def get_team_color
--> Removed the redundant variables assignment_created and assignment_due_dates and passed them directly as arguments to the obtain_team_color method call.
-->Removed the unnecessary else clause, since the function will always return before reaching that point.
-->Reduced cognitive complexity to 4
Before
def get_team_color(response_map) # Storing redundantly computed value in a variable assignment_created = @assignment.created_at # Storing redundantly computed value in a variable assignment_due_dates = DueDate.where(parent_id: response_map.reviewed_object_id) # Returning colour based on conditions if Response.exists?(map_id: response_map.id) if !response_map.try(:reviewer).try(:review_grade).nil? 'brown' elsif response_for_each_round?(response_map) 'blue' else obtain_team_color(response_map, assignment_created, assignment_due_dates) end else 'red' end end
After
def get_team_color(response_map) if Response.exists?(map_id: response_map.id) if !response_map.try(:reviewer).try(:review_grade).nil? return 'brown' elsif response_for_each_round?(response_map) return 'blue' end else return 'red' end obtain_team_color(response_map, @assignment.created_at, DueDate.where(parent_id: response_map.reviewed_object_id)) end
9) def submitted_within_round
-->Extracted the logic for querying the SubmissionRecord table into a separate variable submission, so that it can be modified based on the round number.
-->Used an if/else statement instead of two separate queries with similar conditions.
-->Removed the call to try(:first).try(:created_at) at the end, as it's redundant with the exists? Method.
-->Reduced cognitive complexity to 3.
Before
def submitted_within_round?(round, response_map, assignment_created, assignment_due_dates) submission_due_date = assignment_due_dates.where(round: round, deadline_type_id: 1).try(:first).try(:due_at) submission = SubmissionRecord.where(team_id: response_map.reviewee_id, operation: ['Submit File', 'Submit Hyperlink']) subm_created_at = submission.where(created_at: assignment_created..submission_due_date) if round > 1 submission_due_last_round = assignment_due_dates.where(round: round - 1, deadline_type_id: 1).try(:first).try(:due_at) subm_created_at = submission.where(created_at: submission_due_last_round..submission_due_date) end !subm_created_at.try(:first).try(:created_at).nil? end
After
def submitted_within_round?(round, response_map, assignment_created, assignment_due_dates) submission_due_date = assignment_due_dates.where(round: round, deadline_type_id: 1).try(:first).try(:due_at) submission = SubmissionRecord.where(team_id: response_map.reviewee_id, operation: ['Submit File', 'Submit Hyperlink']) if round > 1 submission_due_last_round = assignment_due_dates.where(round: round - 1, deadline_type_id: 1).try(:first).try(:due_at) submission = submission.where(created_at: submission_due_last_round..submission_due_date) else submission = submission.where(created_at: assignment_created..submission_due_date) end submission.exists? end
10) def get_data_for_review_report
-->Moved the initialization of rspan and response_counts outside of the loop.
-->Used each_with_object instead of manually accumulating values.
-->Used then to make the code more readable.
-->Simplified the logic for checking if a team exists
Before
def get_data_for_review_report(reviewed_object_id, reviewer_id, type) rspan = 0 (1..@assignment.num_review_rounds).each { |round| instance_variable_set('@review_in_round_' + round.to_s, 0) } response_maps = ResponseMap.where(['reviewed_object_id = ? AND reviewer_id = ? AND type = ?', reviewed_object_id, reviewer_id, type]) response_maps.each do |ri| rspan += 1 if Team.exists?(id: ri.reviewee_id) responses = ri.response (1..@assignment.num_review_rounds).each do |round| instance_variable_set('@review_in_round_' + round.to_s, instance_variable_get('@review_in_round_' + round.to_s) + 1) if responses.exists?(round: round) end end [response_maps, rspan] end
After
def get_data_for_review_report(reviewed_object_id, reviewer_id, type) response_maps = ResponseMap.where(reviewed_object_id: reviewed_object_id, reviewer_id: reviewer_id, type: type) response_counts = Array.new(@assignment.num_review_rounds, 0) response_maps.each_with_object([[], 0]) do |response_map, (response_maps_accumulator, rspan)| next unless Team.exists?(id: response_map.reviewee_id) rspan += 1 responses = response_map.response response_counts = (1..@assignment.num_review_rounds).each_with_object(response_counts) do |round, response_counts_accumulator| response_counts_accumulator[round - 1] += 1 if responses.exists?(round: round) response_counts_accumulator end response_maps_accumulator << response_map end.then { |response_maps_accumulator, rspan| [response_maps_accumulator, rspan, response_counts] } end
11) def get_awarded_review_score
-->Extracting the names of the round-specific instance variables to a separate array, which is generated using map.
-->Using each instead of each_with_index to set initial values for the instance variables.
-->Combining the check for team_id with the subsequent loop over the rounds to avoid redundancy.
-->Adding a check for nil before setting the instance variable to avoid raising an error if a score is missing.
Before
def get_awarded_review_score(reviewer_id, team_id) # Storing redundantly computed value in num_rounds variable num_rounds = @assignment.num_review_rounds # Setting values of instance variables (1..num_rounds).each { |round| instance_variable_set('@score_awarded_round_' + round.to_s, '-----') } # Iterating through list (1..num_rounds).each do |round| # Changing values of instance variable based on below condition unless team_id.nil? || team_id == -1.0 instance_variable_set('@score_awarded_round_' + round.to_s, @review_scores[reviewer_id][round][team_id].to_s + '%') end end end
After
def get_awarded_review_score(reviewer_id, team_id) num_rounds = @assignment.num_review_rounds round_variable_names = (1..num_rounds).map { |round| '@score_awarded_round_' + round.to_s } round_variable_names.each { |name| instance_variable_set(name, '-----') } unless team_id.nil? || team_id == -1.0 (1..num_rounds).each do |round| score = @review_scores[reviewer_id][round][team_id] instance_variable_set('@score_awarded_round_' + round.to_s, score.to_s + '%') unless score.nil? end end end
12) def list_review_submissions
-->This extracts the condition that checks whether team and participant are not nil into a separate method called review_submissions_available?. This makes the code more readable and easier to understand, while also reducing the complexity of the list_review_submissions method.
Before
def list_review_submissions(participant_id, reviewee_team_id, response_map_id) participant = Participant.find(participant_id) team = AssignmentTeam.find(reviewee_team_id) html = unless team.nil? || participant.nil? review_submissions_path = team.path + '_review' + '/' + response_map_id.to_s files = team.submitted_files(review_submissions_path) html += display_review_files_directory_tree(participant, files) if files.present? end html.html_safe end
After
def list_review_submissions(participant_id, reviewee_team_id, response_map_id) participant = Participant.find(participant_id) team = AssignmentTeam.find(reviewee_team_id) html = if review_submissions_available?(team, participant) review_submissions_path = team.path + '_review' + '/' + response_map_id.to_s files = team.submitted_files(review_submissions_path) html += display_review_files_directory_tree(participant, files) if files.present? end html.html_safe end def review_submissions_available?(team, participant) team.present? && participant.present? end
13) def check_submission_state
Few existing methods name are too long, this could have been changed by the team if they were working on that file - get_each_review_and_feedback_response_map, get_css_style_for_calibration_report
-->Removed the unnecessary color argument and made the method return the color.
-->Reduced the Cognitive Complexity by simplifying the conditional logic and nesting levels.
-->Made the link check more concise.
-->Broke the long line by formatting the conditional statements.
-->Reduced cognitive complexity to 3.
Before
def check_submission_state(response_map, assignment_created, assignment_due_dates, round, color) if submitted_within_round?(round, response_map, assignment_created, assignment_due_dates) color.push 'purple' else link = submitted_hyperlink(round, response_map, assignment_created, assignment_due_dates) if link.nil? || (link !~ %r{https*:\/\/wiki(.*)}) # can be extended for github links in future color.push 'green' else link_updated_at = get_link_updated_at(link) color.push link_updated_since_last?(round, assignment_due_dates, link_updated_at) ? 'purple' : 'green' end end end
After
def check_submission_state(response_map, assignment_created, assignment_due_dates, round, color) if submitted_within_round?(round, response_map, assignment_created, assignment_due_dates) color.push('purple') else link = submitted_hyperlink(round, response_map, assignment_created, assignment_due_dates) if link.nil? || !link.start_with?('https://wiki') color.push('green') else link_updated_at = get_link_updated_at(link) if link_updated_since_last?(round, assignment_due_dates, link_updated_at) color.push('purple') else color.push('green') end end end end
14) def get_awarded_review_score
In the refactored version, we have removed the unless condition and replaced it with a simple return statement, which reduces the cognitive complexity. We have also used the dig method to access the nested value of the @review_scores hash, which is more concise than the original version. Additionally, we have concatenated the score and the % symbol in a single string interpolation to make it more readable. Finally, we have removed the unnecessary use of the each method and initialized the round_variable_names variable outside of the block, which simplifies the code
Before def get_awarded_review_score(reviewer_id, team_id) num_rounds = @assignment.num_review_rounds round_variable_names = (1..num_rounds).map { |round| '@score_awarded_round_' + round.to_s } round_variable_names.each { |name| instance_variable_set(name, '-----') } return if team_id.nil? || team_id == -1.0 (1..num_rounds).each do |round| score = @review_scores.dig(reviewer_id, round, team_id) instance_variable_set('@score_awarded_round_' + round.to_s, "#{score}%" ) unless score.nil? end end
After
def get_awarded_review_score(reviewer_id, team_id) num_rounds = @assignment.num_review_rounds round_variable_names = (1..num_rounds).map { |round| "@score_awarded_round_#{round}" } round_variable_names.each { |name| instance_variable_set(name, '-----') } return if team_id.nil? || team_id == -1.0 (1..num_rounds).each do |round| score = @review_scores.dig(reviewer_id, round, team_id) next if score.nil? instance_variable_set("@score_awarded_round_#{round}", "#{score}%") end end
Testing
1)def display_volume_metric_chart
2)def display_tagging_interval_chart(intervals)
3)def sort_reviewer_by_review_volume_desc
4)def initialize_chart_elements(reviewer)
5)def calculate_key_chart_information(intervals)
6)def review_metrics(round, team_id)
7)def get_data_for_review_report(reviewed_object_id, reviewer_id, type)
8)def get_team_color(response_map)
9)def submitted_within_round?(round, response_map, assignment_created, assignment_due_dates)
10) def get_data_for_review_report(reviewed_object_id, reviewer_id, type)
11) def get_awarded_review_score(reviewer_id, team_id)
12)def list_review_submissions(participant_id, reviewee_team_id, response_map_id)
13) def check_submission_state(response_map, assignment_created, assignment_due_dates, round, color)
References
[ https://github.com/expertiza/expertiza/pull/1526 ]
[ https://github.com/ArshdeepSinghSyal/expertiza/tree/beta ]
Project Mentors
1) Edward Gehringer (efg@ncsu.edu)
2) Jialin Cui (jcui9@ncsu.edu)
Team Members
1) Kunal Shah (kshah24@ncsu.edu)
2) Ritwik Vaidya (rvaidya2@ncsu.edu)
3) Dakshil Kanakia (drkanaki@ncsu.edu)