From 272f61235752cb02d51c37a348ffb1a886ea5451 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sat, 13 Dec 2025 20:54:38 -0800 Subject: [PATCH] add import services for characters, weapons, summons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parses game JSON inventory data and creates collection records 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/services/character_import_service.rb | 169 +++++ app/services/summon_import_service.rb | 146 ++++ app/services/weapon_import_service.rb | 266 +++++++ .../services/character_import_service_spec.rb | 528 ++++++++++++++ spec/services/summon_import_service_spec.rb | 499 +++++++++++++ spec/services/weapon_import_service_spec.rb | 671 ++++++++++++++++++ 6 files changed, 2279 insertions(+) create mode 100644 app/services/character_import_service.rb create mode 100644 app/services/summon_import_service.rb create mode 100644 app/services/weapon_import_service.rb create mode 100644 spec/services/character_import_service_spec.rb create mode 100644 spec/services/summon_import_service_spec.rb create mode 100644 spec/services/weapon_import_service_spec.rb diff --git a/app/services/character_import_service.rb b/app/services/character_import_service.rb new file mode 100644 index 0000000..361e656 --- /dev/null +++ b/app/services/character_import_service.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +## +# Service for importing characters from game JSON data. +# Parses the game's character inventory data and creates CollectionCharacter records. +# +# Note: Unlike weapons and summons, characters are unique per user - each character +# can only be in a user's collection once. +# +# @example Import characters for a user +# service = CharacterImportService.new(user, game_data) +# result = service.import +# if result.success? +# puts "Imported #{result.created.size} characters" +# end +# +class CharacterImportService + Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) + + def initialize(user, game_data, options = {}) + @user = user + @game_data = game_data + @update_existing = options[:update_existing] || false + @created = [] + @updated = [] + @skipped = [] + @errors = [] + @default_awakening = nil + end + + ## + # Imports characters from game data. + # + # @return [Result] Import result with counts and errors + def import + items = extract_items + return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No character items found in data']) if items.empty? + + ActiveRecord::Base.transaction do + items.each_with_index do |item, index| + import_item(item, index) + rescue StandardError => e + @errors << { index: index, game_id: item.dig('param', 'id'), error: e.message } + end + end + + Result.new( + success?: @errors.empty?, + created: @created, + updated: @updated, + skipped: @skipped, + errors: @errors + ) + end + + private + + def extract_items + return @game_data if @game_data.is_a?(Array) + return @game_data['list'] if @game_data.is_a?(Hash) && @game_data['list'].is_a?(Array) + + [] + end + + def import_item(item, _index) + param = item['param'] || {} + master = item['master'] || {} + + # The character's granblue_id is in master.id + granblue_id = master['id'] + game_id = param['id'] + + character = find_character(granblue_id) + unless character + @errors << { game_id: game_id, granblue_id: granblue_id, error: 'Character not found' } + return + end + + # Characters are unique per user - check by character_id, not game_id + existing = @user.collection_characters.find_by(character_id: character.id) + + if existing + if @update_existing + update_existing_character(existing, item, character) + else + @skipped << { game_id: game_id, character_id: character.id, reason: 'Already exists' } + end + return + end + + create_collection_character(item, character) + end + + def find_character(granblue_id) + Character.find_by(granblue_id: granblue_id.to_s) + end + + def create_collection_character(item, character) + attrs = build_collection_character_attrs(item, character) + + collection_character = @user.collection_characters.build(attrs) + + if collection_character.save + @created << collection_character + else + @errors << { + game_id: item.dig('param', 'id'), + granblue_id: character.granblue_id, + error: collection_character.errors.full_messages.join(', ') + } + end + end + + def update_existing_character(existing, item, character) + attrs = build_collection_character_attrs(item, character) + + if existing.update(attrs) + @updated << existing + else + @errors << { + game_id: item.dig('param', 'id'), + granblue_id: character.granblue_id, + error: existing.errors.full_messages.join(', ') + } + end + end + + def build_collection_character_attrs(item, character) + param = item['param'] || {} + awakening_level = parse_awakening_level(param['arousal_level']) + + attrs = { + character: character, + uncap_level: parse_uncap_level(param['evolution']), + transcendence_step: parse_transcendence_step(param['phase']) + } + + # Only set awakening_level if > 1 (requires awakening to be set) + # The model's before_save callback will set default awakening + if awakening_level > 1 + attrs[:awakening] = default_awakening + attrs[:awakening_level] = awakening_level + end + + attrs + end + + def default_awakening + @default_awakening ||= Awakening.find_by(slug: 'character-balanced', object_type: 'Character') + end + + def parse_uncap_level(evolution) + value = evolution.to_i + # Evolution 6 = transcended, but uncap_level maxes at 5 + value.clamp(0, 5) + end + + def parse_transcendence_step(phase) + value = phase.to_i + value.clamp(0, 10) + end + + def parse_awakening_level(arousal_level) + value = arousal_level.to_i + # Default to 1 if not present or 0 + value = 1 if value < 1 + value.clamp(1, 10) + end +end diff --git a/app/services/summon_import_service.rb b/app/services/summon_import_service.rb new file mode 100644 index 0000000..1e4a83f --- /dev/null +++ b/app/services/summon_import_service.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +## +# Service for importing summons from game JSON data. +# Parses the game's summon inventory data and creates CollectionSummon records. +# +# @example Import summons for a user +# service = SummonImportService.new(user, game_data) +# result = service.import +# if result.success? +# puts "Imported #{result.created.size} summons" +# end +# +class SummonImportService + Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) + + def initialize(user, game_data, options = {}) + @user = user + @game_data = game_data + @update_existing = options[:update_existing] || false + @created = [] + @updated = [] + @skipped = [] + @errors = [] + end + + ## + # Imports summons from game data. + # + # @return [Result] Import result with counts and errors + def import + items = extract_items + return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No summon items found in data']) if items.empty? + + ActiveRecord::Base.transaction do + items.each_with_index do |item, index| + import_item(item, index) + rescue StandardError => e + @errors << { index: index, game_id: item.dig('param', 'id'), error: e.message } + end + end + + Result.new( + success?: @errors.empty?, + created: @created, + updated: @updated, + skipped: @skipped, + errors: @errors + ) + end + + private + + def extract_items + return @game_data if @game_data.is_a?(Array) + return @game_data['list'] if @game_data.is_a?(Hash) && @game_data['list'].is_a?(Array) + + [] + end + + def import_item(item, _index) + param = item['param'] || {} + master = item['master'] || {} + + # The summon's granblue_id can be in param.image_id or master.id + # image_id may have a suffix like "_04" for transcended summons, so strip it + image_id = param['image_id'].to_s.split('_').first if param['image_id'].present? + granblue_id = image_id || master['id'] + game_id = param['id'] + + summon = find_summon(granblue_id) + unless summon + @errors << { game_id: game_id, granblue_id: granblue_id, error: 'Summon not found' } + return + end + + # Check for existing collection summon with same game ID + existing = @user.collection_summons.find_by(game_id: game_id.to_s) + + if existing + if @update_existing + update_existing_summon(existing, item, summon) + else + @skipped << { game_id: game_id, reason: 'Already exists' } + end + return + end + + create_collection_summon(item, summon) + end + + def find_summon(granblue_id) + Summon.find_by(granblue_id: granblue_id.to_s) + end + + def create_collection_summon(item, summon) + attrs = build_collection_summon_attrs(item, summon) + + collection_summon = @user.collection_summons.build(attrs) + + if collection_summon.save + @created << collection_summon + else + @errors << { + game_id: item.dig('param', 'id'), + granblue_id: summon.granblue_id, + error: collection_summon.errors.full_messages.join(', ') + } + end + end + + def update_existing_summon(existing, item, summon) + attrs = build_collection_summon_attrs(item, summon) + + if existing.update(attrs) + @updated << existing + else + @errors << { + game_id: item.dig('param', 'id'), + granblue_id: summon.granblue_id, + error: existing.errors.full_messages.join(', ') + } + end + end + + def build_collection_summon_attrs(item, summon) + param = item['param'] || {} + + { + summon: summon, + game_id: param['id'].to_s, + uncap_level: parse_uncap_level(param['evolution']), + transcendence_step: parse_transcendence_step(param['phase']) + } + end + + def parse_uncap_level(evolution) + value = evolution.to_i + value.clamp(0, 5) + end + + def parse_transcendence_step(phase) + value = phase.to_i + value.clamp(0, 10) + end +end diff --git a/app/services/weapon_import_service.rb b/app/services/weapon_import_service.rb new file mode 100644 index 0000000..6923395 --- /dev/null +++ b/app/services/weapon_import_service.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +## +# Service for importing weapons from game JSON data. +# Parses the game's weapon inventory data and creates CollectionWeapon records. +# +# @example Import weapons for a user +# service = WeaponImportService.new(user, game_data) +# result = service.import +# if result.success? +# puts "Imported #{result.created.size} weapons" +# end +# +class WeaponImportService + Result = Struct.new(:success?, :created, :updated, :skipped, :errors, keyword_init: true) + + # Game awakening form to our slug mapping + AWAKENING_FORM_MAPPING = { + 1 => 'weapon-atk', # Attack + 2 => 'weapon-def', # Defense + 3 => 'weapon-special', # Special + 4 => 'weapon-ca', # C.A. + 5 => 'weapon-skill', # Skill DMG + 6 => 'weapon-heal' # Healing + }.freeze + + def initialize(user, game_data, options = {}) + @user = user + @game_data = game_data + @update_existing = options[:update_existing] || false + @created = [] + @updated = [] + @skipped = [] + @errors = [] + @awakening_cache = {} + end + + ## + # Imports weapons from game data. + # + # @return [Result] Import result with counts and errors + def import + items = extract_items + return Result.new(success?: false, created: [], updated: [], skipped: [], errors: ['No weapon items found in data']) if items.empty? + + ActiveRecord::Base.transaction do + items.each_with_index do |item, index| + import_item(item, index) + rescue StandardError => e + @errors << { index: index, game_id: item.dig('param', 'id'), error: e.message } + end + end + + Result.new( + success?: @errors.empty?, + created: @created, + updated: @updated, + skipped: @skipped, + errors: @errors + ) + end + + private + + def extract_items + return @game_data if @game_data.is_a?(Array) + return @game_data['list'] if @game_data.is_a?(Hash) && @game_data['list'].is_a?(Array) + + [] + end + + def import_item(item, _index) + param = item['param'] || {} + master = item['master'] || {} + + # The weapon's granblue_id can be in param.image_id or master.id + granblue_id = param['image_id'] || master['id'] + game_id = param['id'] + + weapon = find_weapon(granblue_id) + unless weapon + @errors << { game_id: game_id, granblue_id: granblue_id, error: 'Weapon not found' } + return + end + + # Check for existing collection weapon with same game ID + existing = @user.collection_weapons.find_by(game_id: game_id.to_s) + + if existing + if @update_existing + update_existing_weapon(existing, item, weapon) + else + @skipped << { game_id: game_id, reason: 'Already exists' } + end + return + end + + create_collection_weapon(item, weapon) + end + + def find_weapon(granblue_id) + Weapon.find_by(granblue_id: granblue_id.to_s) + end + + def create_collection_weapon(item, weapon) + attrs = build_collection_weapon_attrs(item, weapon) + + collection_weapon = @user.collection_weapons.build(attrs) + + if collection_weapon.save + @created << collection_weapon + else + @errors << { + game_id: item.dig('param', 'id'), + granblue_id: weapon.granblue_id, + error: collection_weapon.errors.full_messages.join(', ') + } + end + end + + def update_existing_weapon(existing, item, weapon) + attrs = build_collection_weapon_attrs(item, weapon) + + if existing.update(attrs) + @updated << existing + else + @errors << { + game_id: item.dig('param', 'id'), + granblue_id: weapon.granblue_id, + error: existing.errors.full_messages.join(', ') + } + end + end + + def build_collection_weapon_attrs(item, weapon) + param = item['param'] || {} + + attrs = { + weapon: weapon, + game_id: param['id'].to_s, + uncap_level: parse_uncap_level(param['evolution']), + transcendence_step: parse_transcendence_step(param['phase']) + } + + # Parse awakening if present + awakening_attrs = parse_awakening(param['arousal']) + attrs.merge!(awakening_attrs) if awakening_attrs + + # Parse AX skills if present + ax_attrs = parse_ax_skills(param['augment_skill_info']) + attrs.merge!(ax_attrs) if ax_attrs + + attrs + end + + def parse_uncap_level(evolution) + value = evolution.to_i + value.clamp(0, 5) + end + + def parse_transcendence_step(phase) + value = phase.to_i + value.clamp(0, 10) + end + + ## + # Parses awakening data from game format. + # Game arousal data contains level and form info. + # + # @param arousal [Hash] The game's arousal (awakening) data + # @return [Hash, nil] Awakening attributes or nil if no awakening + def parse_awakening(arousal) + return nil if arousal.blank? || arousal['is_arousal_weapon'] != true + return nil if arousal['level'].blank? + + form = arousal['form'].to_i + awakening = find_awakening_by_form(form) + return nil unless awakening + + { + awakening_id: awakening.id, + awakening_level: arousal['level'].to_i.clamp(1, 20) + } + end + + def find_awakening_by_form(form) + slug = AWAKENING_FORM_MAPPING[form] + return nil unless slug + + @awakening_cache[slug] ||= Awakening.find_by(slug: slug, object_type: 'Weapon') + end + + ## + # Parses AX skill data from game format. + # Game augment_skill_info is an array of skill arrays. + # + # @param augment_skill_info [Array] The game's AX skill data + # @return [Hash, nil] AX skill attributes or nil if no AX skills + def parse_ax_skills(augment_skill_info) + return nil if augment_skill_info.blank? || !augment_skill_info.is_a?(Array) + + # First entry in augment_skill_info is an array of skills + skills = augment_skill_info.first + return nil if skills.blank? || !skills.is_a?(Array) + + attrs = {} + + # First AX skill + if skills[0].is_a?(Hash) + ax1 = parse_single_ax_skill(skills[0]) + if ax1 + attrs[:ax_modifier1] = ax1[:modifier] + attrs[:ax_strength1] = ax1[:strength] + end + end + + # Second AX skill + if skills[1].is_a?(Hash) + ax2 = parse_single_ax_skill(skills[1]) + if ax2 + attrs[:ax_modifier2] = ax2[:modifier] + attrs[:ax_strength2] = ax2[:strength] + end + end + + attrs.empty? ? nil : attrs + end + + ## + # Parses a single AX skill from game data. + # + # @param skill [Hash] Single AX skill data with skill_id and effect_value + # @return [Hash, nil] { modifier:, strength: } or nil + def parse_single_ax_skill(skill) + return nil unless skill['skill_id'].present? + + # The skill_id maps to our AX modifier + modifier = skill['skill_id'].to_i + + # Parse strength from effect_value (may be "3" or "1_3" format) + # or from show_value (may be "3%" format) + strength = parse_ax_strength(skill['effect_value'], skill['show_value']) + + return nil unless strength + + { modifier: modifier, strength: strength } + end + + def parse_ax_strength(effect_value, show_value) + # Try effect_value first + if effect_value.present? + # Handle "1_3" format (seems to be "tier_value") + if effect_value.to_s.include?('_') + return effect_value.to_s.split('_').last.to_f + end + return effect_value.to_f if effect_value.to_s.match?(/\A[\d.]+\z/) + end + + # Try show_value (e.g., "3%") + if show_value.present? + return show_value.to_s.gsub('%', '').to_f + end + + nil + end +end diff --git a/spec/services/character_import_service_spec.rb b/spec/services/character_import_service_spec.rb new file mode 100644 index 0000000..576c382 --- /dev/null +++ b/spec/services/character_import_service_spec.rb @@ -0,0 +1,528 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CharacterImportService, type: :service do + let(:user) { create(:user) } + + # Create character awakening first (required by model's before_save callback) + let!(:awakening_balanced) do + Awakening.find_by(slug: 'character-balanced', object_type: 'Character') || + create(:awakening, :for_character, slug: 'character-balanced', name_en: 'Balanced') + end + + # Create characters with specific granblue_ids matching the game data + # Use unique IDs that won't conflict with seeded data + let(:standard_character) do + Character.find_by(granblue_id: '3040171000') || + create(:character, granblue_id: '3040171000', name_en: 'Hallessena') + end + + let(:flb_character) do + Character.find_by(granblue_id: '3040167000') || + create(:character, granblue_id: '3040167000', name_en: 'Zeta') + end + + let(:transcendable_character) do + Character.find_by(granblue_id: '3040036000') || + create(:character, :transcendable, granblue_id: '3040036000', name_en: 'Siegfried') + end + + let(:another_character) do + Character.find_by(granblue_id: '3040212000') || + create(:character, granblue_id: '3040212000', name_en: 'Narmaya (Summer)') + end + + before do + standard_character + flb_character + transcendable_character + another_character + end + + describe '#import' do + context 'with valid game data' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { + 'id' => '3040171000', + 'max_evolution_level' => 4 + }, + 'param' => { + 'id' => 129_355_003, + 'evolution' => '4', + 'phase' => '0', + 'arousal_level' => 1 + } + } + ] + } + end + + it 'creates a collection character' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(1) + expect(result.errors).to be_empty + end + + it 'sets the correct uncap_level from evolution' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.uncap_level).to eq(4) + end + + it 'associates the correct character via granblue_id' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.character.granblue_id).to eq('3040171000') + end + + it 'sets awakening_level from arousal_level' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.awakening_level).to eq(1) + end + end + + context 'with FLB character (evolution 5)' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { + 'id' => '3040167000', + 'max_evolution_level' => 5 + }, + 'param' => { + 'id' => 128_935_603, + 'evolution' => '5', + 'phase' => '0', + 'arousal_level' => 9 + } + } + ] + } + end + + it 'sets uncap_level to 5' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.uncap_level).to eq(5) + end + + it 'sets awakening_level from arousal_level' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.awakening_level).to eq(9) + end + end + + context 'with transcendence data (evolution 6, phase 5)' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { + 'id' => '3040036000', + 'max_evolution_level' => 6 + }, + 'param' => { + 'id' => 128_343_789, + 'evolution' => '6', + 'phase' => '5', + 'arousal_level' => 10 + } + } + ] + } + end + + it 'clamps uncap_level to 5 even when evolution is 6' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.uncap_level).to eq(5) + end + + it 'sets transcendence_step from phase' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.transcendence_step).to eq(5) + end + + it 'sets max awakening_level' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.awakening_level).to eq(10) + end + end + + context 'with duplicate character (unique per user)' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { + 'id' => '3040171000' + }, + 'param' => { + 'id' => 999_999_999, + 'evolution' => '4', + 'phase' => '0', + 'arousal_level' => 5 + } + } + ] + } + end + + before do + create(:collection_character, user: user, character: standard_character) + end + + it 'skips the duplicate based on character_id (not game_id)' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(0) + expect(result.skipped.size).to eq(1) + expect(result.skipped.first[:reason]).to eq('Already exists') + end + + context 'with update_existing: true' do + let(:game_data_updated) do + { + 'list' => [ + { + 'master' => { + 'id' => '3040171000' + }, + 'param' => { + 'id' => 999_999_999, + 'evolution' => '5', + 'phase' => '0', + 'arousal_level' => 10 + } + } + ] + } + end + + it 'updates the existing character' do + service = described_class.new(user, game_data_updated, update_existing: true) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(0) + expect(result.updated.size).to eq(1) + expect(result.updated.first.uncap_level).to eq(5) + expect(result.updated.first.awakening_level).to eq(10) + end + end + end + + context 'with unknown character' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { + 'id' => '9999999999' + }, + 'param' => { + 'id' => 12_345, + 'evolution' => '4', + 'phase' => '0', + 'arousal_level' => 1 + } + } + ] + } + end + + it 'records an error for the unknown character' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.errors.size).to eq(1) + expect(result.errors.first[:error]).to eq('Character not found') + end + end + + context 'with multiple characters' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { 'id' => '3040171000' }, + 'param' => { + 'id' => 111_111_111, + 'evolution' => '4', + 'phase' => '0', + 'arousal_level' => 1 + } + }, + { + 'master' => { 'id' => '3040212000' }, + 'param' => { + 'id' => 222_222_222, + 'evolution' => '5', + 'phase' => '0', + 'arousal_level' => 7 + } + } + ] + } + end + + it 'imports all characters' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(2) + end + + it 'associates correct characters' do + service = described_class.new(user, game_data) + result = service.import + + granblue_ids = result.created.map { |c| c.character.granblue_id }.sort + expect(granblue_ids).to eq(%w[3040171000 3040212000]) + end + end + + context 'with empty data' do + let(:game_data) { { 'list' => [] } } + + it 'returns an error' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be false + expect(result.errors).to include('No character items found in data') + end + end + + context 'with array format data' do + let(:game_data) do + [ + { + 'master' => { 'id' => '3040171000' }, + 'param' => { + 'id' => 777_777_777, + 'evolution' => '4', + 'phase' => '0', + 'arousal_level' => 5 + } + } + ] + end + + it 'handles array format correctly' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(1) + expect(result.created.first.awakening_level).to eq(5) + end + end + + context 'with max values' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { 'id' => '3040036000' }, + 'param' => { + 'id' => 888_888_888, + 'evolution' => '10', + 'phase' => '15', + 'arousal_level' => 99 + } + } + ] + } + end + + it 'clamps uncap_level to max 5' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.uncap_level).to eq(5) + end + + it 'clamps transcendence_step to max 10' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.transcendence_step).to eq(10) + end + + it 'clamps awakening_level to max 10' do + service = described_class.new(user, game_data) + result = service.import + + character = result.created.first + expect(character.awakening_level).to eq(10) + end + end + end + + describe 'edge cases' do + context 'with nil arousal_level' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { 'id' => '3040171000' }, + 'param' => { + 'id' => 555_555_555, + 'evolution' => '4', + 'phase' => '0', + 'arousal_level' => nil + } + } + ] + } + end + + it 'defaults awakening_level to 1' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.awakening_level).to eq(1) + end + end + + context 'with arousal_level 0' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { 'id' => '3040171000' }, + 'param' => { + 'id' => 444_444_444, + 'evolution' => '4', + 'phase' => '0', + 'arousal_level' => 0 + } + } + ] + } + end + + it 'defaults awakening_level to 1' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.awakening_level).to eq(1) + end + end + + context 'with string evolution values' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { 'id' => '3040171000' }, + 'param' => { + 'id' => 333_333_333, + 'evolution' => '5', + 'phase' => '0', + 'arousal_level' => '7' + } + } + ] + } + end + + it 'handles string values correctly' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.uncap_level).to eq(5) + expect(result.created.first.awakening_level).to eq(7) + end + end + + context 'with missing param fields' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { 'id' => '3040171000' }, + 'param' => { + 'id' => 222_222_222 + } + } + ] + } + end + + it 'handles missing fields gracefully (defaults to 0/1)' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + character = result.created.first + expect(character.uncap_level).to eq(0) + expect(character.transcendence_step).to eq(0) + expect(character.awakening_level).to eq(1) + end + end + + context 'assigns default awakening via model callback' do + let(:game_data) do + { + 'list' => [ + { + 'master' => { 'id' => '3040171000' }, + 'param' => { + 'id' => 111_111_111, + 'evolution' => '4', + 'phase' => '0', + 'arousal_level' => 5 + } + } + ] + } + end + + it 'has default awakening set by model' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.awakening).to eq(awakening_balanced) + end + end + end +end diff --git a/spec/services/summon_import_service_spec.rb b/spec/services/summon_import_service_spec.rb new file mode 100644 index 0000000..73e7bec --- /dev/null +++ b/spec/services/summon_import_service_spec.rb @@ -0,0 +1,499 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SummonImportService, type: :service do + let(:user) { create(:user) } + + # Create summons with specific granblue_ids matching the game data + let(:standard_summon) do + Summon.find_by(granblue_id: '2040035000') || + create(:summon, granblue_id: '2040035000', name_en: 'Celeste') + end + + let(:flb_summon) do + Summon.find_by(granblue_id: '2040445000') || + create(:summon, granblue_id: '2040445000', name_en: 'Typhon') + end + + let(:ulb_summon) do + Summon.find_by(granblue_id: '2040379000') || + create(:summon, granblue_id: '2040379000', name_en: 'Gorilla') + end + + let(:transcendable_summon) do + Summon.find_by(granblue_id: '2040100000') || + create(:summon, :transcendable, granblue_id: '2040100000', name_en: 'Bahamut') + end + + before do + standard_summon + flb_summon + ulb_summon + transcendable_summon + end + + describe '#import' do + context 'with valid game data' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 1_500_667_184, + 'image_id' => '2040035000', + 'level' => '1', + 'evolution' => '0', + 'phase' => '0' + }, + 'master' => { + 'id' => 2_040_035_000, + 'rarity' => '4' + } + } + ] + } + end + + it 'creates a collection summon' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(1) + expect(result.errors).to be_empty + end + + it 'sets the correct game_id' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.game_id).to eq('1500667184') + end + + it 'sets the correct uncap_level from evolution' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.uncap_level).to eq(0) + end + + it 'associates the correct summon via granblue_id' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.summon.granblue_id).to eq('2040035000') + end + end + + context 'with FLB summon (evolution 4)' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 1_499_006_961, + 'image_id' => '2040445000', + 'level' => '150', + 'evolution' => '4', + 'phase' => '0' + }, + 'master' => { + 'id' => 2_040_445_000, + 'rarity' => '4' + } + } + ] + } + end + + it 'sets uncap_level to 4' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.uncap_level).to eq(4) + end + end + + context 'with ULB summon (evolution 5)' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 1_494_986_603, + 'image_id' => '2040379000', + 'level' => '200', + 'evolution' => '5', + 'phase' => '0' + }, + 'master' => { + 'id' => 2_040_379_000, + 'rarity' => '4' + } + } + ] + } + end + + it 'sets uncap_level to 5' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.uncap_level).to eq(5) + end + end + + context 'with transcendence data (evolution 6, phase 5)' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 1_088_016_859, + 'image_id' => '2040100000_04', + 'level' => '250', + 'evolution' => '6', + 'phase' => '5' + }, + 'master' => { + 'id' => 2_040_100_000, + 'rarity' => '4' + } + } + ] + } + end + + it 'clamps uncap_level to 5 even when evolution is 6' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.uncap_level).to eq(5) + end + + it 'sets transcendence_step from phase' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.transcendence_step).to eq(5) + end + end + + context 'with duplicate game_id' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 9_999_9999, + 'image_id' => '2040035000', + 'evolution' => '3', + 'phase' => '0' + }, + 'master' => { 'id' => 2_040_035_000 } + } + ] + } + end + + before do + create(:collection_summon, user: user, summon: standard_summon, game_id: '99999999') + end + + it 'skips the duplicate' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(0) + expect(result.skipped.size).to eq(1) + expect(result.skipped.first[:reason]).to eq('Already exists') + end + + context 'with update_existing: true' do + let(:game_data_updated) do + { + 'list' => [ + { + 'param' => { + 'id' => 9_999_9999, + 'image_id' => '2040035000', + 'evolution' => '5', + 'phase' => '0' + }, + 'master' => { 'id' => 2_040_035_000 } + } + ] + } + end + + it 'updates the existing summon' do + service = described_class.new(user, game_data_updated, update_existing: true) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(0) + expect(result.updated.size).to eq(1) + expect(result.updated.first.uncap_level).to eq(5) + end + end + end + + context 'with unknown summon' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 12_345, + 'image_id' => '9999999999', + 'evolution' => '3', + 'phase' => '0' + }, + 'master' => { 'id' => 9_999_999_999 } + } + ] + } + end + + it 'records an error for the unknown summon' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.errors.size).to eq(1) + expect(result.errors.first[:error]).to eq('Summon not found') + end + end + + context 'with multiple summons' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 1_111_1111, + 'image_id' => '2040035000', + 'evolution' => '0', + 'phase' => '0' + }, + 'master' => { 'id' => 2_040_035_000 } + }, + { + 'param' => { + 'id' => 2_222_2222, + 'image_id' => '2040445000', + 'evolution' => '4', + 'phase' => '0' + }, + 'master' => { 'id' => 2_040_445_000 } + } + ] + } + end + + it 'imports all summons' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(2) + end + + it 'associates correct summons' do + service = described_class.new(user, game_data) + result = service.import + + summons = result.created.sort_by(&:game_id) + expect(summons[0].summon.granblue_id).to eq('2040035000') + expect(summons[1].summon.granblue_id).to eq('2040445000') + end + end + + context 'with empty data' do + let(:game_data) { { 'list' => [] } } + + it 'returns an error' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be false + expect(result.errors).to include('No summon items found in data') + end + end + + context 'with array format data' do + let(:game_data) do + [ + { + 'param' => { + 'id' => 7_777_7777, + 'image_id' => '2040035000', + 'evolution' => '3', + 'phase' => '0' + }, + 'master' => { 'id' => 2_040_035_000 } + } + ] + end + + it 'handles array format correctly' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(1) + expect(result.created.first.uncap_level).to eq(3) + end + end + + context 'with image_id containing suffix' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 8_888_8888, + 'image_id' => '2040100000_04', + 'evolution' => '6', + 'phase' => '5' + }, + 'master' => { 'id' => 2_040_100_000 } + } + ] + } + end + + it 'uses master.id when image_id has suffix' do + service = described_class.new(user, game_data) + result = service.import + + # Uses master.id since image_id with _04 suffix won't match + expect(result.created.first.summon.granblue_id).to eq('2040100000') + end + end + + context 'with max values' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 6_666_6666, + 'image_id' => '2040100000', + 'evolution' => '10', + 'phase' => '15' + }, + 'master' => { 'id' => 2_040_100_000 } + } + ] + } + end + + it 'clamps uncap_level to max 5' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.uncap_level).to eq(5) + end + + it 'clamps transcendence_step to max 10' do + service = described_class.new(user, game_data) + result = service.import + + summon = result.created.first + expect(summon.transcendence_step).to eq(10) + end + end + end + + describe 'edge cases' do + context 'with nil param values' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 5_555_5555, + 'image_id' => '2040035000', + 'evolution' => nil, + 'phase' => nil + }, + 'master' => { 'id' => 2_040_035_000 } + } + ] + } + end + + it 'handles nil values gracefully (defaults to 0)' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.uncap_level).to eq(0) + expect(result.created.first.transcendence_step).to eq(0) + end + end + + context 'with string evolution values' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 4_444_4444, + 'image_id' => '2040035000', + 'evolution' => '4', + 'phase' => '0' + }, + 'master' => { 'id' => 2_040_035_000 } + } + ] + } + end + + it 'handles string evolution values' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.uncap_level).to eq(4) + end + end + + context 'with master.id fallback' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => 3_333_3333, + 'evolution' => '3', + 'phase' => '0' + }, + 'master' => { + 'id' => 2_040_035_000 + } + } + ] + } + end + + it 'uses master.id when param.image_id is missing' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.summon.granblue_id).to eq('2040035000') + end + end + end +end diff --git a/spec/services/weapon_import_service_spec.rb b/spec/services/weapon_import_service_spec.rb new file mode 100644 index 0000000..770ff7d --- /dev/null +++ b/spec/services/weapon_import_service_spec.rb @@ -0,0 +1,671 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe WeaponImportService, type: :service do + let(:user) { create(:user) } + + # Create weapons with specific granblue_ids matching the game data + let(:standard_weapon) do + Weapon.find_by(granblue_id: '1040020000') || + create(:weapon, granblue_id: '1040020000', name_en: 'Luminiera Sword Omega') + end + + let(:transcendable_weapon) do + Weapon.find_by(granblue_id: '1040310600') || + create(:weapon, :transcendable, granblue_id: '1040310600', name_en: 'Yggdrasil Crystal Blade Omega') + end + + let(:awakened_weapon) do + Weapon.find_by(granblue_id: '1040914400') || + create(:weapon, granblue_id: '1040914400', name_en: 'Yamato Katana') + end + + let(:ax_weapon) do + Weapon.find_by(granblue_id: '1040213900') || + create(:weapon, :ax_weapon, granblue_id: '1040213900', name_en: 'Celeste Claw Omega') + end + + # Create weapon awakenings + let!(:awakening_atk) do + Awakening.find_by(slug: 'weapon-atk', object_type: 'Weapon') || + create(:awakening, :for_weapon, slug: 'weapon-atk', name_en: 'Attack') + end + + let!(:awakening_def) do + Awakening.find_by(slug: 'weapon-def', object_type: 'Weapon') || + create(:awakening, :for_weapon, slug: 'weapon-def', name_en: 'Defense') + end + + let!(:awakening_special) do + Awakening.find_by(slug: 'weapon-special', object_type: 'Weapon') || + create(:awakening, :for_weapon, slug: 'weapon-special', name_en: 'Special') + end + + let!(:awakening_ca) do + Awakening.find_by(slug: 'weapon-ca', object_type: 'Weapon') || + create(:awakening, :for_weapon, slug: 'weapon-ca', name_en: 'C.A.') + end + + let!(:awakening_skill) do + Awakening.find_by(slug: 'weapon-skill', object_type: 'Weapon') || + create(:awakening, :for_weapon, slug: 'weapon-skill', name_en: 'Skill DMG') + end + + let!(:awakening_heal) do + Awakening.find_by(slug: 'weapon-heal', object_type: 'Weapon') || + create(:awakening, :for_weapon, slug: 'weapon-heal', name_en: 'Healing') + end + + before do + standard_weapon + transcendable_weapon + awakened_weapon + ax_weapon + end + + describe '#import' do + context 'with valid game data' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '49858531', + 'image_id' => '1040020000', + 'evolution' => 3, + 'phase' => 0 + }, + 'master' => { + 'id' => '1040020000', + 'name' => 'Luminiera Sword Omega' + } + } + ] + } + end + + it 'creates a collection weapon' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(1) + expect(result.errors).to be_empty + end + + it 'sets the correct game_id' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.game_id).to eq('49858531') + end + + it 'sets the correct uncap_level from evolution' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.uncap_level).to eq(3) + end + + it 'associates the correct weapon via granblue_id' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.weapon.granblue_id).to eq('1040020000') + end + end + + context 'with transcendence data' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '53169135', + 'image_id' => '1040310600', + 'evolution' => 5, + 'phase' => 6 + }, + 'master' => { + 'id' => '1040310600', + 'name' => 'Yggdrasil Crystal Blade Omega' + } + } + ] + } + end + + it 'sets transcendence_step from phase' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.transcendence_step).to eq(6) + end + + it 'sets uncap_level to 5 for transcended weapons' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.uncap_level).to eq(5) + end + end + + context 'with awakening data' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '96548732', + 'image_id' => '1040914400', + 'evolution' => 5, + 'phase' => 0, + 'arousal' => { + 'is_arousal_weapon' => true, + 'level' => 15, + 'form' => 1, + 'form_name' => 'Attack' + } + }, + 'master' => { + 'id' => '1040914400', + 'name' => 'Yamato Katana' + } + } + ] + } + end + + it 'sets awakening_id based on form' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.awakening).to eq(awakening_atk) + end + + it 'sets awakening_level from arousal level' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.awakening_level).to eq(15) + end + end + + context 'with different awakening forms' do + { + 1 => 'weapon-atk', + 2 => 'weapon-def', + 3 => 'weapon-special', + 4 => 'weapon-ca', + 5 => 'weapon-skill', + 6 => 'weapon-heal' + }.each do |form, slug| + it "maps awakening form #{form} to #{slug}" do + game_data = { + 'list' => [ + { + 'param' => { + 'id' => "test_#{form}", + 'image_id' => '1040914400', + 'evolution' => 5, + 'phase' => 0, + 'arousal' => { + 'is_arousal_weapon' => true, + 'level' => 10, + 'form' => form + } + }, + 'master' => { 'id' => '1040914400' } + } + ] + } + + service = described_class.new(user, game_data) + result = service.import + + expect(result.created.first.awakening.slug).to eq(slug) + end + end + end + + context 'with no awakening' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '12345678', + 'image_id' => '1040020000', + 'evolution' => 3, + 'phase' => 0, + 'arousal' => { + 'is_arousal_weapon' => false + } + }, + 'master' => { 'id' => '1040020000' } + } + ] + } + end + + it 'does not set awakening when is_arousal_weapon is false' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.awakening).to be_nil + expect(weapon.awakening_level).to eq(1) # default value + end + end + + context 'with AX skills' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '55555555', + 'image_id' => '1040213900', + 'evolution' => 5, + 'phase' => 0, + 'augment_skill_info' => [ + [ + { + 'skill_id' => 1, + 'effect_value' => '7', + 'show_value' => '7%' + }, + { + 'skill_id' => 2, + 'effect_value' => '2_4', + 'show_value' => '4%' + } + ] + ] + }, + 'master' => { 'id' => '1040213900' } + } + ] + } + end + + it 'parses first AX skill correctly' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.ax_modifier1).to eq(1) + expect(weapon.ax_strength1).to eq(7.0) + end + + it 'parses second AX skill with underscore format' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.ax_modifier2).to eq(2) + expect(weapon.ax_strength2).to eq(4.0) + end + end + + context 'with show_value format for AX strength' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '66666666', + 'image_id' => '1040213900', + 'evolution' => 5, + 'phase' => 0, + 'augment_skill_info' => [ + [ + { + 'skill_id' => 3, + 'effect_value' => nil, + 'show_value' => '5.5%' + } + ] + ] + }, + 'master' => { 'id' => '1040213900' } + } + ] + } + end + + it 'parses strength from show_value when effect_value is nil' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.ax_modifier1).to eq(3) + expect(weapon.ax_strength1).to eq(5.5) + end + end + + context 'with duplicate game_id' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '99999999', + 'image_id' => '1040020000', + 'evolution' => 3, + 'phase' => 0 + }, + 'master' => { 'id' => '1040020000' } + } + ] + } + end + + before do + create(:collection_weapon, user: user, weapon: standard_weapon, game_id: '99999999') + end + + it 'skips the duplicate' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(0) + expect(result.skipped.size).to eq(1) + expect(result.skipped.first[:reason]).to eq('Already exists') + end + + context 'with update_existing: true' do + let(:game_data_updated) do + { + 'list' => [ + { + 'param' => { + 'id' => '99999999', + 'image_id' => '1040020000', + 'evolution' => 5, + 'phase' => 0 + }, + 'master' => { 'id' => '1040020000' } + } + ] + } + end + + it 'updates the existing weapon' do + service = described_class.new(user, game_data_updated, update_existing: true) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(0) + expect(result.updated.size).to eq(1) + expect(result.updated.first.uncap_level).to eq(5) + end + end + end + + context 'with unknown weapon' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '12345', + 'image_id' => '9999999999', + 'evolution' => 3, + 'phase' => 0 + }, + 'master' => { 'id' => '9999999999' } + } + ] + } + end + + it 'records an error for the unknown weapon' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.errors.size).to eq(1) + expect(result.errors.first[:error]).to eq('Weapon not found') + end + end + + context 'with multiple weapons' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '11111111', + 'image_id' => '1040020000', + 'evolution' => 3, + 'phase' => 0 + }, + 'master' => { 'id' => '1040020000' } + }, + { + 'param' => { + 'id' => '22222222', + 'image_id' => '1040310600', + 'evolution' => 5, + 'phase' => 3 + }, + 'master' => { 'id' => '1040310600' } + } + ] + } + end + + it 'imports all weapons' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(2) + end + + it 'associates correct weapons' do + service = described_class.new(user, game_data) + result = service.import + + weapons = result.created.sort_by(&:game_id) + expect(weapons[0].weapon.granblue_id).to eq('1040020000') + expect(weapons[1].weapon.granblue_id).to eq('1040310600') + end + end + + context 'with empty data' do + let(:game_data) { { 'list' => [] } } + + it 'returns an error' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be false + expect(result.errors).to include('No weapon items found in data') + end + end + + context 'with array format data' do + let(:game_data) do + [ + { + 'param' => { + 'id' => '77777777', + 'image_id' => '1040020000', + 'evolution' => 4, + 'phase' => 0 + }, + 'master' => { 'id' => '1040020000' } + } + ] + end + + it 'handles array format correctly' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.size).to eq(1) + expect(result.created.first.uncap_level).to eq(4) + end + end + + context 'with max uncap/transcendence values' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '88888888', + 'image_id' => '1040310600', + 'evolution' => 10, + 'phase' => 15 + }, + 'master' => { 'id' => '1040310600' } + } + ] + } + end + + it 'clamps uncap_level to max 5' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.uncap_level).to eq(5) + end + + it 'clamps transcendence_step to max 10' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.transcendence_step).to eq(10) + end + end + + context 'with max awakening level' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '10101010', + 'image_id' => '1040914400', + 'evolution' => 5, + 'phase' => 0, + 'arousal' => { + 'is_arousal_weapon' => true, + 'level' => 25, + 'form' => 1 + } + }, + 'master' => { 'id' => '1040914400' } + } + ] + } + end + + it 'clamps awakening_level to max 20' do + service = described_class.new(user, game_data) + result = service.import + + weapon = result.created.first + expect(weapon.awakening_level).to eq(20) + end + end + + context 'with image_id from master.id fallback' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '33333333', + 'evolution' => 3, + 'phase' => 0 + }, + 'master' => { + 'id' => '1040020000' + } + } + ] + } + end + + it 'uses master.id when param.image_id is missing' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.weapon.granblue_id).to eq('1040020000') + end + end + end + + describe 'edge cases' do + context 'with nil arousal' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '44444444', + 'image_id' => '1040020000', + 'evolution' => 3, + 'phase' => 0, + 'arousal' => nil + }, + 'master' => { 'id' => '1040020000' } + } + ] + } + end + + it 'handles nil arousal gracefully' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.awakening).to be_nil + end + end + + context 'with empty augment_skill_info' do + let(:game_data) do + { + 'list' => [ + { + 'param' => { + 'id' => '55556666', + 'image_id' => '1040213900', + 'evolution' => 5, + 'phase' => 0, + 'augment_skill_info' => [] + }, + 'master' => { 'id' => '1040213900' } + } + ] + } + end + + it 'handles empty augment_skill_info gracefully' do + service = described_class.new(user, game_data) + result = service.import + + expect(result.success?).to be true + expect(result.created.first.ax_modifier1).to be_nil + expect(result.created.first.ax_modifier2).to be_nil + end + end + end +end