diff --git a/Gemfile.lock b/Gemfile.lock index f0577bc8de7..47d29164583 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,9 +22,9 @@ GIT GIT remote: https://github.com/Coursemology/polyglot - revision: 7cf1c55b530fd50950144d24524b0647f2d98f76 + revision: bf38cf4d4e8a4ee732c604fc27db2263257a23ec specs: - coursemology-polyglot (0.4.1) + coursemology-polyglot (0.4.2) activesupport (>= 4.2) GIT diff --git a/app/controllers/concerns/codaveri_language_concern.rb b/app/controllers/concerns/codaveri_language_concern.rb index c1156071b4a..d00b5bb490b 100644 --- a/app/controllers/concerns/codaveri_language_concern.rb +++ b/app/controllers/concerns/codaveri_language_concern.rb @@ -29,6 +29,10 @@ def programming_language_map Coursemology::Polyglot::Language::Java::Java21 => { language: 'java', version: '21.0' + }, + Coursemology::Polyglot::Language::CSharp::CSharp5Point0 => { + language: 'csharp', + version: '5.0.201' } } end diff --git a/app/services/course/assessment/question/codaveri_problem_generation_service.rb b/app/services/course/assessment/question/codaveri_problem_generation_service.rb index 930677c36ad..1f0f4aacd93 100644 --- a/app/services/course/assessment/question/codaveri_problem_generation_service.rb +++ b/app/services/course/assessment/question/codaveri_problem_generation_service.rb @@ -1,8 +1,27 @@ # frozen_string_literal: true -class Course::Assessment::Question::CodaveriProblemGenerationService +class Course::Assessment::Question::CodaveriProblemGenerationService # rubocop:disable Metrics/ClassLength POLL_INTERVAL_SECONDS = 2 MAX_POLL_RETRIES = 1000 + LANGUAGE_FILENAME_MAPPING = { + 'python' => 'main.py', + 'r' => 'main.R', + 'javascript' => 'main.js', + 'csharp' => 'main.cs', + 'go' => 'main.go', + 'rust' => 'main.rs', + 'typescript' => 'main.ts' + }.freeze + + LANGUAGE_TESTCASE_TYPE_MAPPING = { + 'r' => 'IO', + 'javascript' => 'IO', + 'csharp' => 'IO', + 'go' => 'IO', + 'rust' => 'IO', + 'typescript' => 'IO' + }.freeze + def codaveri_generate_problem response_status, response_body, generation_id = send_problem_generation_request poll_count = 0 @@ -23,7 +42,7 @@ def codaveri_generate_problem private - def initialize(assessment, params, language, version) + def initialize(assessment, params, language, version) # rubocop:disable Metrics/AbcSize custom_prompt = params[:custom_prompt].to_s || '' @payload = { userId: assessment.creator_id.to_s, @@ -71,15 +90,15 @@ def initialize(assessment, params, language, version) end def generate_payload_file_name(codaveri_language, file_content) - return 'main.py' if codaveri_language == 'python' - return 'main.R' if codaveri_language == 'r' + return LANGUAGE_FILENAME_MAPPING[codaveri_language] if LANGUAGE_FILENAME_MAPPING.key?(codaveri_language) match = file_content&.match(/\bclass\s+(\w+)\s*\{/) match ? "#{match[1]}.java" : 'Main.java' end def generate_payload_testcases_type(codaveri_language) - codaveri_language == 'r' ? 'IO' : 'expression' + # New languages supported by Codaveri only allow IO test cases. + LANGUAGE_TESTCASE_TYPE_MAPPING.fetch(codaveri_language, 'expression') end def generate_payload_io_test_case(test_case, visibility, index) diff --git a/app/services/course/assessment/question/programming/c_sharp/c_sharp_makefile b/app/services/course/assessment/question/programming/c_sharp/c_sharp_makefile new file mode 100644 index 00000000000..1a77a465169 --- /dev/null +++ b/app/services/course/assessment/question/programming/c_sharp/c_sharp_makefile @@ -0,0 +1,24 @@ +prepare: + +compile: submission/template.cs tests/prepend.cs tests/append.cs + cat tests/prepend.cs submission/template.cs tests/append.cs > answer.cs + +public: + echo "Not Implemented" + +private: + echo "Not Implemented" + +evaluation: + echo "Not Implemented" + +solution: solution.cs + echo "Not Implemented" + +solution.cs: solution/template.cs tests/prepend.cs tests/append.cs + cat tests/prepend.cs solution/template.cs tests/append.cs > solution.cs + +clean: + rm -f answer.cs + rm -f report.xml + rm -f solution.cs diff --git a/app/services/course/assessment/question/programming/c_sharp/c_sharp_package_service.rb b/app/services/course/assessment/question/programming/c_sharp/c_sharp_package_service.rb new file mode 100644 index 00000000000..339ba24d50e --- /dev/null +++ b/app/services/course/assessment/question/programming/c_sharp/c_sharp_package_service.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +class Course::Assessment::Question::Programming::CSharp::CSharpPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::Programming::LanguagePackageService + def submission_templates + [ + { + filename: 'template.cs', + content: @test_params[:submission] || '' + } + ] + end + + def generate_package(old_attachment) + return nil if @test_params.blank? + + @tmp_dir = Dir.mktmpdir + @old_meta = old_attachment.present? ? extract_meta(old_attachment, nil) : nil + data_files_to_keep = old_attachment.present? ? find_data_files_to_keep(old_attachment) : [] + @meta = generate_meta(data_files_to_keep) + + return nil if @meta == @old_meta + + @attachment = generate_zip_file(data_files_to_keep) + FileUtils.remove_entry @tmp_dir if @tmp_dir.present? + @attachment + end + + def extract_meta(attachment, template_files) + return @meta if @meta.present? && attachment == @attachment + + # attachment will be nil if the question is not autograded, in that case the meta data will be + # generated from the template files in the database. + return generate_non_autograded_meta(template_files) if attachment.nil? + + extract_autograded_meta(attachment) + end + + private + + def extract_autograded_meta(attachment) + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + meta = package.meta_file + @old_meta = meta.present? ? JSON.parse(meta) : nil + ensure + next unless package + + temporary_file.close + end + end + + def generate_non_autograded_meta(template_files) + meta = default_meta + + return meta if template_files.blank? + + # For python editor, there is only a single submission template file. + meta[:submission] = template_files.first.content + + meta.as_json + end + + def extract_from_package(package, new_data_filenames, data_files_to_delete) + data_files_to_keep = [] + + if @old_meta.present? + package.unzip_file @tmp_dir + + @old_meta['data_files']&.each do |file| + next if data_files_to_delete.try(:include?, file['filename']) + # new files overrides old ones + next if new_data_filenames.include?(file['filename']) + + data_files_to_keep.append(File.new(File.join(@tmp_dir, file['filename']))) + end + end + + data_files_to_keep + end + + def find_data_files_to_keep(attachment) + new_filenames = (@test_params[:data_files] || []).reject(&:nil?).map(&:original_filename) + + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + return extract_from_package(package, new_filenames, @test_params[:data_files_to_delete]) + ensure + next unless package + + temporary_file.close + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def generate_zip_file(data_files_to_keep) + tmp = Tempfile.new(['package', '.zip']) + makefile_path = get_file_path('c_sharp_makefile') + + Zip::OutputStream.open(tmp.path) do |zip| + # Create solution directory with template file + zip.put_next_entry 'solution/' + zip.put_next_entry 'solution/template.cs' + zip.print @test_params[:solution] + zip.print "\n" + + # Create submission directory with template file + zip.put_next_entry 'submission/' + zip.put_next_entry 'submission/template.cs' + zip.print @test_params[:submission] + zip.print "\n" + + # Create tests directory with prepend, append and autograde files + zip.put_next_entry 'tests/' + zip.put_next_entry 'tests/append.cs' + zip.print "\n" + zip.print @test_params[:append] + zip.print "\n" + + zip.put_next_entry 'tests/prepend.cs' + zip.print @test_params[:prepend] + zip.print "\n" + + [:public, :private, :evaluation].each do |test_type| + zip_test_files(test_type, zip) + end + + # Creates Makefile + zip.put_next_entry 'Makefile' + zip.print File.read(makefile_path) + + zip.put_next_entry '.meta' + zip.print @meta.to_json + end + + Zip::File.open(tmp.path) do |zip| + @test_params[:data_files]&.each do |file| + next if file.nil? + + zip.add(file.original_filename, file.tempfile.path) + end + + data_files_to_keep.each do |file| + zip.add(File.basename(file.path), file.path) + end + end + + tmp + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # Retrieves the absolute path of the file specified + # + # @param [String] filename The filename of the file to get the path of + def get_file_path(filename) + File.join(__dir__, filename).freeze + end + + def zip_test_files(test_type, zip) + # Create a dummy report to pass test cases to DB/Codaveri + tests = @test_params[:test_cases] + return unless tests[test_type]&.count&.> 0 + + zip.put_next_entry "report-#{test_type}.xml" + zip.print build_dummy_report(test_type, tests[test_type]) + end + + def build_dummy_report(test_type, test_cases) + Course::Assessment::ProgrammingTestCaseReportBuilder.build_dummy_report(test_type, test_cases, '.cs') + end + + def get_data_files_meta(data_files_to_keep, new_data_files) + data_files = [] + + new_data_files.each do |file| + sha256 = Digest::SHA256.file(file.tempfile).hexdigest + data_files.append(filename: file.original_filename, size: file.tempfile.size, hash: sha256) + end + + data_files_to_keep.each do |file| + sha256 = Digest::SHA256.file(file).hexdigest + data_files.append(filename: File.basename(file.path), size: file.size, hash: sha256) + end + + data_files.sort_by { |file| file[:filename].downcase } + end + + def generate_meta(data_files_to_keep) + meta = default_meta + + [:submission, :solution, :prepend, :append].each { |field| meta[field] = @test_params[field] } + + new_data_files = (@test_params[:data_files] || []).reject(&:nil?) + meta[:data_files] = get_data_files_meta(data_files_to_keep, new_data_files) + + [:public, :private, :evaluation].each do |test_type| + meta[:test_cases][test_type] = @test_params[:test_cases][test_type] || [] + end + + meta.as_json + end +end diff --git a/app/services/course/assessment/question/programming/go/go_makefile b/app/services/course/assessment/question/programming/go/go_makefile new file mode 100644 index 00000000000..d644dcb7c53 --- /dev/null +++ b/app/services/course/assessment/question/programming/go/go_makefile @@ -0,0 +1,24 @@ +prepare: + +compile: submission/template.go tests/prepend.go tests/append.go + cat tests/prepend.go submission/template.go tests/append.go > answer.go + +public: + echo "Not Implemented" + +private: + echo "Not Implemented" + +evaluation: + echo "Not Implemented" + +solution: solution.go + echo "Not Implemented" + +solution.go: solution/template.go tests/prepend.go tests/append.go + cat tests/prepend.go solution/template.go tests/append.go > solution.go + +clean: + rm -f answer.go + rm -f report.xml + rm -f solution.go diff --git a/app/services/course/assessment/question/programming/go/go_package_service.rb b/app/services/course/assessment/question/programming/go/go_package_service.rb new file mode 100644 index 00000000000..ce7e9e28ab4 --- /dev/null +++ b/app/services/course/assessment/question/programming/go/go_package_service.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +class Course::Assessment::Question::Programming::Go::GoPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::Programming::LanguagePackageService + def submission_templates + [ + { + filename: 'template.go', + content: @test_params[:submission] || '' + } + ] + end + + def generate_package(old_attachment) + return nil if @test_params.blank? + + @tmp_dir = Dir.mktmpdir + @old_meta = old_attachment.present? ? extract_meta(old_attachment, nil) : nil + data_files_to_keep = old_attachment.present? ? find_data_files_to_keep(old_attachment) : [] + @meta = generate_meta(data_files_to_keep) + + return nil if @meta == @old_meta + + @attachment = generate_zip_file(data_files_to_keep) + FileUtils.remove_entry @tmp_dir if @tmp_dir.present? + @attachment + end + + def extract_meta(attachment, template_files) + return @meta if @meta.present? && attachment == @attachment + + # attachment will be nil if the question is not autograded, in that case the meta data will be + # generated from the template files in the database. + return generate_non_autograded_meta(template_files) if attachment.nil? + + extract_autograded_meta(attachment) + end + + private + + def extract_autograded_meta(attachment) + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + meta = package.meta_file + @old_meta = meta.present? ? JSON.parse(meta) : nil + ensure + next unless package + + temporary_file.close + end + end + + def generate_non_autograded_meta(template_files) + meta = default_meta + + return meta if template_files.blank? + + # For python editor, there is only a single submission template file. + meta[:submission] = template_files.first.content + + meta.as_json + end + + def extract_from_package(package, new_data_filenames, data_files_to_delete) + data_files_to_keep = [] + + if @old_meta.present? + package.unzip_file @tmp_dir + + @old_meta['data_files']&.each do |file| + next if data_files_to_delete.try(:include?, file['filename']) + # new files overrides old ones + next if new_data_filenames.include?(file['filename']) + + data_files_to_keep.append(File.new(File.join(@tmp_dir, file['filename']))) + end + end + + data_files_to_keep + end + + def find_data_files_to_keep(attachment) + new_filenames = (@test_params[:data_files] || []).reject(&:nil?).map(&:original_filename) + + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + return extract_from_package(package, new_filenames, @test_params[:data_files_to_delete]) + ensure + next unless package + + temporary_file.close + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def generate_zip_file(data_files_to_keep) + tmp = Tempfile.new(['package', '.zip']) + makefile_path = get_file_path('go_makefile') + + Zip::OutputStream.open(tmp.path) do |zip| + # Create solution directory with template file + zip.put_next_entry 'solution/' + zip.put_next_entry 'solution/template.go' + zip.print @test_params[:solution] + zip.print "\n" + + # Create submission directory with template file + zip.put_next_entry 'submission/' + zip.put_next_entry 'submission/template.go' + zip.print @test_params[:submission] + zip.print "\n" + + # Create tests directory with prepend, append and autograde files + zip.put_next_entry 'tests/' + zip.put_next_entry 'tests/append.go' + zip.print "\n" + zip.print @test_params[:append] + zip.print "\n" + + zip.put_next_entry 'tests/prepend.go' + zip.print @test_params[:prepend] + zip.print "\n" + + [:public, :private, :evaluation].each do |test_type| + zip_test_files(test_type, zip) + end + + # Creates Makefile + zip.put_next_entry 'Makefile' + zip.print File.read(makefile_path) + + zip.put_next_entry '.meta' + zip.print @meta.to_json + end + + Zip::File.open(tmp.path) do |zip| + @test_params[:data_files]&.each do |file| + next if file.nil? + + zip.add(file.original_filename, file.tempfile.path) + end + + data_files_to_keep.each do |file| + zip.add(File.basename(file.path), file.path) + end + end + + tmp + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # Retrieves the absolute path of the file specified + # + # @param [String] filename The filename of the file to get the path of + def get_file_path(filename) + File.join(__dir__, filename).freeze + end + + def zip_test_files(test_type, zip) + # Create a dummy report to pass test cases to DB/Codaveri + tests = @test_params[:test_cases] + return unless tests[test_type]&.count&.> 0 + + zip.put_next_entry "report-#{test_type}.xml" + zip.print build_dummy_report(test_type, tests[test_type]) + end + + def build_dummy_report(test_type, test_cases) + Course::Assessment::ProgrammingTestCaseReportBuilder.build_dummy_report(test_type, test_cases, '.go') + end + + def get_data_files_meta(data_files_to_keep, new_data_files) + data_files = [] + + new_data_files.each do |file| + sha256 = Digest::SHA256.file(file.tempfile).hexdigest + data_files.append(filename: file.original_filename, size: file.tempfile.size, hash: sha256) + end + + data_files_to_keep.each do |file| + sha256 = Digest::SHA256.file(file).hexdigest + data_files.append(filename: File.basename(file.path), size: file.size, hash: sha256) + end + + data_files.sort_by { |file| file[:filename].downcase } + end + + def generate_meta(data_files_to_keep) + meta = default_meta + + [:submission, :solution, :prepend, :append].each { |field| meta[field] = @test_params[field] } + + new_data_files = (@test_params[:data_files] || []).reject(&:nil?) + meta[:data_files] = get_data_files_meta(data_files_to_keep, new_data_files) + + [:public, :private, :evaluation].each do |test_type| + meta[:test_cases][test_type] = @test_params[:test_cases][test_type] || [] + end + + meta.as_json + end +end diff --git a/app/services/course/assessment/question/programming/java_script/java_script_makefile b/app/services/course/assessment/question/programming/java_script/java_script_makefile new file mode 100644 index 00000000000..29aaf28fdda --- /dev/null +++ b/app/services/course/assessment/question/programming/java_script/java_script_makefile @@ -0,0 +1,24 @@ +prepare: + +compile: submission/template.js tests/prepend.js tests/append.js + cat tests/prepend.js submission/template.js tests/append.js > answer.js + +public: + echo "Not Implemented" + +private: + echo "Not Implemented" + +evaluation: + echo "Not Implemented" + +solution: solution.js + echo "Not Implemented" + +solution.js: solution/template.js tests/prepend.js tests/append.js + cat tests/prepend.js solution/template.js tests/append.js > solution.js + +clean: + rm -f answer.js + rm -f report.xml + rm -f solution.js diff --git a/app/services/course/assessment/question/programming/java_script/java_script_package_service.rb b/app/services/course/assessment/question/programming/java_script/java_script_package_service.rb new file mode 100644 index 00000000000..c332e101336 --- /dev/null +++ b/app/services/course/assessment/question/programming/java_script/java_script_package_service.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +class Course::Assessment::Question::Programming::JavaScript::JavaScriptPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::Programming::LanguagePackageService + def submission_templates + [ + { + filename: 'template.js', + content: @test_params[:submission] || '' + } + ] + end + + def generate_package(old_attachment) + return nil if @test_params.blank? + + @tmp_dir = Dir.mktmpdir + @old_meta = old_attachment.present? ? extract_meta(old_attachment, nil) : nil + data_files_to_keep = old_attachment.present? ? find_data_files_to_keep(old_attachment) : [] + @meta = generate_meta(data_files_to_keep) + + return nil if @meta == @old_meta + + @attachment = generate_zip_file(data_files_to_keep) + FileUtils.remove_entry @tmp_dir if @tmp_dir.present? + @attachment + end + + def extract_meta(attachment, template_files) + return @meta if @meta.present? && attachment == @attachment + + # attachment will be nil if the question is not autograded, in that case the meta data will be + # generated from the template files in the database. + return generate_non_autograded_meta(template_files) if attachment.nil? + + extract_autograded_meta(attachment) + end + + private + + def extract_autograded_meta(attachment) + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + meta = package.meta_file + @old_meta = meta.present? ? JSON.parse(meta) : nil + ensure + next unless package + + temporary_file.close + end + end + + def generate_non_autograded_meta(template_files) + meta = default_meta + + return meta if template_files.blank? + + # For python editor, there is only a single submission template file. + meta[:submission] = template_files.first.content + + meta.as_json + end + + def extract_from_package(package, new_data_filenames, data_files_to_delete) + data_files_to_keep = [] + + if @old_meta.present? + package.unzip_file @tmp_dir + + @old_meta['data_files']&.each do |file| + next if data_files_to_delete.try(:include?, file['filename']) + # new files overrides old ones + next if new_data_filenames.include?(file['filename']) + + data_files_to_keep.append(File.new(File.join(@tmp_dir, file['filename']))) + end + end + + data_files_to_keep + end + + def find_data_files_to_keep(attachment) + new_filenames = (@test_params[:data_files] || []).reject(&:nil?).map(&:original_filename) + + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + return extract_from_package(package, new_filenames, @test_params[:data_files_to_delete]) + ensure + next unless package + + temporary_file.close + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def generate_zip_file(data_files_to_keep) + tmp = Tempfile.new(['package', '.zip']) + makefile_path = get_file_path('java_script_makefile') + + Zip::OutputStream.open(tmp.path) do |zip| + # Create solution directory with template file + zip.put_next_entry 'solution/' + zip.put_next_entry 'solution/template.js' + zip.print @test_params[:solution] + zip.print "\n" + + # Create submission directory with template file + zip.put_next_entry 'submission/' + zip.put_next_entry 'submission/template.js' + zip.print @test_params[:submission] + zip.print "\n" + + # Create tests directory with prepend, append and autograde files + zip.put_next_entry 'tests/' + zip.put_next_entry 'tests/append.js' + zip.print "\n" + zip.print @test_params[:append] + zip.print "\n" + + zip.put_next_entry 'tests/prepend.js' + zip.print @test_params[:prepend] + zip.print "\n" + + [:public, :private, :evaluation].each do |test_type| + zip_test_files(test_type, zip) + end + + # Creates Makefile + zip.put_next_entry 'Makefile' + zip.print File.read(makefile_path) + + zip.put_next_entry '.meta' + zip.print @meta.to_json + end + + Zip::File.open(tmp.path) do |zip| + @test_params[:data_files]&.each do |file| + next if file.nil? + + zip.add(file.original_filename, file.tempfile.path) + end + + data_files_to_keep.each do |file| + zip.add(File.basename(file.path), file.path) + end + end + + tmp + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # Retrieves the absolute path of the file specified + # + # @param [String] filename The filename of the file to get the path of + def get_file_path(filename) + File.join(__dir__, filename).freeze + end + + def zip_test_files(test_type, zip) + # Create a dummy report to pass test cases to DB/Codaveri + tests = @test_params[:test_cases] + return unless tests[test_type]&.count&.> 0 + + zip.put_next_entry "report-#{test_type}.xml" + zip.print build_dummy_report(test_type, tests[test_type]) + end + + def build_dummy_report(test_type, test_cases) + Course::Assessment::ProgrammingTestCaseReportBuilder.build_dummy_report(test_type, test_cases, '.js') + end + + def get_data_files_meta(data_files_to_keep, new_data_files) + data_files = [] + + new_data_files.each do |file| + sha256 = Digest::SHA256.file(file.tempfile).hexdigest + data_files.append(filename: file.original_filename, size: file.tempfile.size, hash: sha256) + end + + data_files_to_keep.each do |file| + sha256 = Digest::SHA256.file(file).hexdigest + data_files.append(filename: File.basename(file.path), size: file.size, hash: sha256) + end + + data_files.sort_by { |file| file[:filename].downcase } + end + + def generate_meta(data_files_to_keep) + meta = default_meta + + [:submission, :solution, :prepend, :append].each { |field| meta[field] = @test_params[field] } + + new_data_files = (@test_params[:data_files] || []).reject(&:nil?) + meta[:data_files] = get_data_files_meta(data_files_to_keep, new_data_files) + + [:public, :private, :evaluation].each do |test_type| + meta[:test_cases][test_type] = @test_params[:test_cases][test_type] || [] + end + + meta.as_json + end +end diff --git a/app/services/course/assessment/question/programming/programming_package_service.rb b/app/services/course/assessment/question/programming/programming_package_service.rb index b2ab727067d..859f4d018c8 100644 --- a/app/services/course/assessment/question/programming/programming_package_service.rb +++ b/app/services/course/assessment/question/programming/programming_package_service.rb @@ -39,7 +39,7 @@ def extract_meta private - def init_language_package_service(params) + def init_language_package_service(params) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity @language_package_service = case @language when Coursemology::Polyglot::Language::Python @@ -50,6 +50,16 @@ def init_language_package_service(params) Course::Assessment::Question::Programming::Java::JavaPackageService.new params when Coursemology::Polyglot::Language::R Course::Assessment::Question::Programming::R::RPackageService.new params + when Coursemology::Polyglot::Language::CSharp + Course::Assessment::Question::Programming::CSharp::CSharpPackageService.new params + when Coursemology::Polyglot::Language::JavaScript + Course::Assessment::Question::Programming::JavaScript::JavaScriptPackageService.new params + when Coursemology::Polyglot::Language::Go + Course::Assessment::Question::Programming::Go::GoPackageService.new params + when Coursemology::Polyglot::Language::Rust + Course::Assessment::Question::Programming::Rust::RustPackageService.new params + when Coursemology::Polyglot::Language::TypeScript + Course::Assessment::Question::Programming::TypeScript::TypeScriptPackageService.new params else raise NotImplementedError end diff --git a/app/services/course/assessment/question/programming/r/r_package_service.rb b/app/services/course/assessment/question/programming/r/r_package_service.rb index 56999af5fe4..14069bf162c 100644 --- a/app/services/course/assessment/question/programming/r/r_package_service.rb +++ b/app/services/course/assessment/question/programming/r/r_package_service.rb @@ -67,7 +67,7 @@ def extract_from_package(package, new_data_filenames, data_files_to_delete) package.unzip_file @tmp_dir @old_meta['data_files']&.each do |file| - next if data_files_to_delete.try(:include?, (file['filename'])) + next if data_files_to_delete.try(:include?, file['filename']) # new files overrides old ones next if new_data_filenames.include?(file['filename']) @@ -165,7 +165,7 @@ def zip_test_files(test_type, zip) end def build_dummy_report(test_type, test_cases) - Course::Assessment::ProgrammingTestCaseReportBuilder.build_dummy_report(test_type, test_cases) + Course::Assessment::ProgrammingTestCaseReportBuilder.build_dummy_report(test_type, test_cases, '.R') end def get_data_files_meta(data_files_to_keep, new_data_files) diff --git a/app/services/course/assessment/question/programming/rust/rust_makefile b/app/services/course/assessment/question/programming/rust/rust_makefile new file mode 100644 index 00000000000..917266cc0fe --- /dev/null +++ b/app/services/course/assessment/question/programming/rust/rust_makefile @@ -0,0 +1,24 @@ +prepare: + +compile: submission/template.rs tests/prepend.rs tests/append.rs + cat tests/prepend.rs submission/template.rs tests/append.rs > answer.rs + +public: + echo "Not Implemented" + +private: + echo "Not Implemented" + +evaluation: + echo "Not Implemented" + +solution: solution.rs + echo "Not Implemented" + +solution.rs: solution/template.rs tests/prepend.rs tests/append.rs + cat tests/prepend.rs solution/template.rs tests/append.rs > solution.rs + +clean: + rm -f answer.rs + rm -f report.xml + rm -f solution.rs diff --git a/app/services/course/assessment/question/programming/rust/rust_package_service.rb b/app/services/course/assessment/question/programming/rust/rust_package_service.rb new file mode 100644 index 00000000000..3df1a2d000b --- /dev/null +++ b/app/services/course/assessment/question/programming/rust/rust_package_service.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +class Course::Assessment::Question::Programming::Rust::RustPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::Programming::LanguagePackageService + def submission_templates + [ + { + filename: 'template.rs', + content: @test_params[:submission] || '' + } + ] + end + + def generate_package(old_attachment) + return nil if @test_params.blank? + + @tmp_dir = Dir.mktmpdir + @old_meta = old_attachment.present? ? extract_meta(old_attachment, nil) : nil + data_files_to_keep = old_attachment.present? ? find_data_files_to_keep(old_attachment) : [] + @meta = generate_meta(data_files_to_keep) + + return nil if @meta == @old_meta + + @attachment = generate_zip_file(data_files_to_keep) + FileUtils.remove_entry @tmp_dir if @tmp_dir.present? + @attachment + end + + def extract_meta(attachment, template_files) + return @meta if @meta.present? && attachment == @attachment + + # attachment will be nil if the question is not autograded, in that case the meta data will be + # generated from the template files in the database. + return generate_non_autograded_meta(template_files) if attachment.nil? + + extract_autograded_meta(attachment) + end + + private + + def extract_autograded_meta(attachment) + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + meta = package.meta_file + @old_meta = meta.present? ? JSON.parse(meta) : nil + ensure + next unless package + + temporary_file.close + end + end + + def generate_non_autograded_meta(template_files) + meta = default_meta + + return meta if template_files.blank? + + # For python editor, there is only a single submission template file. + meta[:submission] = template_files.first.content + + meta.as_json + end + + def extract_from_package(package, new_data_filenames, data_files_to_delete) + data_files_to_keep = [] + + if @old_meta.present? + package.unzip_file @tmp_dir + + @old_meta['data_files']&.each do |file| + next if data_files_to_delete.try(:include?, file['filename']) + # new files overrides old ones + next if new_data_filenames.include?(file['filename']) + + data_files_to_keep.append(File.new(File.join(@tmp_dir, file['filename']))) + end + end + + data_files_to_keep + end + + def find_data_files_to_keep(attachment) + new_filenames = (@test_params[:data_files] || []).reject(&:nil?).map(&:original_filename) + + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + return extract_from_package(package, new_filenames, @test_params[:data_files_to_delete]) + ensure + next unless package + + temporary_file.close + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def generate_zip_file(data_files_to_keep) + tmp = Tempfile.new(['package', '.zip']) + makefile_path = get_file_path('rust_makefile') + + Zip::OutputStream.open(tmp.path) do |zip| + # Create solution directory with template file + zip.put_next_entry 'solution/' + zip.put_next_entry 'solution/template.rs' + zip.print @test_params[:solution] + zip.print "\n" + + # Create submission directory with template file + zip.put_next_entry 'submission/' + zip.put_next_entry 'submission/template.rs' + zip.print @test_params[:submission] + zip.print "\n" + + # Create tests directory with prepend, append and autograde files + zip.put_next_entry 'tests/' + zip.put_next_entry 'tests/append.rs' + zip.print "\n" + zip.print @test_params[:append] + zip.print "\n" + + zip.put_next_entry 'tests/prepend.rs' + zip.print @test_params[:prepend] + zip.print "\n" + + [:public, :private, :evaluation].each do |test_type| + zip_test_files(test_type, zip) + end + + # Creates Makefile + zip.put_next_entry 'Makefile' + zip.print File.read(makefile_path) + + zip.put_next_entry '.meta' + zip.print @meta.to_json + end + + Zip::File.open(tmp.path) do |zip| + @test_params[:data_files]&.each do |file| + next if file.nil? + + zip.add(file.original_filename, file.tempfile.path) + end + + data_files_to_keep.each do |file| + zip.add(File.basename(file.path), file.path) + end + end + + tmp + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # Retrieves the absolute path of the file specified + # + # @param [String] filename The filename of the file to get the path of + def get_file_path(filename) + File.join(__dir__, filename).freeze + end + + def zip_test_files(test_type, zip) + # Create a dummy report to pass test cases to DB/Codaveri + tests = @test_params[:test_cases] + return unless tests[test_type]&.count&.> 0 + + zip.put_next_entry "report-#{test_type}.xml" + zip.print build_dummy_report(test_type, tests[test_type]) + end + + def build_dummy_report(test_type, test_cases) + Course::Assessment::ProgrammingTestCaseReportBuilder.build_dummy_report(test_type, test_cases, '.rs') + end + + def get_data_files_meta(data_files_to_keep, new_data_files) + data_files = [] + + new_data_files.each do |file| + sha256 = Digest::SHA256.file(file.tempfile).hexdigest + data_files.append(filename: file.original_filename, size: file.tempfile.size, hash: sha256) + end + + data_files_to_keep.each do |file| + sha256 = Digest::SHA256.file(file).hexdigest + data_files.append(filename: File.basename(file.path), size: file.size, hash: sha256) + end + + data_files.sort_by { |file| file[:filename].downcase } + end + + def generate_meta(data_files_to_keep) + meta = default_meta + + [:submission, :solution, :prepend, :append].each { |field| meta[field] = @test_params[field] } + + new_data_files = (@test_params[:data_files] || []).reject(&:nil?) + meta[:data_files] = get_data_files_meta(data_files_to_keep, new_data_files) + + [:public, :private, :evaluation].each do |test_type| + meta[:test_cases][test_type] = @test_params[:test_cases][test_type] || [] + end + + meta.as_json + end +end diff --git a/app/services/course/assessment/question/programming/type_script/type_script_makefile b/app/services/course/assessment/question/programming/type_script/type_script_makefile new file mode 100644 index 00000000000..ff6a046f0ae --- /dev/null +++ b/app/services/course/assessment/question/programming/type_script/type_script_makefile @@ -0,0 +1,24 @@ +prepare: + +compile: submission/template.ts tests/prepend.ts tests/append.ts + cat tests/prepend.ts submission/template.ts tests/append.ts > answer.ts + +public: + echo "Not Implemented" + +private: + echo "Not Implemented" + +evaluation: + echo "Not Implemented" + +solution: solution.ts + echo "Not Implemented" + +solution.ts: solution/template.ts tests/prepend.ts tests/append.ts + cat tests/prepend.ts solution/template.ts tests/append.ts > solution.ts + +clean: + rm -f answer.ts + rm -f report.xml + rm -f solution.ts diff --git a/app/services/course/assessment/question/programming/type_script/type_script_package_service.rb b/app/services/course/assessment/question/programming/type_script/type_script_package_service.rb new file mode 100644 index 00000000000..853c46b5c15 --- /dev/null +++ b/app/services/course/assessment/question/programming/type_script/type_script_package_service.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +class Course::Assessment::Question::Programming::TypeScript::TypeScriptPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::Programming::LanguagePackageService + def submission_templates + [ + { + filename: 'template.ts', + content: @test_params[:submission] || '' + } + ] + end + + def generate_package(old_attachment) + return nil if @test_params.blank? + + @tmp_dir = Dir.mktmpdir + @old_meta = old_attachment.present? ? extract_meta(old_attachment, nil) : nil + data_files_to_keep = old_attachment.present? ? find_data_files_to_keep(old_attachment) : [] + @meta = generate_meta(data_files_to_keep) + + return nil if @meta == @old_meta + + @attachment = generate_zip_file(data_files_to_keep) + FileUtils.remove_entry @tmp_dir if @tmp_dir.present? + @attachment + end + + def extract_meta(attachment, template_files) + return @meta if @meta.present? && attachment == @attachment + + # attachment will be nil if the question is not autograded, in that case the meta data will be + # generated from the template files in the database. + return generate_non_autograded_meta(template_files) if attachment.nil? + + extract_autograded_meta(attachment) + end + + private + + def extract_autograded_meta(attachment) + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + meta = package.meta_file + @old_meta = meta.present? ? JSON.parse(meta) : nil + ensure + next unless package + + temporary_file.close + end + end + + def generate_non_autograded_meta(template_files) + meta = default_meta + + return meta if template_files.blank? + + # For python editor, there is only a single submission template file. + meta[:submission] = template_files.first.content + + meta.as_json + end + + def extract_from_package(package, new_data_filenames, data_files_to_delete) + data_files_to_keep = [] + + if @old_meta.present? + package.unzip_file @tmp_dir + + @old_meta['data_files']&.each do |file| + next if data_files_to_delete.try(:include?, file['filename']) + # new files overrides old ones + next if new_data_filenames.include?(file['filename']) + + data_files_to_keep.append(File.new(File.join(@tmp_dir, file['filename']))) + end + end + + data_files_to_keep + end + + def find_data_files_to_keep(attachment) + new_filenames = (@test_params[:data_files] || []).reject(&:nil?).map(&:original_filename) + + attachment.open(binmode: true) do |temporary_file| + package = Course::Assessment::ProgrammingPackage.new(temporary_file) + return extract_from_package(package, new_filenames, @test_params[:data_files_to_delete]) + ensure + next unless package + + temporary_file.close + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def generate_zip_file(data_files_to_keep) + tmp = Tempfile.new(['package', '.zip']) + makefile_path = get_file_path('type_script_makefile') + + Zip::OutputStream.open(tmp.path) do |zip| + # Create solution directory with template file + zip.put_next_entry 'solution/' + zip.put_next_entry 'solution/template.ts' + zip.print @test_params[:solution] + zip.print "\n" + + # Create submission directory with template file + zip.put_next_entry 'submission/' + zip.put_next_entry 'submission/template.ts' + zip.print @test_params[:submission] + zip.print "\n" + + # Create tests directory with prepend, append and autograde files + zip.put_next_entry 'tests/' + zip.put_next_entry 'tests/append.ts' + zip.print "\n" + zip.print @test_params[:append] + zip.print "\n" + + zip.put_next_entry 'tests/prepend.ts' + zip.print @test_params[:prepend] + zip.print "\n" + + [:public, :private, :evaluation].each do |test_type| + zip_test_files(test_type, zip) + end + + # Creates Makefile + zip.put_next_entry 'Makefile' + zip.print File.read(makefile_path) + + zip.put_next_entry '.meta' + zip.print @meta.to_json + end + + Zip::File.open(tmp.path) do |zip| + @test_params[:data_files]&.each do |file| + next if file.nil? + + zip.add(file.original_filename, file.tempfile.path) + end + + data_files_to_keep.each do |file| + zip.add(File.basename(file.path), file.path) + end + end + + tmp + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # Retrieves the absolute path of the file specified + # + # @param [String] filename The filename of the file to get the path of + def get_file_path(filename) + File.join(__dir__, filename).freeze + end + + def zip_test_files(test_type, zip) + # Create a dummy report to pass test cases to DB/Codaveri + tests = @test_params[:test_cases] + return unless tests[test_type]&.count&.> 0 + + zip.put_next_entry "report-#{test_type}.xml" + zip.print build_dummy_report(test_type, tests[test_type]) + end + + def build_dummy_report(test_type, test_cases) + Course::Assessment::ProgrammingTestCaseReportBuilder.build_dummy_report(test_type, test_cases, '.ts') + end + + def get_data_files_meta(data_files_to_keep, new_data_files) + data_files = [] + + new_data_files.each do |file| + sha256 = Digest::SHA256.file(file.tempfile).hexdigest + data_files.append(filename: file.original_filename, size: file.tempfile.size, hash: sha256) + end + + data_files_to_keep.each do |file| + sha256 = Digest::SHA256.file(file).hexdigest + data_files.append(filename: File.basename(file.path), size: file.size, hash: sha256) + end + + data_files.sort_by { |file| file[:filename].downcase } + end + + def generate_meta(data_files_to_keep) + meta = default_meta + + [:submission, :solution, :prepend, :append].each { |field| meta[field] = @test_params[field] } + + new_data_files = (@test_params[:data_files] || []).reject(&:nil?) + meta[:data_files] = get_data_files_meta(data_files_to_keep, new_data_files) + + [:public, :private, :evaluation].each do |test_type| + meta[:test_cases][test_type] = @test_params[:test_cases][test_type] || [] + end + + meta.as_json + end +end 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 new file mode 100644 index 00000000000..16c93ae6f30 --- /dev/null +++ b/app/services/course/assessment/question/programming_codaveri/c_sharp/c_sharp_package_service.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true +class Course::Assessment::Question::ProgrammingCodaveri::CSharp::CSharpPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::ProgrammingCodaveri::LanguagePackageService + def process_solutions + extract_main_solution + end + + def process_test_cases + extract_test_cases + end + + def process_data + extract_supporting_files + end + + def process_templates + extract_template + end + + private + + # Extracts the main solution of a programing question problem and append it to the + # [:resources][0][:solutions] array array for the problem management API request body. + def extract_main_solution + main_solution_object = default_codaveri_solution_template + + solution_files = @package.solution_files + + main_solution_object[:path] = 'template.cs' + main_solution_object[:content] = solution_files[Pathname.new('template.cs')] + return if main_solution_object[:content].blank? + + @solution_files.append(main_solution_object) + end + + # In a programming question package, there may be data files that are included in the package + # The contents of these files are appended to the "additionalFiles" array in the API Request main body. + def extract_supporting_files + extract_supporting_main_files + extract_supporting_tests_files + extract_supporting_submission_files + extract_supporting_solution_files + end + + # Finds and extracts all contents of additional files in the root package folder + # (excluding the default Makefile and .meta files). + # All data files uploaded through the Coursemology UI will be extracted in this function. + # The remaining functions are to capture files manually added to the package ZIP by the user. + def extract_supporting_main_files + main_files = @package.main_files.compact.to_h + main_filenames = main_files.keys + + main_filenames.each do |filename| + next if ['Makefile', '.meta', 'report-public.xml', 'report-private.xml', + 'report-evaluation.xml'].include?(filename.to_s) + + extract_supporting_file(filename, main_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the test files folder + # (excluding the default append.cs and prepend.cs files). + def extract_supporting_tests_files + test_files = @package.test_files + test_filenames = test_files.keys + + test_filenames.each do |filename| + next if ['append.cs', 'prepend.cs'].include?(filename.to_s) + + extract_supporting_file(filename, test_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the submission files folder + # (excluding the default template.cs file). + def extract_supporting_submission_files + submission_files = @package.submission_files + submission_filenames = submission_files.keys + + submission_filenames.each do |filename| + next if ['template.cs'].include?(filename.to_s) + + extract_supporting_file(filename, submission_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the solution files folder + # (excluding the default template.cs file). + def extract_supporting_solution_files + solution_files = @package.solution_files + solution_filenames = solution_files.keys + + solution_filenames.each do |filename| + next if ['template.cs'].include?(filename.to_s) + + extract_supporting_file(filename, solution_files[filename]) + 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 + test_cases_with_id = preload_question_test_cases + @package.test_reports.each do |test_type, test_report| + Course::Assessment::ProgrammingTestCaseReport.new(test_report).test_cases.each do |test_case| + test_case_object = default_codaveri_io_test_case_template + + # combine all extracted data + test_case_object[:index] = test_cases_with_id[test_case.name] + test_case_object[:timeout] = @question.time_limit * 1000 if @question.time_limit # in millisecond + test_case_object[:input] = test_case.expression + test_case_object[:output] = test_case.expected + test_case_object[:hint] = test_case.hint + test_case_object[:display] = test_case.display + test_case_object[:visibility] = codaveri_test_case_visibility(test_type) + @test_case_files.append(test_case_object) + end + end + end + + # Extracts template file from submissions folder and append it to the + # [:resources][0][:templates] array for the problem management API request body. + def extract_template + main_template_object = default_codaveri_template_template + + submission_files = @package.submission_files + test_files = @package.test_files + + main_template_object[:path] = 'template.cs' + main_template_object[:content] = submission_files[Pathname.new('template.cs')] + + main_template_object[:prefix] = test_files[Pathname.new('prepend.cs')] + main_template_object[:suffix] = test_files[Pathname.new('append.cs')] + + @template_files.append(main_template_object) + end + + def preload_question_test_cases + # The regex below finds all text after the last slash + # (eg AutoGrader/AutoGrader/test_private_4 -> test_private_4) + @question.test_cases.pluck(:identifier, :id).to_h { |x| [x[0].match(/[^\/]+$/).to_s, x[1]] } + end + + def codaveri_test_case_visibility(test_case_type) + case test_case_type + when :public + 'public' + when :private + 'private' + when :evaluation + 'hidden' + else + test_case_type + end + end +end 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 new file mode 100644 index 00000000000..4156f3dd069 --- /dev/null +++ b/app/services/course/assessment/question/programming_codaveri/go/go_package_service.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true +class Course::Assessment::Question::ProgrammingCodaveri::Go::GoPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::ProgrammingCodaveri::LanguagePackageService + def process_solutions + extract_main_solution + end + + def process_test_cases + extract_test_cases + end + + def process_data + extract_supporting_files + end + + def process_templates + extract_template + end + + private + + # Extracts the main solution of a programing question problem and append it to the + # [:resources][0][:solutions] array array for the problem management API request body. + def extract_main_solution + main_solution_object = default_codaveri_solution_template + + solution_files = @package.solution_files + + main_solution_object[:path] = 'template.go' + main_solution_object[:content] = solution_files[Pathname.new('template.go')] + return if main_solution_object[:content].blank? + + @solution_files.append(main_solution_object) + end + + # In a programming question package, there may be data files that are included in the package + # The contents of these files are appended to the "additionalFiles" array in the API Request main body. + def extract_supporting_files + extract_supporting_main_files + extract_supporting_tests_files + extract_supporting_submission_files + extract_supporting_solution_files + end + + # Finds and extracts all contents of additional files in the root package folder + # (excluding the default Makefile and .meta files). + # All data files uploaded through the Coursemology UI will be extracted in this function. + # The remaining functions are to capture files manually added to the package ZIP by the user. + def extract_supporting_main_files + main_files = @package.main_files.compact.to_h + main_filenames = main_files.keys + + main_filenames.each do |filename| + next if ['Makefile', '.meta', 'report-public.xml', 'report-private.xml', + 'report-evaluation.xml'].include?(filename.to_s) + + extract_supporting_file(filename, main_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the test files folder + # (excluding the default append.go and prepend.go files). + def extract_supporting_tests_files + test_files = @package.test_files + test_filenames = test_files.keys + + test_filenames.each do |filename| + next if ['append.go', 'prepend.go'].include?(filename.to_s) + + extract_supporting_file(filename, test_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the submission files folder + # (excluding the default template.go file). + def extract_supporting_submission_files + submission_files = @package.submission_files + submission_filenames = submission_files.keys + + submission_filenames.each do |filename| + next if ['template.go'].include?(filename.to_s) + + extract_supporting_file(filename, submission_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the solution files folder + # (excluding the default template.go file). + def extract_supporting_solution_files + solution_files = @package.solution_files + solution_filenames = solution_files.keys + + solution_filenames.each do |filename| + next if ['template.go'].include?(filename.to_s) + + extract_supporting_file(filename, solution_files[filename]) + 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 + test_cases_with_id = preload_question_test_cases + @package.test_reports.each do |test_type, test_report| + Course::Assessment::ProgrammingTestCaseReport.new(test_report).test_cases.each do |test_case| + test_case_object = default_codaveri_io_test_case_template + + # combine all extracted data + test_case_object[:index] = test_cases_with_id[test_case.name] + test_case_object[:timeout] = @question.time_limit * 1000 if @question.time_limit # in millisecond + test_case_object[:input] = test_case.expression + test_case_object[:output] = test_case.expected + test_case_object[:hint] = test_case.hint + test_case_object[:display] = test_case.display + test_case_object[:visibility] = codaveri_test_case_visibility(test_type) + @test_case_files.append(test_case_object) + end + end + end + + # Extracts template file from submissions folder and append it to the + # [:resources][0][:templates] array for the problem management API request body. + def extract_template + main_template_object = default_codaveri_template_template + + submission_files = @package.submission_files + test_files = @package.test_files + + main_template_object[:path] = 'template.go' + main_template_object[:content] = submission_files[Pathname.new('template.go')] + + main_template_object[:prefix] = test_files[Pathname.new('prepend.go')] + main_template_object[:suffix] = test_files[Pathname.new('append.go')] + + @template_files.append(main_template_object) + end + + def preload_question_test_cases + # The regex below finds all text after the last slash + # (eg AutoGrader/AutoGrader/test_private_4 -> test_private_4) + @question.test_cases.pluck(:identifier, :id).to_h { |x| [x[0].match(/[^\/]+$/).to_s, x[1]] } + end + + def codaveri_test_case_visibility(test_case_type) + case test_case_type + when :public + 'public' + when :private + 'private' + when :evaluation + 'hidden' + else + test_case_type + end + end +end 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 new file mode 100644 index 00000000000..c5382efd647 --- /dev/null +++ b/app/services/course/assessment/question/programming_codaveri/java_script/java_script_package_service.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true +class Course::Assessment::Question::ProgrammingCodaveri::JavaScript::JavaScriptPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::ProgrammingCodaveri::LanguagePackageService + def process_solutions + extract_main_solution + end + + def process_test_cases + extract_test_cases + end + + def process_data + extract_supporting_files + end + + def process_templates + extract_template + end + + private + + # Extracts the main solution of a programing question problem and append it to the + # [:resources][0][:solutions] array array for the problem management API request body. + def extract_main_solution + main_solution_object = default_codaveri_solution_template + + solution_files = @package.solution_files + + main_solution_object[:path] = 'template.js' + main_solution_object[:content] = solution_files[Pathname.new('template.js')] + return if main_solution_object[:content].blank? + + @solution_files.append(main_solution_object) + end + + # In a programming question package, there may be data files that are included in the package + # The contents of these files are appended to the "additionalFiles" array in the API Request main body. + def extract_supporting_files + extract_supporting_main_files + extract_supporting_tests_files + extract_supporting_submission_files + extract_supporting_solution_files + end + + # Finds and extracts all contents of additional files in the root package folder + # (excluding the default Makefile and .meta files). + # All data files uploaded through the Coursemology UI will be extracted in this function. + # The remaining functions are to capture files manually added to the package ZIP by the user. + def extract_supporting_main_files + main_files = @package.main_files.compact.to_h + main_filenames = main_files.keys + + main_filenames.each do |filename| + next if ['Makefile', '.meta', 'report-public.xml', 'report-private.xml', + 'report-evaluation.xml'].include?(filename.to_s) + + extract_supporting_file(filename, main_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the test files folder + # (excluding the default append.js and prepend.js files). + def extract_supporting_tests_files + test_files = @package.test_files + test_filenames = test_files.keys + + test_filenames.each do |filename| + next if ['append.js', 'prepend.js'].include?(filename.to_s) + + extract_supporting_file(filename, test_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the submission files folder + # (excluding the default template.js file). + def extract_supporting_submission_files + submission_files = @package.submission_files + submission_filenames = submission_files.keys + + submission_filenames.each do |filename| + next if ['template.js'].include?(filename.to_s) + + extract_supporting_file(filename, submission_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the solution files folder + # (excluding the default template.js file). + def extract_supporting_solution_files + solution_files = @package.solution_files + solution_filenames = solution_files.keys + + solution_filenames.each do |filename| + next if ['template.js'].include?(filename.to_s) + + extract_supporting_file(filename, solution_files[filename]) + 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 + test_cases_with_id = preload_question_test_cases + @package.test_reports.each do |test_type, test_report| + Course::Assessment::ProgrammingTestCaseReport.new(test_report).test_cases.each do |test_case| + test_case_object = default_codaveri_io_test_case_template + + # combine all extracted data + test_case_object[:index] = test_cases_with_id[test_case.name] + test_case_object[:timeout] = @question.time_limit * 1000 if @question.time_limit # in millisecond + test_case_object[:input] = test_case.expression + test_case_object[:output] = test_case.expected + test_case_object[:hint] = test_case.hint + test_case_object[:display] = test_case.display + test_case_object[:visibility] = codaveri_test_case_visibility(test_type) + @test_case_files.append(test_case_object) + end + end + end + + # Extracts template file from submissions folder and append it to the + # [:resources][0][:templates] array for the problem management API request body. + def extract_template + main_template_object = default_codaveri_template_template + + submission_files = @package.submission_files + test_files = @package.test_files + + main_template_object[:path] = 'template.js' + main_template_object[:content] = submission_files[Pathname.new('template.js')] + + main_template_object[:prefix] = test_files[Pathname.new('prepend.js')] + main_template_object[:suffix] = test_files[Pathname.new('append.js')] + + @template_files.append(main_template_object) + end + + def preload_question_test_cases + # The regex below finds all text after the last slash + # (eg AutoGrader/AutoGrader/test_private_4 -> test_private_4) + @question.test_cases.pluck(:identifier, :id).to_h { |x| [x[0].match(/[^\/]+$/).to_s, x[1]] } + end + + def codaveri_test_case_visibility(test_case_type) + case test_case_type + when :public + 'public' + when :private + 'private' + when :evaluation + 'hidden' + else + test_case_type + end + end +end diff --git a/app/services/course/assessment/question/programming_codaveri/programming_codaveri_package_service.rb b/app/services/course/assessment/question/programming_codaveri/programming_codaveri_package_service.rb index 22b15b60249..25dfa2e83ba 100644 --- a/app/services/course/assessment/question/programming_codaveri/programming_codaveri_package_service.rb +++ b/app/services/course/assessment/question/programming_codaveri/programming_codaveri_package_service.rb @@ -53,6 +53,16 @@ def init_language_codaveri_package_service(question, package) Course::Assessment::Question::ProgrammingCodaveri::R::RPackageService.new question, package when Coursemology::Polyglot::Language::Java Course::Assessment::Question::ProgrammingCodaveri::Java::JavaPackageService.new question, package + when Coursemology::Polyglot::Language::CSharp + Course::Assessment::Question::ProgrammingCodaveri::CSharp::CSharpPackageService.new question, package + when Coursemology::Polyglot::Language::JavaScript + Course::Assessment::Question::ProgrammingCodaveri::JavaScript::JavaScriptPackageService.new question, package + when Coursemology::Polyglot::Language::Go + Course::Assessment::Question::ProgrammingCodaveri::Go::GoPackageService.new question, package + when Coursemology::Polyglot::Language::Rust + Course::Assessment::Question::ProgrammingCodaveri::Rust::RustPackageService.new question, package + when Coursemology::Polyglot::Language::TypeScript + Course::Assessment::Question::ProgrammingCodaveri::TypeScript::TypeScriptPackageService.new question, package else raise NotImplementedError end 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 669a4d943f3..999a0fc737c 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 @@ -1,6 +1,5 @@ # frozen_string_literal: true -# rubocop:disable Metrics/abcSize -class Course::Assessment::Question::ProgrammingCodaveri::R::RPackageService < +class Course::Assessment::Question::ProgrammingCodaveri::R::RPackageService < # rubocop:disable Metrics/ClassLength Course::Assessment::Question::ProgrammingCodaveri::LanguagePackageService def process_solutions extract_main_solution @@ -121,7 +120,7 @@ def extract_supporting_file(filename, content) # 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 + def extract_test_cases # rubocop:disable Metrics/AbcSize test_cases_with_id = preload_question_test_cases @package.test_reports.each do |test_type, test_report| Course::Assessment::ProgrammingTestCaseReport.new(test_report).test_cases.each do |test_case| @@ -176,4 +175,3 @@ def codaveri_test_case_visibility(test_case_type) end end end -# rubocop:enable 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 new file mode 100644 index 00000000000..4824130cfee --- /dev/null +++ b/app/services/course/assessment/question/programming_codaveri/rust/rust_package_service.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true +class Course::Assessment::Question::ProgrammingCodaveri::Rust::RustPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::ProgrammingCodaveri::LanguagePackageService + def process_solutions + extract_main_solution + end + + def process_test_cases + extract_test_cases + end + + def process_data + extract_supporting_files + end + + def process_templates + extract_template + end + + private + + # Extracts the main solution of a programing question problem and append it to the + # [:resources][0][:solutions] array array for the problem management API request body. + def extract_main_solution + main_solution_object = default_codaveri_solution_template + + solution_files = @package.solution_files + + main_solution_object[:path] = 'template.rs' + main_solution_object[:content] = solution_files[Pathname.new('template.rs')] + return if main_solution_object[:content].blank? + + @solution_files.append(main_solution_object) + end + + # In a programming question package, there may be data files that are included in the package + # The contents of these files are appended to the "additionalFiles" array in the API Request main body. + def extract_supporting_files + extract_supporting_main_files + extract_supporting_tests_files + extract_supporting_submission_files + extract_supporting_solution_files + end + + # Finds and extracts all contents of additional files in the root package folder + # (excluding the default Makefile and .meta files). + # All data files uploaded through the Coursemology UI will be extracted in this function. + # The remaining functions are to capture files manually added to the package ZIP by the user. + def extract_supporting_main_files + main_files = @package.main_files.compact.to_h + main_filenames = main_files.keys + + main_filenames.each do |filename| + next if ['Makefile', '.meta', 'report-public.xml', 'report-private.xml', + 'report-evaluation.xml'].include?(filename.to_s) + + extract_supporting_file(filename, main_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the test files folder + # (excluding the default append.rs and prepend.rs files). + def extract_supporting_tests_files + test_files = @package.test_files + test_filenames = test_files.keys + + test_filenames.each do |filename| + next if ['append.rs', 'prepend.rs'].include?(filename.to_s) + + extract_supporting_file(filename, test_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the submission files folder + # (excluding the default template.rs file). + def extract_supporting_submission_files + submission_files = @package.submission_files + submission_filenames = submission_files.keys + + submission_filenames.each do |filename| + next if ['template.rs'].include?(filename.to_s) + + extract_supporting_file(filename, submission_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the solution files folder + # (excluding the default template.rs file). + def extract_supporting_solution_files + solution_files = @package.solution_files + solution_filenames = solution_files.keys + + solution_filenames.each do |filename| + next if ['template.rs'].include?(filename.to_s) + + extract_supporting_file(filename, solution_files[filename]) + 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 + test_cases_with_id = preload_question_test_cases + @package.test_reports.each do |test_type, test_report| + Course::Assessment::ProgrammingTestCaseReport.new(test_report).test_cases.each do |test_case| + test_case_object = default_codaveri_io_test_case_template + + # combine all extracted data + test_case_object[:index] = test_cases_with_id[test_case.name] + test_case_object[:timeout] = @question.time_limit * 1000 if @question.time_limit # in millisecond + test_case_object[:input] = test_case.expression + test_case_object[:output] = test_case.expected + test_case_object[:hint] = test_case.hint + test_case_object[:display] = test_case.display + test_case_object[:visibility] = codaveri_test_case_visibility(test_type) + @test_case_files.append(test_case_object) + end + end + end + + # Extracts template file from submissions folder and append it to the + # [:resources][0][:templates] array for the problem management API request body. + def extract_template + main_template_object = default_codaveri_template_template + + submission_files = @package.submission_files + test_files = @package.test_files + + main_template_object[:path] = 'template.rs' + main_template_object[:content] = submission_files[Pathname.new('template.rs')] + + main_template_object[:prefix] = test_files[Pathname.new('prepend.rs')] + main_template_object[:suffix] = test_files[Pathname.new('append.rs')] + + @template_files.append(main_template_object) + end + + def preload_question_test_cases + # The regex below finds all text after the last slash + # (eg AutoGrader/AutoGrader/test_private_4 -> test_private_4) + @question.test_cases.pluck(:identifier, :id).to_h { |x| [x[0].match(/[^\/]+$/).to_s, x[1]] } + end + + def codaveri_test_case_visibility(test_case_type) + case test_case_type + when :public + 'public' + when :private + 'private' + when :evaluation + 'hidden' + else + test_case_type + end + end +end 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 new file mode 100644 index 00000000000..8b81504fd73 --- /dev/null +++ b/app/services/course/assessment/question/programming_codaveri/type_script/type_script_package_service.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true +class Course::Assessment::Question::ProgrammingCodaveri::TypeScript::TypeScriptPackageService < # rubocop:disable Metrics/ClassLength + Course::Assessment::Question::ProgrammingCodaveri::LanguagePackageService + def process_solutions + extract_main_solution + end + + def process_test_cases + extract_test_cases + end + + def process_data + extract_supporting_files + end + + def process_templates + extract_template + end + + private + + # Extracts the main solution of a programing question problem and append it to the + # [:resources][0][:solutions] array array for the problem management API request body. + def extract_main_solution + main_solution_object = default_codaveri_solution_template + + solution_files = @package.solution_files + + main_solution_object[:path] = 'template.ts' + main_solution_object[:content] = solution_files[Pathname.new('template.ts')] + return if main_solution_object[:content].blank? + + @solution_files.append(main_solution_object) + end + + # In a programming question package, there may be data files that are included in the package + # The contents of these files are appended to the "additionalFiles" array in the API Request main body. + def extract_supporting_files + extract_supporting_main_files + extract_supporting_tests_files + extract_supporting_submission_files + extract_supporting_solution_files + end + + # Finds and extracts all contents of additional files in the root package folder + # (excluding the default Makefile and .meta files). + # All data files uploaded through the Coursemology UI will be extracted in this function. + # The remaining functions are to capture files manually added to the package ZIP by the user. + def extract_supporting_main_files + main_files = @package.main_files.compact.to_h + main_filenames = main_files.keys + + main_filenames.each do |filename| + next if ['Makefile', '.meta', 'report-public.xml', 'report-private.xml', + 'report-evaluation.xml'].include?(filename.to_s) + + extract_supporting_file(filename, main_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the test files folder + # (excluding the default append.ts and prepend.ts files). + def extract_supporting_tests_files + test_files = @package.test_files + test_filenames = test_files.keys + + test_filenames.each do |filename| + next if ['append.ts', 'prepend.ts'].include?(filename.to_s) + + extract_supporting_file(filename, test_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the submission files folder + # (excluding the default template.ts file). + def extract_supporting_submission_files + submission_files = @package.submission_files + submission_filenames = submission_files.keys + + submission_filenames.each do |filename| + next if ['template.ts'].include?(filename.to_s) + + extract_supporting_file(filename, submission_files[filename]) + end + end + + # Finds and extracts all contents of additional files in the solution files folder + # (excluding the default template.ts file). + def extract_supporting_solution_files + solution_files = @package.solution_files + solution_filenames = solution_files.keys + + solution_filenames.each do |filename| + next if ['template.ts'].include?(filename.to_s) + + extract_supporting_file(filename, solution_files[filename]) + 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 + test_cases_with_id = preload_question_test_cases + @package.test_reports.each do |test_type, test_report| + Course::Assessment::ProgrammingTestCaseReport.new(test_report).test_cases.each do |test_case| + test_case_object = default_codaveri_io_test_case_template + + # combine all extracted data + test_case_object[:index] = test_cases_with_id[test_case.name] + test_case_object[:timeout] = @question.time_limit * 1000 if @question.time_limit # in millisecond + test_case_object[:input] = test_case.expression + test_case_object[:output] = test_case.expected + test_case_object[:hint] = test_case.hint + test_case_object[:display] = test_case.display + test_case_object[:visibility] = codaveri_test_case_visibility(test_type) + @test_case_files.append(test_case_object) + end + end + end + + # Extracts template file from submissions folder and append it to the + # [:resources][0][:templates] array for the problem management API request body. + def extract_template + main_template_object = default_codaveri_template_template + + submission_files = @package.submission_files + test_files = @package.test_files + + main_template_object[:path] = 'template.ts' + main_template_object[:content] = submission_files[Pathname.new('template.ts')] + + main_template_object[:prefix] = test_files[Pathname.new('prepend.ts')] + main_template_object[:suffix] = test_files[Pathname.new('append.ts')] + + @template_files.append(main_template_object) + end + + def preload_question_test_cases + # The regex below finds all text after the last slash + # (eg AutoGrader/AutoGrader/test_private_4 -> test_private_4) + @question.test_cases.pluck(:identifier, :id).to_h { |x| [x[0].match(/[^\/]+$/).to_s, x[1]] } + end + + def codaveri_test_case_visibility(test_case_type) + case test_case_type + when :public + 'public' + when :private + 'private' + when :evaluation + 'hidden' + else + test_case_type + end + end +end diff --git a/app/views/course/assessment/question/programming/metadata/_csharp.json.jbuilder b/app/views/course/assessment/question/programming/metadata/_csharp.json.jbuilder new file mode 100644 index 00000000000..a5f1e190fc4 --- /dev/null +++ b/app/views/course/assessment/question/programming/metadata/_csharp.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.partial! 'course/assessment/question/programming/metadata/default', data: data diff --git a/app/views/course/assessment/question/programming/metadata/_golang.json.jbuilder b/app/views/course/assessment/question/programming/metadata/_golang.json.jbuilder new file mode 100644 index 00000000000..a5f1e190fc4 --- /dev/null +++ b/app/views/course/assessment/question/programming/metadata/_golang.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.partial! 'course/assessment/question/programming/metadata/default', data: data diff --git a/app/views/course/assessment/question/programming/metadata/_javascript.json.jbuilder b/app/views/course/assessment/question/programming/metadata/_javascript.json.jbuilder index 2558dc020f4..a5f1e190fc4 100644 --- a/app/views/course/assessment/question/programming/metadata/_javascript.json.jbuilder +++ b/app/views/course/assessment/question/programming/metadata/_javascript.json.jbuilder @@ -1,2 +1,2 @@ # frozen_string_literal: true -# JavaScript meta goes here if implemented +json.partial! 'course/assessment/question/programming/metadata/default', data: data diff --git a/app/views/course/assessment/question/programming/metadata/_rust.json.jbuilder b/app/views/course/assessment/question/programming/metadata/_rust.json.jbuilder new file mode 100644 index 00000000000..a5f1e190fc4 --- /dev/null +++ b/app/views/course/assessment/question/programming/metadata/_rust.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.partial! 'course/assessment/question/programming/metadata/default', data: data diff --git a/app/views/course/assessment/question/programming/metadata/_typescript.json.jbuilder b/app/views/course/assessment/question/programming/metadata/_typescript.json.jbuilder new file mode 100644 index 00000000000..a5f1e190fc4 --- /dev/null +++ b/app/views/course/assessment/question/programming/metadata/_typescript.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.partial! 'course/assessment/question/programming/metadata/default', data: data diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateQuestionPrototypeForm.tsx b/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateQuestionPrototypeForm.tsx index 6b7112224f4..373a775acb9 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateQuestionPrototypeForm.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/GenerateQuestionPrototypeForm.tsx @@ -16,6 +16,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../translations'; +import { CODAVERI_EVALUATOR_ONLY_LANGUAGES } from './constants'; import LockableSection from './LockableSection'; import TestCasesManager from './TestCasesManager'; import { LockStates, QuestionPrototypeFormData } from './types'; @@ -36,6 +37,10 @@ const TestCaseComponentMapper: Record< c_cpp: ReorderableTestCase, javascript: ReorderableTestCase, r: ReorderableTestCase, + csharp: ReorderableTestCase, + golang: ReorderableTestCase, + rust: ReorderableTestCase, + typescript: ReorderableTestCase, }; const GenerateQuestionPrototypeForm: FC = (props) => { @@ -48,7 +53,9 @@ const GenerateQuestionPrototypeForm: FC = (props) => { if (title) dispatch(actions.setActiveFormTitle({ title })); }, }); - const isIOTestCaseLanguage = editorMode === 'r'; + // New languages supported by Codaveri only allow IO test cases. + const isIOTestCaseLanguage = + CODAVERI_EVALUATOR_ONLY_LANGUAGES.includes(editorMode); const TestCaseComponent = TestCaseComponentMapper[editorMode]; const lhsHeader = isIOTestCaseLanguage diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/constants.ts b/client/app/bundles/course/assessment/pages/AssessmentGenerate/constants.ts index 137edb434b2..18b5bec8235 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/constants.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/constants.ts @@ -1,3 +1,5 @@ +import { LanguageMode } from 'types/course/assessment/question/programming'; + import { CodaveriGenerateFormData, QuestionPrototypeFormData } from './types'; export const defaultQuestionFormData: QuestionPrototypeFormData = { @@ -25,3 +27,12 @@ export const defaultCodaveriFormData: CodaveriGenerateFormData = { customPrompt: '', difficulty: 'easy', }; + +export const CODAVERI_EVALUATOR_ONLY_LANGUAGES: LanguageMode[] = [ + 'r', + 'javascript', + 'csharp', + 'golang', + 'rust', + 'typescript', +]; diff --git a/client/app/bundles/course/assessment/pages/AssessmentGenerate/utils.ts b/client/app/bundles/course/assessment/pages/AssessmentGenerate/utils.ts index 380c267d783..5a51568c0bb 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentGenerate/utils.ts +++ b/client/app/bundles/course/assessment/pages/AssessmentGenerate/utils.ts @@ -12,7 +12,10 @@ import { TestcaseVisibility, } from 'types/course/assessment/question-generation'; -import { defaultQuestionFormData } from './constants'; +import { + CODAVERI_EVALUATOR_ONLY_LANGUAGES, + defaultQuestionFormData, +} from './constants'; import { CodaveriGenerateFormData, QuestionPrototypeFormData } from './types'; function buildFromExpressionTestCase( @@ -210,7 +213,7 @@ export const buildQuestionDataFromPrototype = ( languageId: LanguageData['id'], languageMode: LanguageMode, ): ProgrammingFormRequestData => { - const isCodaveri = languageMode === 'r'; + const isCodaveri = CODAVERI_EVALUATOR_ONLY_LANGUAGES.includes(languageMode); const metadata: BasicMetadata = { solution: prefilledData?.testUi?.metadata?.solution, submission: prefilledData?.testUi?.metadata?.submission, diff --git a/client/app/bundles/course/assessment/question/programming/commons/builder.ts b/client/app/bundles/course/assessment/question/programming/commons/builder.ts index c7613d70dcc..2d9954dd077 100644 --- a/client/app/bundles/course/assessment/question/programming/commons/builder.ts +++ b/client/app/bundles/course/assessment/question/programming/commons/builder.ts @@ -146,6 +146,11 @@ const POLYGLOT_BUILDER: Partial< c_cpp: basicBuilder, java: javaBuilder, r: basicBuilder, + javascript: basicBuilder, + csharp: basicBuilder, + golang: basicBuilder, + rust: basicBuilder, + typescript: basicBuilder, }; const appendSkillIdsInto = (data: FormData, skillIds: number[]): void => diff --git a/client/app/bundles/course/assessment/question/programming/commons/validation.ts b/client/app/bundles/course/assessment/question/programming/commons/validation.ts index 845335531a0..ad53208b028 100644 --- a/client/app/bundles/course/assessment/question/programming/commons/validation.ts +++ b/client/app/bundles/course/assessment/question/programming/commons/validation.ts @@ -79,6 +79,11 @@ const POLYGLOT_SCHEMA: Partial< c_cpp: basicMetadataSchema, r: basicMetadataSchema, java: javaMetadataSchema, + javascript: basicMetadataSchema, + csharp: basicMetadataSchema, + golang: basicMetadataSchema, + rust: basicMetadataSchema, + typescript: basicMetadataSchema, }; const schema: Translated = (t) => diff --git a/client/app/bundles/course/assessment/question/programming/components/package/CsharpPackageEditor.tsx b/client/app/bundles/course/assessment/question/programming/components/package/CsharpPackageEditor.tsx new file mode 100644 index 00000000000..e9d9b6a7c4d --- /dev/null +++ b/client/app/bundles/course/assessment/question/programming/components/package/CsharpPackageEditor.tsx @@ -0,0 +1,91 @@ +import { Link, Typography } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../../translations'; +import ControlledEditor from '../common/ControlledEditor'; +import DataFilesManager from '../common/DataFilesManager'; +import ReorderableTestCasesManager from '../ReorderableTestCasesManager'; + +import PackageEditor, { PackageEditorProps } from './PackageEditor'; + +const PREPEND_DIV_ID = 'code-inserts-prepend'; +const APPEND_DIV_ID = 'code-inserts-append'; + +const TestCasesHint = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + + {t(translations.standardInputOutputTestCasesHint, { + language: 'C#', + prepend: (chunk) => {chunk}, + append: (chunk) => {chunk}, + })} + + ); +}; + +const CsharpPackageEditor = (props: PackageEditorProps): JSX.Element => { + const { t } = useTranslation(); + + return ( + <> + + + + + + +
+ +
+
+ +
+
+ + + + + + }> + + + + ); +}; + +export default CsharpPackageEditor; diff --git a/client/app/bundles/course/assessment/question/programming/components/package/GoPackageEditor.tsx b/client/app/bundles/course/assessment/question/programming/components/package/GoPackageEditor.tsx new file mode 100644 index 00000000000..2c4e11edbb5 --- /dev/null +++ b/client/app/bundles/course/assessment/question/programming/components/package/GoPackageEditor.tsx @@ -0,0 +1,93 @@ +import { Link, Typography } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../../translations'; +import ControlledEditor from '../common/ControlledEditor'; +import DataFilesManager from '../common/DataFilesManager'; +import ReorderableTestCasesManager from '../ReorderableTestCasesManager'; + +import PackageEditor, { PackageEditorProps } from './PackageEditor'; + +const PREPEND_DIV_ID = 'code-inserts-prepend'; +const APPEND_DIV_ID = 'code-inserts-append'; + +const TestCasesHint = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + + {t(translations.standardInputOutputTestCasesHint, { + language: 'Go', + prepend: (chunk) => {chunk}, + append: (chunk) => {chunk}, + })} + + ); +}; + +const GoPackageEditor = (props: PackageEditorProps): JSX.Element => { + const { t } = useTranslation(); + + return ( + <> + + + + + + +
+ +
+
+ +
+
+ + + + + + }> + + + + ); +}; + +export default GoPackageEditor; diff --git a/client/app/bundles/course/assessment/question/programming/components/package/JavascriptPackageEditor.tsx b/client/app/bundles/course/assessment/question/programming/components/package/JavascriptPackageEditor.tsx new file mode 100644 index 00000000000..22c93a8fc6d --- /dev/null +++ b/client/app/bundles/course/assessment/question/programming/components/package/JavascriptPackageEditor.tsx @@ -0,0 +1,86 @@ +import { Link, Typography } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../../translations'; +import ControlledEditor from '../common/ControlledEditor'; +import DataFilesManager from '../common/DataFilesManager'; +import ReorderableTestCasesManager from '../ReorderableTestCasesManager'; + +import PackageEditor, { PackageEditorProps } from './PackageEditor'; + +const PREPEND_DIV_ID = 'code-inserts-prepend'; +const APPEND_DIV_ID = 'code-inserts-append'; + +const TestCasesHint = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + + {t(translations.standardInputOutputTestCasesHint, { + language: 'Node.js', + prepend: (chunk) => {chunk}, + append: (chunk) => {chunk}, + })} + + ); +}; + +const JavascriptPackageEditor = (props: PackageEditorProps): JSX.Element => { + const { t } = useTranslation(); + + return ( + <> + + + + + + +
+ +
+
+ +
+
+ + + + + + }> + + + + ); +}; + +export default JavascriptPackageEditor; diff --git a/client/app/bundles/course/assessment/question/programming/components/package/PolyglotEditor.tsx b/client/app/bundles/course/assessment/question/programming/components/package/PolyglotEditor.tsx index 98968ebf60a..6f1e7a53e53 100644 --- a/client/app/bundles/course/assessment/question/programming/components/package/PolyglotEditor.tsx +++ b/client/app/bundles/course/assessment/question/programming/components/package/PolyglotEditor.tsx @@ -12,16 +12,26 @@ import translations from '../../../../translations'; import ControlledEditor from '../common/ControlledEditor'; import CppPackageEditor from './CppPackageEditor'; +import CsharpPackageEditor from './CsharpPackageEditor'; +import GoPackageEditor from './GoPackageEditor'; import JavaPackageEditor from './JavaPackageEditor'; +import JavascriptPackageEditor from './JavascriptPackageEditor'; import PackageDetails from './PackageDetails'; import PythonPackageEditor from './PythonPackageEditor'; import RPackageEditor from './RPackageEditor'; +import RustPackageEditor from './RustPackageEditor'; +import TypescriptPackageEditor from './TypescriptPackageEditor'; const EDITORS: Partial> = { python: PythonPackageEditor, java: JavaPackageEditor, c_cpp: CppPackageEditor, r: RPackageEditor, + javascript: JavascriptPackageEditor, + csharp: CsharpPackageEditor, + golang: GoPackageEditor, + rust: RustPackageEditor, + typescript: TypescriptPackageEditor, }; interface PolyglotEditorProps { diff --git a/client/app/bundles/course/assessment/question/programming/components/package/RPackageEditor.tsx b/client/app/bundles/course/assessment/question/programming/components/package/RPackageEditor.tsx index 1f4f1f77539..6cab82132d4 100644 --- a/client/app/bundles/course/assessment/question/programming/components/package/RPackageEditor.tsx +++ b/client/app/bundles/course/assessment/question/programming/components/package/RPackageEditor.tsx @@ -17,7 +17,8 @@ const TestCasesHint = (): JSX.Element => { return ( - {t(translations.rTestCasesHint, { + {t(translations.standardInputOutputTestCasesHint, { + language: 'R', prepend: (chunk) => {chunk}, append: (chunk) => {chunk}, })} diff --git a/client/app/bundles/course/assessment/question/programming/components/package/RustPackageEditor.tsx b/client/app/bundles/course/assessment/question/programming/components/package/RustPackageEditor.tsx new file mode 100644 index 00000000000..a7d293c11a5 --- /dev/null +++ b/client/app/bundles/course/assessment/question/programming/components/package/RustPackageEditor.tsx @@ -0,0 +1,83 @@ +import { Link, Typography } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../../translations'; +import ControlledEditor from '../common/ControlledEditor'; +import DataFilesManager from '../common/DataFilesManager'; +import ReorderableTestCasesManager from '../ReorderableTestCasesManager'; + +import PackageEditor, { PackageEditorProps } from './PackageEditor'; + +const PREPEND_DIV_ID = 'code-inserts-prepend'; +const APPEND_DIV_ID = 'code-inserts-append'; + +const TestCasesHint = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + + {t(translations.standardInputOutputTestCasesHint, { + language: 'Rust', + prepend: (chunk) => {chunk}, + append: (chunk) => {chunk}, + })} + + ); +}; + +const RustPackageEditor = (props: PackageEditorProps): JSX.Element => { + const { t } = useTranslation(); + + return ( + <> + + + + + + +
+ +
+
+ +
+
+ + + + + + }> + + + + ); +}; + +export default RustPackageEditor; diff --git a/client/app/bundles/course/assessment/question/programming/components/package/TypescriptPackageEditor.tsx b/client/app/bundles/course/assessment/question/programming/components/package/TypescriptPackageEditor.tsx new file mode 100644 index 00000000000..cb25dd85f46 --- /dev/null +++ b/client/app/bundles/course/assessment/question/programming/components/package/TypescriptPackageEditor.tsx @@ -0,0 +1,86 @@ +import { Link, Typography } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../../translations'; +import ControlledEditor from '../common/ControlledEditor'; +import DataFilesManager from '../common/DataFilesManager'; +import ReorderableTestCasesManager from '../ReorderableTestCasesManager'; + +import PackageEditor, { PackageEditorProps } from './PackageEditor'; + +const PREPEND_DIV_ID = 'code-inserts-prepend'; +const APPEND_DIV_ID = 'code-inserts-append'; + +const TestCasesHint = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + + {t(translations.standardInputOutputTestCasesHint, { + language: 'Node.js with TypeScript', + prepend: (chunk) => {chunk}, + append: (chunk) => {chunk}, + })} + + ); +}; + +const TypescriptPackageEditor = (props: PackageEditorProps): JSX.Element => { + const { t } = useTranslation(); + + return ( + <> + + + + + + +
+ +
+
+ +
+
+ + + + + + }> + + + + ); +}; + +export default TypescriptPackageEditor; diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 9aa4dfbd4a5..a34cfa766ab 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -1571,13 +1571,13 @@ const translations = defineMessages({ 'against the Expected expectations using the equality operator (==). Notably, print() ' + 'returns None, so printed outputs should not be confused with actual return values.', }, - rTestCasesHint: { - id: 'course.assessment.question.programming.rTestCasesHint', + standardInputOutputTestCasesHint: { + id: 'course.assessment.question.programming.standardInputOutputTestCasesHint', defaultMessage: - 'Each test case launches a separate R console instance and provides input via standard input. This console will ' + - 'run the Prepend script, the student submission, and the Append script. ' + - 'The standard output of these scripts will be compared (as a string) to the expected output of the test case. We recommend ' + - 'processing the standard input in one of these scripts.', + 'Each test case launches a separate {language} console environment and provides input via standard input. ' + + 'The environment will combine the Prepend, student submission, and Append scripts into a single program and run it. ' + + 'The standard output of the program will be compared (as a string) to the expected output of the test case. ' + + 'We recommend handling input parsing and function calls in one of these scripts.', }, inlineCode: { id: 'course.assessment.question.programming.inlineCode', diff --git a/client/app/lib/components/core/fields/EditorField.tsx b/client/app/lib/components/core/fields/EditorField.tsx index 39c7fd3809b..032befbc9ca 100644 --- a/client/app/lib/components/core/fields/EditorField.tsx +++ b/client/app/lib/components/core/fields/EditorField.tsx @@ -54,7 +54,7 @@ const useLazyMode = (language: LanguageMode): boolean => { (async (): Promise => { await import( - /* webpackInclude: /ace-builds\/src-noconflict\/mode-(c_cpp|python|r|java|javascript)\./ */ + /* webpackInclude: /ace-builds\/src-noconflict\/mode-(c_cpp|python|r|java|javascript|csharp|golang|rust|typescript)\./ */ /* webpackChunkName: "ace-[request]" */ `ace-builds/src-noconflict/mode-${language}` ); diff --git a/client/app/types/course/assessment/question/programming.ts b/client/app/types/course/assessment/question/programming.ts index d177ce5834b..52e3ac8974e 100644 --- a/client/app/types/course/assessment/question/programming.ts +++ b/client/app/types/course/assessment/question/programming.ts @@ -1,6 +1,15 @@ import { AvailableSkills, QuestionFormData } from '../questions'; -export type LanguageMode = 'c_cpp' | 'java' | 'javascript' | 'python' | 'r'; +export type LanguageMode = + | 'c_cpp' + | 'java' + | 'javascript' + | 'python' + | 'r' + | 'csharp' + | 'golang' + | 'rust' + | 'typescript'; export interface LanguageDependencyData { name: string; diff --git a/lib/autoload/course/assessment/programming_test_case_report_builder.rb b/lib/autoload/course/assessment/programming_test_case_report_builder.rb index 237383a6e26..6538d5c09c7 100644 --- a/lib/autoload/course/assessment/programming_test_case_report_builder.rb +++ b/lib/autoload/course/assessment/programming_test_case_report_builder.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true # rubocop:disable Metrics/AbcSize class Course::Assessment::ProgrammingTestCaseReportBuilder - def self.build_dummy_report(test_type, test_cases) + def self.build_dummy_report(test_type, test_cases, file_type) builder = Nokogiri::XML::Builder.new do |xml| xml.testsuites do xml.testsuite( name: "#{test_type.capitalize}TestsGrader", tests: test_cases.count.to_s, - file: '.R', + file: file_type, time: '0.01', timestamp: Time.now.iso8601, failures: 0.to_s, @@ -19,7 +19,7 @@ def self.build_dummy_report(test_type, test_cases) name: "test_#{test_type}_#{format('%02i', index: index)}", time: '0.00001', timestamp: Time.now.iso8601, - file: 'answer.R', + file: "answer#{file_type}", line: '1' ) do xml.meta(expression: test_case[:expression], expected: test_case[:expected], hint: test_case[:hint]) diff --git a/lib/tasks/db/set_polyglot_language_flags.rake b/lib/tasks/db/set_polyglot_language_flags.rake index 1b02af6a915..7782947b4d5 100644 --- a/lib/tasks/db/set_polyglot_language_flags.rake +++ b/lib/tasks/db/set_polyglot_language_flags.rake @@ -16,7 +16,12 @@ namespace :db do Coursemology::Polyglot::Language::Python::Python3Point13, Coursemology::Polyglot::Language::Java::Java17, Coursemology::Polyglot::Language::Java::Java21, - Coursemology::Polyglot::Language::R::R4Point1 + Coursemology::Polyglot::Language::R::R4Point1, + Coursemology::Polyglot::Language::JavaScript::JavaScript22, + Coursemology::Polyglot::Language::CSharp::CSharp5Point0, + Coursemology::Polyglot::Language::Go::Go1Point16, + Coursemology::Polyglot::Language::Rust::Rust1Point68, + Coursemology::Polyglot::Language::TypeScript::TypeScript5Point8 ].freeze QUESTION_GENERATION_WHITELIST = @@ -31,7 +36,12 @@ namespace :db do Coursemology::Polyglot::Language::Python::Python3Point13, Coursemology::Polyglot::Language::Java::Java17, Coursemology::Polyglot::Language::Java::Java21, - Coursemology::Polyglot::Language::R::R4Point1 + Coursemology::Polyglot::Language::R::R4Point1, + Coursemology::Polyglot::Language::JavaScript::JavaScript22, + Coursemology::Polyglot::Language::CSharp::CSharp5Point0, + Coursemology::Polyglot::Language::Go::Go1Point16, + Coursemology::Polyglot::Language::Rust::Rust1Point68, + Coursemology::Polyglot::Language::TypeScript::TypeScript5Point8 ].freeze KODITSU_WHITELIST = @@ -60,7 +70,12 @@ namespace :db do [ Coursemology::Polyglot::Language::JavaScript, Coursemology::Polyglot::Language::CPlusPlus, - Coursemology::Polyglot::Language::R::R4Point1 + Coursemology::Polyglot::Language::R::R4Point1, + Coursemology::Polyglot::Language::JavaScript::JavaScript22, + Coursemology::Polyglot::Language::CSharp::CSharp5Point0, + Coursemology::Polyglot::Language::Go::Go1Point16, + Coursemology::Polyglot::Language::Rust::Rust1Point68, + Coursemology::Polyglot::Language::TypeScript::TypeScript5Point8 ].freeze task set_polyglot_language_flags: :environment do diff --git a/lib/tasks/db/set_polyglot_language_weights.rake b/lib/tasks/db/set_polyglot_language_weights.rake index fe4a2dbf701..c509eed938a 100644 --- a/lib/tasks/db/set_polyglot_language_weights.rake +++ b/lib/tasks/db/set_polyglot_language_weights.rake @@ -4,7 +4,7 @@ namespace :db do # changing the order in which languages are displayed in drop-down menus. def comparable_polyglot_version(language) - language&.polyglot_version&.split('.')&.map(&:to_i) + language.polyglot_version&.split('.')&.map(&:to_i) || [] end def version_compare(lang1, lang2) @@ -17,7 +17,7 @@ namespace :db do @latest_hash.key?(language.name) end - def language_compare(lang1, lang2) # rubocop:disable Metrics/CyclomaticComplexity + def language_compare(lang1, lang2) # Put more recent versions first return -version_compare(lang1, lang2) if lang1.polyglot_name == lang2.polyglot_name