enkessler/cuke_cataloger

View on GitHub
lib/cuke_cataloger/unique_test_case_tagger.rb

Summary

Maintainability
D
2 days
Test Coverage
# TODO: have better testing so that this can be safely refactored
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/MethodLength
module CukeCataloger

  # A tagger that handles test case cataloging.
  class UniqueTestCaseTagger

    # The pattern of a sub id
    SUB_ID_PATTERN = /^\d+\-\d+$/ # Not a part of the public API. Subject to change at any time.

    # The pattern of a sub id, with id capture
    SUB_ID_MATCH_PATTERN = /^\d+\-(\d+)$/ # Not a part of the public API. Subject to change at any time.


    # Where the id tag should be placed, relative to the other tags on the test
    attr_accessor :tag_location

    # Creates a new UniqueTestCaseTagger object
    def initialize
      @file_line_increases = Hash.new(0)
      @tag_location = :adjacent
    end

    # Adds id tags based on *tag_prefix* to the tests found in *feature_directory*
    def tag_tests(feature_directory, tag_prefix = '@test_case_', explicit_indexes = {}, tag_rows = true, id_column_name = 'test_case_id') # rubocop:disable Metrics/LineLength
      warn('This script will potentially rewrite all of your feature files. Please be patient and remember to tip your source control system.') # rubocop:disable Metrics/LineLength

      @known_id_tags = {}

      configure_id_tag(tag_prefix)
      configure_test_suite_model(feature_directory)

      @start_indexes = merge_indexes(default_start_indexes(determine_known_ids(feature_directory,
                                                                               tag_prefix,
                                                                               id_column_name)),
                                     explicit_indexes)
      @next_index = @start_indexes[:primary]

      # Analysis and output
      @tests.each do |test|
        case
          when test.is_a?(CukeModeler::Scenario)
            process_scenario(test)
          when test.is_a?(CukeModeler::Outline)
            process_outline(test, tag_rows, id_column_name)
          else
            raise("Unknown test type: #{test.class}")
        end
      end
    end

    # Finds existing id tags and their associated tests in *feature_directory* based on *tag_prefix*
    def scan_for_tagged_tests(feature_directory, tag_prefix = '@test_case_', id_column_name = 'test_case_id')
      @results = []
      @known_id_tags = {}

      configure_id_tag(tag_prefix)
      configure_test_suite_model(feature_directory)

      @tests.each do |test|
        add_to_results(test) if id_tag?(test)

        next unless test.is_a?(CukeModeler::Outline)

        test.examples.each do |example|
          next unless id_parameter?(example, id_column_name)

          example_rows_for(example).each do |row|
            add_to_results(row) if row_id?(row, id_column_name)
          end
        end
      end

      @results
    end

    # Checks for cataloging problems in *feature_directory* based on *tag_prefix*
    def validate_test_ids(feature_directory, tag_prefix = '@test_case_', tag_rows = true, id_column_name = 'test_case_id') # rubocop:disable Metrics/LineLength
      @results = []
      @known_id_tags = {}

      configure_id_tag(tag_prefix)
      configure_test_suite_model(feature_directory)

      @features.each { |feature| validate_feature(feature) }
      @tests.each { |test| validate_test(test, tag_rows, id_column_name) }

      @results
    end

    # Finds existing id tags in *feature_directory* based on *tag_prefix*
    def determine_known_ids(feature_directory, tag_prefix = '@test_case_', id_column_name = 'test_case_id')
      known_ids = []

      found_tagged_objects = scan_for_tagged_tests(feature_directory, tag_prefix, id_column_name)
                             .collect { |result| result[:object] }

      found_tagged_objects.each do |element|
        if element.is_a?(CukeModeler::Row)
          row_id = row_id_for(element, id_column_name)
          known_ids << row_id if well_formed_sub_id?(row_id)
        else
          known_ids << test_id_for(element)
        end
      end

      known_ids
    end


    private


    def configure_id_tag(tag_prefix)
      @tag_prefix = tag_prefix
      # TODO: should probably escape these characters
      @tag_pattern = Regexp.new("^#{@tag_prefix}\\d+$")
    end

    def configure_test_suite_model(feature_directory)
      @directory = CukeModeler::Directory.new(feature_directory)
      @model_repo = CQL::Repository.new(@directory)

      @tests = @model_repo.query do
        select :self
        from scenarios, outlines
      end.collect { |result| result[:self] } # rubocop:disable Style/MultilineBlockChain - Idiomatic CQL

      # Sorting for consistency, regardless of how the model tree happened to be traversed
      @tests.sort! do |a, b|
        if a.get_ancestor(:feature_file).path < b.get_ancestor(:feature_file).path # Earlier file
          -1
        elsif a.get_ancestor(:feature_file).path > b.get_ancestor(:feature_file).path # Later file
          1
        elsif a.source_line < b.source_line # Same file, earlier line
          -1
        elsif a.source_line > b.source_line # Same file, later line
          1
        else # Same file, same line number (shouldn't ever happen)
          0
        end
      end

      @features = @model_repo.query do
        select :self
        from features
      end.collect { |result| result[:self] } # rubocop:disable Style/MultilineBlockChain - Idiomatic CQL
    end

    def validate_feature(feature)
      check_for_feature_level_test_tag(feature)
    end

    def validate_test(test, tag_rows, id_column_name)
      check_for_missing_test_tag(test)
      check_for_multiple_test_id_tags(test)
      check_for_duplicated_test_id_tags(test)

      return unless test.is_a?(CukeModeler::Outline) && tag_rows

      check_for_missing_id_columns(test, id_column_name)
      check_for_missing_row_tags(test, id_column_name)
      check_for_duplicated_row_tags(test, id_column_name)
      check_for_mismatched_row_tags(test, id_column_name)
      check_for_malformed_row_tags(test, id_column_name)
    end

    def check_for_feature_level_test_tag(feature)
      add_to_results(feature, :feature_test_tag) if id_tag?(feature)
    end

    def check_for_duplicated_test_id_tags(test)
      unless @existing_tags
        @existing_tags = @model_repo.query do
          select tags
          from features, scenarios, outlines, examples
        end.collect { |result| result['tags'] }.flatten # rubocop:disable Style/MultilineBlockChain - Idiomatic CQL

        @existing_tags.map!(&:name)
      end

      test_id_tag = static_id_tag_for(test)

      matching_tags = @existing_tags.select { |tag| tag == test_id_tag }

      add_to_results(test, :duplicate_id_tag) if matching_tags.count > 1
    end

    def check_for_multiple_test_id_tags(test)
      tags = test.tags.map(&:name)

      id_tags_found = tags.select { |tag| tag =~ @tag_pattern }

      add_to_results(test, :multiple_tags) if id_tags_found.count > 1
    end

    def check_for_missing_test_tag(test)
      add_to_results(test, :missing_tag) unless id_tag?(test)
    end

    def check_for_missing_id_columns(test, id_column_name)
      test.examples.each do |example|
        add_to_results(example, :missing_id_column) unless id_column?(example, id_column_name)
      end
    end

    def check_for_duplicated_row_tags(test, id_column_name)
      validate_rows(test, :duplicate_row_id, false, :duplicate_row_id?, id_column_name)
    end

    def check_for_missing_row_tags(test, id_column_name)
      validate_rows(test, :missing_row_id, true, :row_id?, id_column_name)
    end

    def check_for_mismatched_row_tags(test, id_column_name)
      validate_rows(test, :mismatched_row_id, true, :matching_id?, id_column_name)
    end

    def check_for_malformed_row_tags(test, id_column_name)
      test.examples.each do |example|
        next unless id_column?(example, id_column_name)

        example_rows_for(example).each do |row|
          add_to_results(row, :malformed_sub_id) if row_id?(row, id_column_name) && !well_formed_sub_id?(row_id_for(row, id_column_name)) # rubocop:disable Metrics/LineLength
        end
      end
    end

    # Checks the rows of the given test for the given problem
    def validate_rows(test, rule, desired, row_check, id_column_name)
      test.examples.each do |example|
        next unless id_column?(example, id_column_name)

        example_rows_for(example).each do |row|
          row_flagged = send(row_check, row, id_column_name)
          add_to_results(row, rule) if desired != row_flagged
        end
      end
    end

    def process_scenario(test)
      apply_tag_if_needed(test)
    end

    def process_outline(test, tag_rows, id_column_name)
      apply_tag_if_needed(test)

      return unless tag_rows

      update_parameters_if_needed(test, id_column_name)
      update_rows_if_needed(test, determine_next_sub_id(test), id_column_name)
    end

    def apply_tag_if_needed(test)
      return if id_tag?(test)

      tag = "#{@tag_prefix}#{@next_index}"
      @next_index += 1

      tag_test(test, tag, (' ' * determine_test_indentation(test)))
    end

    def id_tag?(test)
      !fast_id_tag_for(test).nil?
    end

    def id_column?(example, id_column_name)
      example.parameters.any? { |param| param == id_column_name }
    end

    def row_id_for(row, id_column_name)
      id_index = determine_row_id_cell_index(row, id_column_name)

      return nil unless id_index

      cell_value = row.cells[id_index].value

      cell_value != '' ? cell_value : nil
    end

    def row_id?(row, id_column_name)
      !row_id_for(row, id_column_name).nil?
    end

    def well_formed_sub_id?(id)
      !(id =~ SUB_ID_PATTERN).nil?
    end

    def matching_id?(row, id_column_name)
      row_id = row_id_for(row, id_column_name)

      # A lack of id counts as 'matching'
      return true if row_id.nil?

      parent_tag = static_id_tag_for(row.get_ancestor(:test))

      if parent_tag
        parent_id = parent_tag.sub(@tag_prefix, '')

        !(row_id =~ /#{parent_id}-/).nil?
      else
        row_id.nil?
      end
    end

    def duplicate_row_id?(row, id_column_name)
      row_id = row_id_for(row, id_column_name)

      return false unless row_id && well_formed_sub_id?(row_id)

      existing_ids = determine_used_sub_ids(row.get_ancestor(:test), id_column_name)
      matching_ids = existing_ids.select { |id| id == row_id[/\d+$/] }

      matching_ids.count > 1
    end

    def determine_next_sub_id(test)
      parent = test_id_for(test)
      explicit_index = @start_indexes[:sub][parent]

      explicit_index ? explicit_index : 1
    end

    def determine_used_sub_ids(test, id_column_name)
      ids = test.examples.collect do |example|
        if id_parameter?(example, id_column_name)
          example_rows_for(example).collect do |row|
            row_id_for(row, id_column_name)
          end
        else
          []
        end
      end

      ids.flatten!
      ids.delete_if { |id| !id.to_s.match(SUB_ID_PATTERN) }

      ids.collect! { |id| id.match(SUB_ID_MATCH_PATTERN)[1] }

      ids
    end

    def determine_row_id_cell_index(row, id_column_name)
      row.get_ancestor(:example).parameters.index { |param| param =~ /#{id_column_name}/ }
    end

    def tag_test(test, tag, padding_string = '  ')
      feature_file = test.get_ancestor(:feature_file)
      file_path = feature_file.path

      index_adjustment = @file_line_increases[file_path]
      tag_index = source_line_to_use(test) + index_adjustment

      file_lines = File.readlines(file_path)
      file_lines.insert(tag_index, "#{padding_string}#{tag}\n")

      File.open(file_path, 'w') { |file| file.print file_lines.join }
      @file_line_increases[file_path] += 1

      new_tag = CukeModeler::Tag.new
      new_tag.name = tag
      test.tags << new_tag
    end

    def update_parameters_if_needed(test, id_column_name)
      feature_file = test.get_ancestor(:feature_file)
      file_path = feature_file.path
      index_adjustment = @file_line_increases[file_path]

      test.examples.each do |example|
        next if id_parameter?(example, id_column_name)

        parameter_line_index = (example.rows.first.source_line - 1) + index_adjustment

        file_lines = File.readlines(file_path)

        new_parameter = id_column_name.ljust(parameter_spacing(example, id_column_name))
        update_parameter_row(file_lines, parameter_line_index, new_parameter)
        File.open(file_path, 'w') { |file| file.print file_lines.join }
      end
    end

    def update_rows_if_needed(test, sub_id, id_column_name)
      feature_file = test.get_ancestor(:feature_file)
      file_path = feature_file.path
      index_adjustment = @file_line_increases[file_path]

      tag_index = fast_id_tag_for(test)[/\d+/]

      file_lines = File.readlines(file_path)

      test.examples.each do |example|
        example.rows[1..(example.rows.count - 1)].each do |row|
          next if row_id?(row, id_column_name)

          row_id = "#{tag_index}-#{sub_id}".ljust(parameter_spacing(example, id_column_name))

          row_line_index = (row.source_line - 1) + index_adjustment

          update_value_row(file_lines, row_line_index, row, row_id, id_column_name)
          sub_id += 1
        end

        File.open(file_path, 'w') { |file| file.print file_lines.join }
      end
    end

    # Slowest way to get the id tag. Will check the object every time.
    def current_id_tag_for(thing)
      tags = thing.tags
      id_tag = tags.detect { |tag| tag.name =~ @tag_pattern }

      return unless id_tag

      id_tag.name
    end

    # Faster way to get the id tag. Will skip checking the object if an id for it is already known.
    def fast_id_tag_for(thing)
      @known_id_tags ||= {}

      id = @known_id_tags[thing.object_id]

      unless id
        id = current_id_tag_for(thing)
        @known_id_tags[thing.object_id] = id
      end

      id
    end

    # Fastest way to get the id tag. Will skip checking the object if it
    # has been checked before, even if no id was found.
    def static_id_tag_for(thing)
      @known_id_tags ||= {}
      id_key = thing.object_id

      return @known_id_tags[id_key] if @known_id_tags.key?(id_key)

      id = current_id_tag_for(thing)
      @known_id_tags[id_key] = id

      id
    end

    def test_id_for(test)
      # TODO: should probably be escaping these in case regex characters used in prefix...
      fast_id_tag_for(test).match(/#{@tag_prefix}(.*)/)[1]
    end

    def id_parameter?(example, id_column_name)
      example.parameters.any? { |parameter| parameter == id_column_name }
    end

    # Adds an id column to the given parameter row
    def update_parameter_row(file_lines, line_index, parameter)
      append_row!(file_lines, line_index, " #{parameter} |")
    end

    # Adds an id to the given value row
    def update_value_row(file_lines, line_index, row, row_id, id_column_name)
      case
        when needs_adding?(row, id_column_name)
          append_row!(file_lines, line_index, " #{row_id} |")
        when needs_filled_in?(row, id_column_name)
          fill_in_row(file_lines, line_index, row, row_id, id_column_name)
        else
          raise("Don't know how to update row")
      end
    end

    def needs_adding?(row, id_column_name)
      !id_parameter?(row.get_ancestor(:example), id_column_name)
    end

    def needs_filled_in?(row, id_column_name)
      id_parameter?(row.get_ancestor(:example), id_column_name)
    end

    # Replaces the indicated line of text with the provided line of tet
    def replace_row!(file_lines, line_index, new_line)
      file_lines[line_index] = new_line
    end

    # Adds text to the beginning of the given line (with whitespace loss)
    def prepend_row!(file_lines, line_index, string)
      old_row = file_lines[line_index]
      new_row = string + old_row.lstrip
      file_lines[line_index] = new_row
    end

    # Adds text to the end of the given line (with whitespace loss)
    def append_row!(file_lines, line_index, string)
      old_row = file_lines[line_index]
      trailing_bits = old_row[/\s*$/]
      new_row = old_row.rstrip + string + trailing_bits

      file_lines[line_index] = new_row
    end

    def example_rows_for(example)
      rows = example.rows.dup
      rows.shift

      rows
    end

    def add_to_results(item, issue = nil)
      result = { test: "#{item.get_ancestor(:feature_file).path}:#{item.source_line}", object: item }
      result[:problem] = issue if issue

      @results << result
    end

    def default_start_indexes(known_ids)
      primary_ids = known_ids.select { |id| id =~ /^\d+$/ }
      sub_ids = known_ids.select { |id| id =~ /^\d+-\d+$/ }

      max_primary_id = primary_ids.map(&:to_i).max || 0
      default_indexes = { primary: max_primary_id + 1,
                          sub: {} }

      sub_primaries = sub_ids.collect { |sub_id| sub_id[/^\d+/] }

      sub_primaries.each do |primary|
        default_indexes[:sub][primary] = sub_ids.select { |sub_id| sub_id[/^\d+/] == primary }
                                                .collect { |sub_id| sub_id[/\d+$/].to_i }.max + 1
      end

      default_indexes
    end

    # Merges the given index sets (of the shape {primary: Integer, sub: Hash}) into a new one
    def merge_indexes(set_1, set_2)
      set_1.merge(set_2) do |key, set_1_value, set_2_value|
        key == :sub ? set_1_value.merge(set_2_value) : set_2_value
      end
    end

    # Determines how wide a parameter string needs to be in order to correctly fit the column in which it resides
    def parameter_spacing(example, id_column_name)
      test = example.get_ancestor(:test)
      test_id = fast_id_tag_for(test)[/\d+$/]
      row_count = test.examples.reduce(0) { |sum, example_table| sum + example_table.rows.count }

      # TODO: Possible bug - what if old test rows have been removed and the existing
      # sub IDs are a lot longer than the number of current rows?
      max_id_length = test_id.length + 1 + row_count.to_s.length # base ID + hyphen + sub ID
      param_length = id_column_name.length

      [param_length, max_id_length].max
    end

    def determine_test_indentation(test)
      # TODO: replace with 'get_most_recent_file_text'
      feature_file = test.get_ancestor(:feature_file)
      file_path = feature_file.path

      index_adjustment = @file_line_increases[file_path]
      test_index = (test.source_line - 1) + index_adjustment

      file_lines = File.readlines(file_path)
      indentation = file_lines[test_index][/^\s*/].length

      indentation
    end

    # Adds an id to the given value row (which has a column for an id but no value for it)
    def fill_in_row(file_lines, line_index, row, row_id, id_column_name)
      sections = file_lines[line_index].split('|', -1)

      replacement_index = determine_row_id_cell_index(row, id_column_name)
      sections[replacement_index + 1] = " #{row_id} "

      new_row = sections.join('|')

      replace_row!(file_lines, line_index, new_row)
    end

    def source_line_to_use(test)
      case @tag_location
        when :above
          determine_highest_tag_line(test)
        when :below
          determine_lowest_tag_line(test)
        when :adjacent
          adjacent_tag_line(test)
        else
          raise(ArgumentError, "Don't know where #{@tag_location} is.")
      end
    end

    def determine_highest_tag_line(test)
      return adjacent_tag_line(test) if test.tags.empty?

      test.tags.map(&:source_line).min - 1
    end

    def determine_lowest_tag_line(test)
      return adjacent_tag_line(test) if test.tags.empty?

      test.tags.map(&:source_line).max
    end

    def adjacent_tag_line(test)
      (test.source_line - 1)
    end

    def cuke_modeler?(*versions)
      versions.include?(cuke_modeler_major_version)
    end

    def cuke_modeler_major_version
      Gem.loaded_specs['cuke_modeler'].version.version.match(/^(\d+)\./)[1].to_i
    end

  end
end

# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/MethodLength