diff --git a/app/services/course/assessment/question/programming_codaveri/c_sharp/c_sharp_package_service.rb b/app/services/course/assessment/question/programming_codaveri/c_sharp/c_sharp_package_service.rb index 16c93ae6f30..2360882dbe0 100644 --- a/app/services/course/assessment/question/programming_codaveri/c_sharp/c_sharp_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/c_sharp/c_sharp_package_service.rb @@ -97,27 +97,6 @@ def extract_supporting_solution_files end end - # Extracts filename and content of a data file and append it to the - # [:additionalFiles] array for the problem management API request body. - # - # @param [Pathname] pathname The pathname of the file. - # @param [String] content The content of the file. - def extract_supporting_file(filename, content) - supporting_file_object = default_codaveri_data_file_template - - supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri - supporting_file_object[:path] = filename.to_s - if content.force_encoding('UTF-8').valid_encoding? - supporting_file_object[:content] = content - supporting_file_object[:encoding] = 'utf8' - else - supporting_file_object[:content] = Base64.strict_encode64(content) - supporting_file_object[:encoding] = 'base64' - end - - @data_files.append(supporting_file_object) - end - # Extracts test cases from the built dummy reports and append all the test cases to the # [:IOTestcases] array for the problem management API request body. def extract_test_cases # rubocop:disable Metrics/AbcSize diff --git a/app/services/course/assessment/question/programming_codaveri/go/go_package_service.rb b/app/services/course/assessment/question/programming_codaveri/go/go_package_service.rb index 4156f3dd069..aadc11cf9e9 100644 --- a/app/services/course/assessment/question/programming_codaveri/go/go_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/go/go_package_service.rb @@ -97,27 +97,6 @@ def extract_supporting_solution_files end end - # Extracts filename and content of a data file and append it to the - # [:additionalFiles] array for the problem management API request body. - # - # @param [Pathname] pathname The pathname of the file. - # @param [String] content The content of the file. - def extract_supporting_file(filename, content) - supporting_file_object = default_codaveri_data_file_template - - supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri - supporting_file_object[:path] = filename.to_s - if content.force_encoding('UTF-8').valid_encoding? - supporting_file_object[:content] = content - supporting_file_object[:encoding] = 'utf8' - else - supporting_file_object[:content] = Base64.strict_encode64(content) - supporting_file_object[:encoding] = 'base64' - end - - @data_files.append(supporting_file_object) - end - # Extracts test cases from the built dummy reports and append all the test cases to the # [:IOTestcases] array for the problem management API request body. def extract_test_cases # rubocop:disable Metrics/AbcSize diff --git a/app/services/course/assessment/question/programming_codaveri/java/java_package_service.rb b/app/services/course/assessment/question/programming_codaveri/java/java_package_service.rb index ca782a0e72c..44809f559e2 100644 --- a/app/services/course/assessment/question/programming_codaveri/java/java_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/java/java_package_service.rb @@ -103,23 +103,11 @@ def extract_supporting_tests_files end end - def extract_supporting_file(filename, content) - supporting_file_object = default_codaveri_data_file_template - - supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri - supporting_file_object[:path] = filename.to_s - # TODO: remove filename.to_s.downcase.end_with?('.java') check - # For now, only plaintext files that require compiling (e.g. *.java) will use 'utf8' ecoding - # Pending Codaveri 'utf8' encoding support for all plaintext files in compiled languages - if content.force_encoding('UTF-8').valid_encoding? && filename.to_s.downcase.end_with?('.java') - supporting_file_object[:content] = content - supporting_file_object[:encoding] = 'utf8' - else - supporting_file_object[:content] = Base64.strict_encode64(content) - supporting_file_object[:encoding] = 'base64' - end - - @data_files.append(supporting_file_object) + # TODO: remove filename.to_s.downcase.end_with?('.java') check + # For now, only plaintext files that require compiling (e.g. *.java) will use 'utf8' ecoding + # Pending Codaveri 'utf8' encoding support for all plaintext files in compiled languages + def utf8_encodable?(filename, utf8_content) + super && filename.to_s.downcase.end_with?('.java') end def extract_template diff --git a/app/services/course/assessment/question/programming_codaveri/java_script/java_script_package_service.rb b/app/services/course/assessment/question/programming_codaveri/java_script/java_script_package_service.rb index c5382efd647..1293ead1815 100644 --- a/app/services/course/assessment/question/programming_codaveri/java_script/java_script_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/java_script/java_script_package_service.rb @@ -97,27 +97,6 @@ def extract_supporting_solution_files end end - # Extracts filename and content of a data file and append it to the - # [:additionalFiles] array for the problem management API request body. - # - # @param [Pathname] pathname The pathname of the file. - # @param [String] content The content of the file. - def extract_supporting_file(filename, content) - supporting_file_object = default_codaveri_data_file_template - - supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri - supporting_file_object[:path] = filename.to_s - if content.force_encoding('UTF-8').valid_encoding? - supporting_file_object[:content] = content - supporting_file_object[:encoding] = 'utf8' - else - supporting_file_object[:content] = Base64.strict_encode64(content) - supporting_file_object[:encoding] = 'base64' - end - - @data_files.append(supporting_file_object) - end - # Extracts test cases from the built dummy reports and append all the test cases to the # [:IOTestcases] array for the problem management API request body. def extract_test_cases # rubocop:disable Metrics/AbcSize diff --git a/app/services/course/assessment/question/programming_codaveri/language_package_service.rb b/app/services/course/assessment/question/programming_codaveri/language_package_service.rb index 3bc81d09294..88775832ccb 100644 --- a/app/services/course/assessment/question/programming_codaveri/language_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/language_package_service.rb @@ -61,6 +61,40 @@ def process_evaluator private + # Extracts filename and content of a data file and append it to the + # [:additionalFiles] array for the problem management API request body. + # + # @param [Pathname] filename The pathname of the file. + # @param [String] content The content of the file. + def extract_supporting_file(filename, content) + supporting_file_object = default_codaveri_data_file_template + + supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri + supporting_file_object[:path] = filename.to_s + # `content` is read straight out of the zip and may be frozen (rubyzip returns a frozen empty + # string literal for zero-byte entries), so tag the encoding on a copy rather than in place. + utf8_content = content.dup.force_encoding('UTF-8') + if utf8_encodable?(filename, utf8_content) + supporting_file_object[:content] = utf8_content + supporting_file_object[:encoding] = 'utf8' + else + supporting_file_object[:content] = Base64.strict_encode64(content) + supporting_file_object[:encoding] = 'base64' + end + + @data_files.append(supporting_file_object) + end + + # Whether a supporting file may be sent to Codaveri as plaintext 'utf8' rather than 'base64'. + # Concrete services may narrow this further; see the Java package service. + # + # @param [Pathname] filename The pathname of the file. + # @param [String] utf8_content The content of the file, tagged as UTF-8. + # @return [Boolean] + def utf8_encodable?(_filename, utf8_content) + utf8_content.valid_encoding? + end + # Defines the default solution template as indicated in the Codevari API problem management spec. # # @return [Hash] diff --git a/app/services/course/assessment/question/programming_codaveri/python/python_package_service.rb b/app/services/course/assessment/question/programming_codaveri/python/python_package_service.rb index 714a83fb060..4b7acfb2910 100644 --- a/app/services/course/assessment/question/programming_codaveri/python/python_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/python/python_package_service.rb @@ -97,27 +97,6 @@ def extract_supporting_solution_files end end - # Extracts filename and content of a data file and append it to the - # [:additionalFiles] array for the problem management API request body. - # - # @param [Pathname] pathname The pathname of the file. - # @param [String] content The content of the file. - def extract_supporting_file(filename, content) - supporting_file_object = default_codaveri_data_file_template - - supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri - supporting_file_object[:path] = filename.to_s - if content.force_encoding('UTF-8').valid_encoding? - supporting_file_object[:content] = content - supporting_file_object[:encoding] = 'utf8' - else - supporting_file_object[:content] = Base64.strict_encode64(content) - supporting_file_object[:encoding] = 'base64' - end - - @data_files.append(supporting_file_object) - end - # Extracts test cases from 'autograde.py' and append all the test cases to the # [:resources][0][:exprTestcases] array for the problem management API request body. def extract_test_cases diff --git a/app/services/course/assessment/question/programming_codaveri/r/r_package_service.rb b/app/services/course/assessment/question/programming_codaveri/r/r_package_service.rb index 999a0fc737c..fc41378b9d5 100644 --- a/app/services/course/assessment/question/programming_codaveri/r/r_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/r/r_package_service.rb @@ -97,27 +97,6 @@ def extract_supporting_solution_files end end - # Extracts filename and content of a data file and append it to the - # [:additionalFiles] array for the problem management API request body. - # - # @param [Pathname] pathname The pathname of the file. - # @param [String] content The content of the file. - def extract_supporting_file(filename, content) - supporting_file_object = default_codaveri_data_file_template - - supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri - supporting_file_object[:path] = filename.to_s - if content.force_encoding('UTF-8').valid_encoding? - supporting_file_object[:content] = content - supporting_file_object[:encoding] = 'utf8' - else - supporting_file_object[:content] = Base64.strict_encode64(content) - supporting_file_object[:encoding] = 'base64' - end - - @data_files.append(supporting_file_object) - end - # Extracts test cases from the built dummy reports and append all the test cases to the # [:IOTestcases] array for the problem management API request body. def extract_test_cases # rubocop:disable Metrics/AbcSize diff --git a/app/services/course/assessment/question/programming_codaveri/rust/rust_package_service.rb b/app/services/course/assessment/question/programming_codaveri/rust/rust_package_service.rb index 4824130cfee..eae0a8cf35c 100644 --- a/app/services/course/assessment/question/programming_codaveri/rust/rust_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/rust/rust_package_service.rb @@ -97,27 +97,6 @@ def extract_supporting_solution_files end end - # Extracts filename and content of a data file and append it to the - # [:additionalFiles] array for the problem management API request body. - # - # @param [Pathname] pathname The pathname of the file. - # @param [String] content The content of the file. - def extract_supporting_file(filename, content) - supporting_file_object = default_codaveri_data_file_template - - supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri - supporting_file_object[:path] = filename.to_s - if content.force_encoding('UTF-8').valid_encoding? - supporting_file_object[:content] = content - supporting_file_object[:encoding] = 'utf8' - else - supporting_file_object[:content] = Base64.strict_encode64(content) - supporting_file_object[:encoding] = 'base64' - end - - @data_files.append(supporting_file_object) - end - # Extracts test cases from the built dummy reports and append all the test cases to the # [:IOTestcases] array for the problem management API request body. def extract_test_cases # rubocop:disable Metrics/AbcSize diff --git a/app/services/course/assessment/question/programming_codaveri/type_script/type_script_package_service.rb b/app/services/course/assessment/question/programming_codaveri/type_script/type_script_package_service.rb index 8b81504fd73..0a54601c220 100644 --- a/app/services/course/assessment/question/programming_codaveri/type_script/type_script_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/type_script/type_script_package_service.rb @@ -97,27 +97,6 @@ def extract_supporting_solution_files end end - # Extracts filename and content of a data file and append it to the - # [:additionalFiles] array for the problem management API request body. - # - # @param [Pathname] pathname The pathname of the file. - # @param [String] content The content of the file. - def extract_supporting_file(filename, content) - supporting_file_object = default_codaveri_data_file_template - - supporting_file_object[:type] = 'internal' # 'external' s3 upload not yet implemented by codaveri - supporting_file_object[:path] = filename.to_s - if content.force_encoding('UTF-8').valid_encoding? - supporting_file_object[:content] = content - supporting_file_object[:encoding] = 'utf8' - else - supporting_file_object[:content] = Base64.strict_encode64(content) - supporting_file_object[:encoding] = 'base64' - end - - @data_files.append(supporting_file_object) - end - # Extracts test cases from the built dummy reports and append all the test cases to the # [:IOTestcases] array for the problem management API request body. def extract_test_cases # rubocop:disable Metrics/AbcSize diff --git a/spec/fixtures/course/programming_question_template_codaveri_empty_data_file.zip b/spec/fixtures/course/programming_question_template_codaveri_empty_data_file.zip new file mode 100644 index 00000000000..4b06fa054c4 Binary files /dev/null and b/spec/fixtures/course/programming_question_template_codaveri_empty_data_file.zip differ diff --git a/spec/services/course/assessment/question/programming_codaveri/language_package_service_spec.rb b/spec/services/course/assessment/question/programming_codaveri/language_package_service_spec.rb new file mode 100644 index 00000000000..978fefbfab5 --- /dev/null +++ b/spec/services/course/assessment/question/programming_codaveri/language_package_service_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Question::ProgrammingCodaveri::LanguagePackageService do + let(:package_path) do + File.join(Rails.root, 'spec/fixtures/course/programming_question_template_codaveri_empty_data_file.zip') + end + let(:package) { Course::Assessment::ProgrammingPackage.new(package_path) } + let(:main_files) { package.main_files } + let(:test_files) { package.test_files } + subject { described_class.new(nil, package) } + + def extract(filename, content) + subject.send(:extract_supporting_file, filename, content) + subject.data_files.last + end + + describe '.extract_supporting_file' do + context 'when the file is valid UTF-8 plaintext' do + it 'extracts the content as utf8' do + filename = Pathname.new('data.csv') + + expect(extract(filename, main_files[filename])).to eq( + type: 'internal', + path: 'data.csv', + content: "codon,amino_acid\nAAA,Lys\nAAC,Asn\n", + encoding: 'utf8' + ) + end + end + + context 'when the file is not valid UTF-8' do + it 'extracts the content as base64' do + filename = Pathname.new('binary.dat') + + expect(extract(filename, main_files[filename])).to eq( + type: 'internal', + path: 'binary.dat', + content: Base64.strict_encode64("\xFF\xFE\x00\x01\x02".b), + encoding: 'base64' + ) + end + end + + # Zero-byte zip entries are read back as a frozen empty string, which used to be mutated + # in place by `force_encoding` and raise FrozenError. + context 'when the file is empty' do + it 'extracts the file as empty utf8 content' do + filename = Pathname.new('empty.csv') + content = main_files[filename] + expect(content).to be_frozen + + expect(extract(filename, content)).to eq( + type: 'internal', + path: 'empty.csv', + content: '', + encoding: 'utf8' + ) + end + + it 'extracts a zero-byte file in the tests folder' do + filename = Pathname.new('empty.csv') + + expect { extract(filename, test_files[filename]) }.not_to raise_error + end + end + + it 'does not re-tag the encoding of the content read from the package' do + filename = Pathname.new('data.csv') + content = main_files[filename] + + extract(filename, content) + + expect(content.encoding).to eq(Encoding::ASCII_8BIT) + end + + it 'appends every extracted file to the data files' do + subject.send(:extract_supporting_file, Pathname.new('data.csv'), main_files[Pathname.new('data.csv')]) + subject.send(:extract_supporting_file, Pathname.new('empty.csv'), test_files[Pathname.new('empty.csv')]) + + expect(subject.data_files.map { |file| file[:path] }).to eq(['data.csv', 'empty.csv']) + end + end + + describe '.utf8_encodable?' do + it 'accepts any valid UTF-8 content' do + expect(subject.send(:utf8_encodable?, Pathname.new('data.csv'), 'plaintext')).to eq(true) + expect(subject.send(:utf8_encodable?, Pathname.new('empty.csv'), '')).to eq(true) + expect(subject.send(:utf8_encodable?, Pathname.new('binary.dat'), "\xFF\xFE".b.force_encoding('UTF-8'))). + to eq(false) + end + + # The Java package service narrows the base implementation, pending Codaveri 'utf8' encoding + # support for all plaintext files in compiled languages. + context 'when the concrete service restricts utf8 encoding' do + subject { Course::Assessment::Question::ProgrammingCodaveri::Java::JavaPackageService.new(nil, package) } + + it 'only accepts plaintext files that require compiling' do + expect(subject.send(:utf8_encodable?, Pathname.new('Helper.java'), 'class Helper {}')).to eq(true) + expect(subject.send(:utf8_encodable?, Pathname.new('data.csv'), 'plaintext')).to eq(false) + end + + it 'extracts an empty supporting file as base64' do + filename = Pathname.new('empty.csv') + + expect(extract(filename, main_files[filename])).to eq( + type: 'internal', + path: 'empty.csv', + content: '', + encoding: 'base64' + ) + end + end + end +end