CSC/ECE 517 Spring 2024 - E2439 Testing for view translation substitutor.rb

From Expertiza_Wiki
Jump to navigation Jump to search

E2439 Testing for view translation substitutor.rb

This wiki page is for the description of changes made in Spring 2024, CSC/ECE 517 for the E2439 Testing for view translation substitutor.rb assignment.

Expertiza

Expertiza is an open source software project (OSS) using Ruby on Rails framework. Expertiza is led by Dr. Edward Gehringer. Expertiza allows students to create different teams and work on various assignments and projects. Also, students have the capability to peer review and evaluate each other's work. In Expertiza, instructors have the ability to create new assignments and edit already existing projects. The instructor can use a filter to narrow a list of subjects for students. Expertiza allows submissions from PDFs, wiki pages and URLs document types.

Project Objective

This class (view_translation_substitutor.rb) is designed to automate substituting text within view templates with translation keys. Our objective is to create tests in view_translation_substitutor_spec.rb to cover various use and edge cases. Also, we must add detailed explanations for all the tests.

Problem Statement

The code coverage for view_translation_substitutor.rb started at 0%, indicating a need to enhance the test coverage, particularly by focusing on testing edge cases. According to the existing test file (view_translation_substitutor_spec.rb), some methods may be broken. Adopting TDD could be an effective strategy to address and repair these methods.

We are unable to find anything that invokes this view_translation_subsitutor throughout the rest of the expertiza code base, so we have created unit tests to verify its functionality independently to ensure that it integrates smoothly with other modules without any issues.

Files Modified

Plan of Work

We will begin by inserting some debug statements into the model and running expertiza in order to find out when the model runs and what it is accomplishing. Once finished, we will determine what is wrong with the main method of the model, substitute() and fix it. Then, we will write tests to have 100% coverage of the model. After that, we will decide what edge cases may arise. Finally, we will write tests to cover the edge cases.

Goals

While implementing the view translation substitution functionality using the ViewTranslationSubstitutor class, the following design goals should be ensured:

  • Goal #1: Determine the purpose and invocation timing of view_translation_substitutor.rb to understand its role in the application's internationalization process and ensure proper integration with the existing codebase.
  • Goal #2: Debug and fix the substitute() method to ensure it correctly processes the locale hash, generates accurate translation statistics, and writes them to the YAML file, handling any edge cases or error scenarios that may arise.
  • Goal #3: Write comprehensive test cases for the key methods of the ViewTranslationSubstitutor class, including substitute, process_directory, process_view, and process_translation, to validate their correctness, robustness, and adherence to the expected behavior.
  • Goal #4: Extend the test suite to cover edge cases and exceptional scenarios, such as empty or invalid locale hashes, missing view files or directories, translation keys with special characters, and performance testing with large or complex view files, ensuring the class handles these situations gracefully and maintains its reliability.

Model Overview & Test Implementation

The ViewTranslationSubstitutor class is designed to assist in the internationalization process of applications by substituting hardcoded text strings in view templates with dynamic translation calls. It processes a given locale hash, iterates through directories and view files, and replaces identified strings with translations from the locale hash. Additionally, it generates statistics regarding the substitutions made during the process.

Method Descriptions

substitute(locale)

Description: Processes the given locale hash by iterating through directories and view files, replacing hardcoded strings with translations, and generating statistics. Parameters: locale: A hash containing translation data organized by directory and view name. Returns: None

Use Cases

  1. Replaces hardcoded strings in view templates with translated versions according to the provided locale hash.
  2. Keeps the content of the Expertiza application up-to-date with the latest translations, maintaining consistency and relevance in multiple languages.
  3. Generates translation statistics for each directory and view.
  4. Writes the translation statistics to a YAML file.

Edge Cases

  1. When translation files are located within nested directories, all directories should be recursively traversed to ensure that no translations are missed.
  2. Handles a locale hash with missing or invalid directory or view names.
  3. Handles a locale hash with missing or invalid translation keys or values.
describe '#substitute' do
  let(:substitutor) { ViewTranslationSubstitutor.new }

  it 'iterates over the locale hash and processes directories' do
    # Create a sample locale hash.
    locale = {
      'dir1' => { 'view1' => { 'key1' => 'val1' } },
      'dir2' => { 'view2' => { 'key2' => 'val2' } }
    }

    # Expect process_files_in_directory to be called for each directory in the locale hash.
    expect(substitutor).to receive(:process_files_in_directory).with('dir1', { 'view1' => { 'key1' => 'val1' } }).and_return('stats1')
    expect(substitutor).to receive(:process_files_in_directory).with('dir2', { 'view2' => { 'key2' => 'val2' } }).and_return('stats2')

    # Expect File.open to be called with a regex matching translation_stats*.yml and write the processed stats to YAML.
    expect(File).to receive(:open).with(/^translation_stats.*\.yml$/, 'w').and_yield(file = double)
    expect(file).to receive(:write).with({ 'dir1' => 'stats1', 'dir2' => 'stats2' }.to_yaml)

    # Call the substitute method with the sample locale hash.
    substitutor.substitute(locale)
  end
end

process_files in directory(dir_name, view_hash)

Description: Processes a directory within the application, iterating through view files and replacing hardcoded strings with translations. Parameters: dir_name: Name of the directory being processed. view_hash: Hash containing translation data for views within the directory.

Returns: A hash containing statistics for each view within the directory.

Use Cases

  1. Ensure consistent handling of translations across different parts of Expertiza.
  2. Calls process_view method for each view in the directory.
  3. Returns the translation statistics for the directory.

Edge Cases

  1. If the view hash is empty, the method will return an empty hash as the translation statistics for the directory.
  2. The method will skip the missing or invalid view names or translations and continue processing the valid ones. The resulting translation statistics will only include the valid views and translations.
describe '#process_files_in_directory' do
  let(:substitutor) { ViewTranslationSubstitutor.new }

  context 'when view_hash is not empty' do
    let(:dir_name) { 'dir1' }
    let(:view_hash) { { 'view1' => { 'key1' => 'val1' } } }

    it 'processes each view in the view_hash' do
      # Stub process_view method to return 'stats1' and ensure it is called with correct arguments.
      allow(substitutor).to receive(:process_view).and_return('stats1')
      dir_stats = substitutor.send(:process_files_in_directory, dir_name, view_hash)
      expect(dir_stats).to eq({ 'view1' => 'stats1' })
      expect(substitutor).to have_received(:process_view).with(dir_name, 'view1', { 'key1' => 'val1' })
    end
  end

  context 'when view_hash is empty' do
    # Local variables
    let(:dir_name) { 'dir2' }
    let(:view_hash) { {} }

    it 'returns an empty hash' do
      # Call the private method process_files_in_directory with an empty view_hash and ensure it returns an empty hash.
      dir_stats = substitutor.send(:process_files_in_directory, dir_name, view_hash)
      expect(dir_stats).to eq({})
    end
  end
end

process_view(directory_name, view_name, translations)

Description: Processes a specific view file, replacing hardcoded strings with translations and updating the file accordingly. Parameters: directory_name: Name of the directory containing the view file. view_name: Name of the view file. translations: Hash containing translation data for the specific view.

Returns: A hash containing statistics for the translations made in the view file.

Use Cases

  1. Processes the translations for a specific view file.
  2. Reads the contents of the primary view file.
  3. If the primary view file doesn't exist, checks for an alternate view file.
  4. Calls process_translation method for each translation key-value pair.
  5. Writes the updated contents back to the view file.
  6. Returns the translation statistics for the view.

Edge Cases

  1. When the filenames begin with an underscore, the files should be handled as if they don't begin with an underscore.
  2. If both the primary and alternate view files (file name that begins with an underscore) are missing, the method returns an error message ('<file not found>') as the translation statistics for the view.
  3. If the translations hash is empty, the method will return an empty hash as the translation statistics for the view. The view file contents will remain unchanged.
  4. The method will skip the missing or invalid keys or values and continue processing the valid ones. The resulting translation statistics will only include the valid translations.
describe '#process_view' do
  # Local variables
  let(:substitutor) { ViewTranslationSubstitutor.new }
  let(:directory_name) { 'test_folder' }
  let(:view_name) { 'example_view' }
  let(:translations) { { 'key1' => 'value1', 'key2' => 'value2' } }

  context 'when the view file exists' do
    before do
      # Stub File.exist? to return true and File.open to yield "Existing content".
      allow(File).to receive(:exist?).with("./#{directory_name}/#{view_name}.html.erb").and_return(true)
      allow(File).to receive(:open).with("./#{directory_name}/#{view_name}.html.erb", 'w').and_yield(StringIO.new("Existing content"))
    end

    it 'reads the file, processes translations, and writes back the contents' do
      # Expect process_translation to be called for each translation and return modified content and stats.
      expect(substitutor).to receive(:process_translation).with("Existing content", 'key1', 'value1').and_return(['stats1', 'new_content1'])
      expect(substitutor).to receive(:process_translation).with('new_content1', 'key2', 'value2').and_return(['stats2', 'new_content2'])

      # Call the process_view method and expect it to return processed view stats.
      view_stats = substitutor.send(:process_view, directory_name, view_name, translations)
      expect(view_stats).to eq({ 'key1' => 'stats1', 'key2' => 'stats2' })

      # Expect File.open to be called twice with the view file path.
      expect(File).to have_received(:open).with("./#{directory_name}/#{view_name}.html.erb", 'w').twice
    end
  end

  context 'when the view file does not exist' do
    it 'returns "<file not found>"' do
      # Call the process_view method and expect it to return "<file not found>".
      view_stats = substitutor.send(:process_view, directory_name, view_name, translations)
      expect(view_stats).to eq('<file not found>')
    end
  end

  context 'when the alternate view file exists' do
    before do
      # Stub File.exist? to return false for main view file and true for alternate view file.
      allow(File).to receive(:exist?).with("./#{directory_name}/#{view_name}.html.erb").and_return(false)
      allow(File).to receive(:exist?).with("./#{directory_name}/_#{view_name}.html.erb").and_return(true)
      allow(File).to receive(:open).with("./#{directory_name}/_#{view_name}.html.erb", 'w').and_yield(StringIO.new("Existing content"))
    end

    it 'reads the alternate file, processes translations, and writes back the contents' do
      # Expect process_translation to be called for each translation and return modified content and stats.
      expect(substitutor).to receive(:process_translation).with("Existing content", 'key1', 'value1').and_return(['stats1', 'new_content1'])
      expect(substitutor).to receive(:process_translation).with('new_content1', 'key2', 'value2').and_return(['stats2', 'new_content2'])

      # Call the process_view method and expect it to return processed view stats.
      view_stats = substitutor.send(:process_view, directory_name, view_name, translations)

      expect(view_stats).to eq({ 'key1' => 'stats1', 'key2' => 'stats2' })

      # Expect File.open to be called twice with the alternate view file path.
      expect(File).to have_received(:open).with("./#{directory_name}/_#{view_name}.html.erb", 'w').twice
    end
  end
end

process_translation(contents, key, val)

Description: Processes translations within the contents of a view file, replacing hardcoded strings with dynamic translation calls. Parameters: contents: Contents of the view file as a string. key: Key representing the translation key. val: Value representing the translation text.

Returns: A tuple containing a hash of statistics for the translations made and the updated contents of the view file.

Use Cases

  1. Performs the actual translation within the view contents.
  2. Matches translation text within the contents using a regular expression.
  3. Replaces the matched text with the corresponding translation key if certain conditions are met.
  4. Skips the matched text if the conditions are not met.
  5. Returns the translation statistics (replacements, skips) and the updated contents.

Edge Cases

  1. If no matching translation text is found in the contents, the method will return the original contents without any modifications. The translation statistics will be set to '<unmatched>'.
  2. The method checks if the translation text is enclosed in quotes or preceded/followed by specific patterns (defined in the BLACKLIST constant). If the conditions are met, the translation is performed. Otherwise, the translation is skipped.
  3. The method preserves the leading and trailing whitespace of the translation text during the replacement process.
  4. The method replaces all occurrences of the matching translation text in the contents.
describe '#process_translation' do
  # Local variables
  let(:contents) { 'This is a test string with some "text" to be replaced.' }
  let(:key) { 'other word' }
  let(:val) { 'text' }
  let(:key2) { 'skipped key' }
  let(:val2) { 'string' }
  let(:key3) { 'non-existent key' }
  let(:val3) { 'non-existent val' }

  it 'replaces the correct text with translation keys' do
    # Call process_translation with a valid key and value and expect correct replacements and modified content.
    replacements, new_contents = substitutor.send(:process_translation, contents, key, val)

    expect(replacements).to include("replacements" => ["\"#{val}\""])
    expect(new_contents).to include("#{key}")
  end

  it 'adds to skip when one value is not found but another value is' do
    # Call process_translation with a key and value that partially match the contents and expect skips and unmodified content.
    skips, new_contents = substitutor.send(:process_translation, contents, key2, val2)

    expect(skips).to include("skips" => ["test string with"])
    expect(new_contents).not_to include("#{key2}")
  end

  it 'adds <unmatched> to translation_stats when there is no match' do
    # Call process_translation with a key and value that do not match any part of the contents and expect <unmatched> and unmodified content.
    translation_stats, new_contents = substitutor.send(:process_translation, contents, key3, val3)

    expect(translation_stats).to include("<unmatched>")
    expect(new_contents).not_to include("#{key3}")
  end
end

Design Patterns & Principles

Single Responsibility Principle (SRP):

  • Each test case has a single responsibility and focuses on a specific aspect of the application.
  • The different types of tests (unit, integration, and system) are separated in order to improve clarity and organization.

Don't Repeat Yourself Principle (DRY):

  • Setup and cleanup steps for all tests are the same and thus, are combined together.

Open/Closed Principle (OCP):

  • Test classes are designed so that they are open for extension. New tests can be added without needing to modify the existing test code.

Singleton Pattern:

  • There is only a single setup that is shared for all of the test cases that need to use it.

Test Plan

Test coverage is currently at 100%. We have written tests to ensure coverage of each of the four methods in view_translation_substitutor.rb. Then, we added tests to cover the edge cases.

Demo Video

Demo Video (Youtube)
Demo Video (Vimeo)

Using RSpec

We were unable to test view_translation_substitutor.rb within Expertiza because it isn't called anywhere. We instead implemented unit tests that offer 100% coverage. We tested each of the four methods separately.

Link to Rspec Repository

How to See Test Coverage

Run RSpec for view_translation_substitutor.rb, sudo su, yum install lynx, then lynx ./coverage/index.html.

We chose to use Lynx because it displays the test coverage in an easy and readable way.

Results

The image below shows the test cases are passing as well as most of the code climate issues were resolved.

view_translation_substitutor.rb model coverage = 100%

Future Work

Review the code base in detail and determine if there were any issues that led to view_translation_substitutor.rb not being invoked in Expertiza. If this feature is intentionally not used, then future work could involve the implementation and integration of this feature into the application. This could include adding new languages for translation as well as updating the existing translations (Hindi) to reflect changes in the application's interface and content.

Team

Mentor

Mustafa Olmez (molmez@ncsu.edu)

Team Members

Shandler Mason (samason4@ncsu.edu)
Ben Morris (bcmorri4@ncsu.edu)
Ray Wang (rwang32@ncsu.edu)

References

  1. Expertiza GitHub
  2. Project Repository
  3. Github Project Board
  4. Pull Request
  5. RSpec