diff --git a/app/services/processors/job_processor.rb b/app/services/processors/job_processor.rb new file mode 100644 index 0000000..286b0c9 --- /dev/null +++ b/app/services/processors/job_processor.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Processors + ## + # JobProcessor is responsible for processing job data from the transformed deck data. + # It finds a Job record by the master’s id and assigns it (and its job skills) to the Party. + # + # @example + # raw_data = { 'job' => { "master": { "id": '130401', ... }, ... }, 'set_action': [ ... ] } + # processor = Processors::JobProcessor.new(party, raw_data, language: 'en') + # processor.process + class JobProcessor < BaseProcessor + ## + # Initializes a new JobProcessor. + # + # @param party [Party] the Party record. + # @param data [Hash] the raw JSON data. + # @param options [Hash] options hash; e.g. expects :language. + def initialize(party, data, options = {}) + super(party, options) + @party = party + @data = data + @language = options[:language] || 'en' + end + + ## + # Processes job data. + # + # Finds a Job record using a case‐insensitive search on +name_en+ or +name_jp+. + # If found, it assigns the job to the party and (if provided) assigns subskills. + # + # @return [void] + def process + if @data.is_a?(Hash) + @data = @data.with_indifferent_access + else + Rails.logger.error "[JOB] Invalid data format: expected a Hash, got #{@data.class}" + return + end + + unless @data.key?('deck') && @data['deck'].key?('pc') && @data['deck']['pc'].key?('job') + Rails.logger.error '[JOB] Missing job data in deck JSON' + return + end + + # Extract job data + job_data = @data.dig('deck', 'pc', 'job', 'master') + job_skills = @data.dig('deck', 'pc', 'set_action') + job_accessory_id = @data.dig('deck', 'pc', 'familiar_id') || @data.dig('deck', 'pc', 'shield_id') + + # Look up and set the Job and its main skill + process_core_job(job_data) + + # Look up and set the job skills. + if job_skills.present? + skills = process_job_skills(job_skills) + party.update(skill1: skills[0], skill2: skills[1], skill3: skills[2]) + end + + # Look up and set the job accessory. + accessory = process_job_accessory(job_accessory_id) + party.update(accessory: accessory) + rescue StandardError => e + Rails.logger.error "[JOB] Exception during job processing: #{e.message}" + raise e + end + + private + + ## + # Updates the party with the corresponding job and its main skill. + # + # This method attempts to locate a Job using the provided job_data's 'id' (which represents + # the granblue_id). If the job is found, it retrieves the job's main + # skill (i.e. the JobSkill record where `main` is true) and updates the party with the job + # and its main skill. If no job is found, the method returns without updating. + # + # @param [Hash] job_data A hash containing job information. + # It must include the key 'id', which holds the granblue_id for the job. + # @return [void] + # + # @example + # job_data = { 'id' => 42 } + # process_core_job(job_data) + def process_core_job(job_data) + # Look up the Job by granblue_id (the job master id). + job = Job.find_by(granblue_id: job_data['id']) + return unless job + + main_skill = JobSkill.find_by(job_id: job.id, main: true) + + party.update(job: job, skill0: main_skill) + end + + ## + # Processes and associates job skills with a given job. + # + # This method first removes any existing skills from the job. It then iterates over the provided + # array of skill names, attempting to find a matching JobSkill record by comparing the provided + # name against both the English and Japanese name fields. Any found JobSkill records are then + # associated with the job. Finally, the method logs the processed job skill names. + # + # @param job_skills [Array] an array of job skill names. + # @return [Array] an array of JobSkill records that were associated with the job. + def process_job_skills(job_skills) + job_skills.map do |skill| + name = skill['name'] + JobSkill.find_by(name_en: name) + end + end + + ## + # Processes raw data to find the currently set job accessory + # + # Searches JobAccessories for the given `granblue_id` + # + # @param accessory_id [String] the granblue_id of the accessory + def process_job_accessory(accessory_id) + JobAccessory.find_by(granblue_id: accessory_id) + end + + # Converts a value (string or boolean) to a boolean. + def to_boolean(val) + val.to_s.downcase == 'true' + end + end +end diff --git a/spec/services/processors/job_processor_spec.rb b/spec/services/processors/job_processor_spec.rb new file mode 100644 index 0000000..eb232b2 --- /dev/null +++ b/spec/services/processors/job_processor_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Processors::JobProcessor, type: :model do + let(:party) { create(:party) } + # Use a job that has associated job skills. + # In our seed/canonical data this job (by its ID) has several associated skills. + let!(:job_record) { Job.find_by!(granblue_id: '130401') } + + # Build the raw data hash that mimics the transformed structure. + # The master section includes the job's basic information. + # The param section includes level data and the subskills derived from the job's associated job skills. + let(:deck_data) do + file_path = Rails.root.join('spec', 'fixtures', 'deck_sample2.json') + JSON.parse(File.read(file_path)) + end + + subject { described_class.new(party, deck_data, language: 'en') } + + context 'with valid job data' do + it 'assigns the job to the party' do + # Before processing, the party should not have a job. + expect(party.job).to be_nil + + # Process the job data. + subject.process + party.reload + + # The party's job should now be set to the job_record. + expect(party.job).to eq(job_record) + end + + it 'assigns the correct main skill to the party' do + # Before processing, the party should not have a job. + expect(party.job).to be_nil + + # Process the job data. + subject.process + party.reload + + main_skill = party.job.skills.where(main: true).first + expect(party.skill0.id).to eq(main_skill.id) + end + + it 'associates the correct job skills' do + # Before processing, the party should not have a job. + expect(party.job).to be_nil + + # Process the job data. + subject.process + party.reload + + # We assume that the processor assigns up to four subskills to party attributes, + # for example, party.skill0, party.skill1, etc. + # Get the expected subskills (using order and taking the first four). + data = deck_data.with_indifferent_access + expected_subskills = data.dig('deck', 'pc', 'set_action').pluck(:name) + actual_subskills = [party.skill1.name_en, party.skill2.name_en, party.skill3.name_en] + expect(actual_subskills).to eq(expected_subskills) + end + + it 'assigns the correct accessory to the party' do + # Process the job data. + subject.process + party.reload + + expect(party.accessory.granblue_id).to eq(1.to_s) + end + end + + context 'with invalid job data' do + let(:invalid_data) { 'invalid data' } + subject { described_class.new(party, invalid_data, language: 'en') } + + it 'logs an error and does not assign a job' do + expect { subject.process }.not_to(change { party.reload.job }) + end + end + + after(:each) do |example| + if example.exception + puts "\nDEBUG [JobProcessor]: #{example.full_description} failed with error: #{example.exception.message}" + end + end +end