From 65e03f62d3750c3c1bd02f801231200efede7462 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 11 Feb 2025 03:06:42 -0800 Subject: [PATCH] Add tests and docs to GridCharacter We added a factory, spec and documentation to the GridCharacter model --- app/models/grid_character.rb | 185 +++++++++++++++++++++++++--- spec/factories/grid_characters.rb | 20 +++ spec/models/grid_characters_spec.rb | 166 +++++++++++++++++++++++++ 3 files changed, 352 insertions(+), 19 deletions(-) create mode 100644 spec/factories/grid_characters.rb create mode 100644 spec/models/grid_characters_spec.rb diff --git a/app/models/grid_character.rb b/app/models/grid_character.rb index 36e93e1..98e5e80 100644 --- a/app/models/grid_character.rb +++ b/app/models/grid_character.rb @@ -1,24 +1,43 @@ # frozen_string_literal: true +## +# This file defines the GridCharacter model which represents a character's grid configuration within a party. +# The GridCharacter model handles validations related to awakenings, rings, mastery values, and transcendence. +# It includes virtual attributes for processing new rings and awakening data, and utilizes the amoeba gem +# for duplicating records with specific attribute resets. +# +# @note This model belongs to a Character, an optional Awakening, and a Party. It maintains associations for +# these relationships and includes counter caches for performance optimization. +# +# @!attribute [r] character +# @return [Character] the associated character record. +# @!attribute [r] awakening +# @return [Awakening, nil] the associated awakening record (optional). +# @!attribute [r] party +# @return [Party] the associated party record. +# class GridCharacter < ApplicationRecord + # Associations belongs_to :character, foreign_key: :character_id, primary_key: :id - belongs_to :awakening, optional: true belongs_to :party, counter_cache: :characters_count, inverse_of: :characters + + # Validations validates_presence_of :party + # Validate that uncap_level and transcendence_step are present and numeric. + validates :uncap_level, presence: true, numericality: { only_integer: true } + validates :transcendence_step, presence: true, numericality: { only_integer: true } + validate :validate_awakening_level, on: :update validate :transcendence, on: :update validate :validate_over_mastery_values, on: :update validate :validate_aetherial_mastery_value, on: :update - validate :over_mastery_attack_matches_hp, on: :update - # Virtual attribute for the new rings structure + # Virtual attributes attr_accessor :new_rings - - # Virtual attribute for the new awakening structure attr_accessor :new_awakening ##### Amoeba configuration @@ -31,52 +50,121 @@ class GridCharacter < ApplicationRecord set perpetuity: false end + # Hooks before_validation :apply_new_rings, if: -> { new_rings.present? } before_validation :apply_new_awakening, if: -> { new_awakening.present? } - - # Add awakening before the model saves before_save :add_awakening + ## + # Validates the awakening level to ensure it falls within the allowed range. + # + # @note Triggered on update. + # @return [void] def validate_awakening_level errors.add(:awakening, 'awakening level too low') if awakening_level < 1 errors.add(:awakening, 'awakening level too high') if awakening_level > 9 end + ## + # Validates the transcendence step of the character. + # + # Ensures that the transcendence step is appropriate based on the character's ULB status. + # Adds errors if: + # - The character has a positive transcendence_step but no transcendence (ulb is false). + # - The transcendence_step exceeds the allowed maximum. + # - The transcendence_step is negative when character.ulb is true. + # + # @note Triggered on update. + # @return [void] def transcendence errors.add(:transcendence_step, 'character has no transcendence') if transcendence_step.positive? && !character.ulb errors.add(:transcendence_step, 'transcendence step too high') if transcendence_step > 5 && character.ulb errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb end + ## + # Validates the over mastery attack value for ring1. + # + # Checks that if ring1's modifier is set, the strength must be one of the allowed attack values. + # Adds an error if the value is not valid. + # + # @return [void] def over_mastery_attack errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength']) end + ## + # Validates the over mastery HP value for ring2. + # + # If ring2's modifier is present, ensures that the strength is within the allowed HP values. + # Adds an error if the value is not valid. + # + # @return [void] def over_mastery_hp return if ring2['modifier'].nil? errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength']) end - def over_mastery_attack_matches_hp - return if ring1[:modifier].nil? && ring2[:modifier].nil? - - return if ring2[:strength] == (ring1[:strength] / 2) - - errors.add(:over_mastery, - 'over mastery attack and hp values do not match') + ## + # Validates over mastery values by invoking individual and cross-field validations. + # + # This method triggers: + # - Validation for individual over mastery values for rings 1-4. + # - Validation ensuring that ring1's attack and ring2's HP values are consistent. + # + # @return [void] + def validate_over_mastery_values + validate_individual_over_mastery_values + validate_over_mastery_attack_matches_hp end - def validate_over_mastery_values + ## + # Validates individual over mastery values for each ring (ring1 to ring4). + # + # Iterates over each ring and, if a modifier is present, uses a helper to verify that the associated strength + # is within the permitted range based on over mastery rules. + # + # @return [void] + def validate_individual_over_mastery_values + # Iterate over rings 1-4 and check each ring’s value. [ring1, ring2, ring3, ring4].each_with_index do |ring, index| next if ring['modifier'].nil? - modifier = over_mastery_modifiers[ring['modifier']] - check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, - 'over_mastery') + # Use a helper to add errors if the value is out-of-range. + check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery') end end + ## + # Validates that the over mastery attack value matches the HP value appropriately. + # + # Converts ring1 and ring2 hashes to use indifferent access, and if either ring has a modifier set, + # checks that ring2's strength is exactly half of ring1's strength. + # Adds an error if the values do not match. + # + # @return [void] + def validate_over_mastery_attack_matches_hp + # Convert ring1 and ring2 to use indifferent access so that keys (symbols or strings) + # can be accessed uniformly. + r1 = ring1.with_indifferent_access + r2 = ring2.with_indifferent_access + # Only check if either ring has a modifier set. + if r1[:modifier].present? || r2[:modifier].present? + # Ensure that ring2's strength equals exactly half of ring1's strength. + unless r2[:strength].to_f == (r1[:strength].to_f / 2) + errors.add(:over_mastery, 'over mastery attack and hp values do not match') + end + end + end + + ## + # Validates the aetherial mastery value for the earring. + # + # If the earring's modifier is present and positive, it uses a helper method to check that the strength + # falls within the allowed range for aetherial mastery. + # + # @return [void] def validate_aetherial_mastery_value return if earring['modifier'].nil? @@ -87,22 +175,40 @@ class GridCharacter < ApplicationRecord 'aetherial_mastery') end + ## + # Returns the blueprint for rendering the grid character. + # + # @return [GridCharacterBlueprint] the blueprint class used for grid character representation. def blueprint GridCharacterBlueprint end private + ## + # Adds a default awakening to the character before saving if none is set. + # + # Retrieves the Awakening record with slug 'character-balanced' and assigns it. + # + # @return [void] def add_awakening return unless awakening.nil? self.awakening = Awakening.where(slug: 'character-balanced').sole end + ## + # Applies new ring configurations from the virtual attribute +new_rings+. + # + # Expects +new_rings+ to be an array of hashes with keys "modifier" and "strength". + # Pads the array with default ring hashes to ensure there are exactly four rings, then assigns them to + # ring1, ring2, ring3, and ring4. + # + # @return [void] def apply_new_rings # Expect new_rings to be an array of hashes, e.g., # [{"modifier" => "1", "strength" => "1500"}, {"modifier" => "2", "strength" => "750"}] - default_ring = { "modifier" => nil, "strength" => nil } + default_ring = { 'modifier' => nil, 'strength' => nil } rings_array = Array(new_rings).map(&:to_h) # Pad with defaults so there are exactly four rings rings_array.fill(default_ring, rings_array.size...4) @@ -112,11 +218,29 @@ class GridCharacter < ApplicationRecord self.ring4 = rings_array[3] end + ## + # Applies new awakening configuration from the virtual attribute +new_awakening+. + # + # Sets the +awakening_id+ and +awakening_level+ based on the provided hash. + # + # @return [void] def apply_new_awakening self.awakening_id = new_awakening[:id] self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1 end + ## + # Checks that a given property value falls within the allowed range based on the specified mastery type. + # + # The +property+ parameter is expected to be a hash in the following format: + # { ring1: { atk: 300 } } + # + # Depending on the +type+, it validates against either over mastery or aetherial mastery values. + # Adds an error to the record if the value is not within the permitted range. + # + # @param property [Hash] the property hash containing the attribute and its value. + # @param type [String] the type of mastery validation to perform ('over_mastery' or 'aetherial_mastery'). + # @return [void] def check_value(property, type) # Input format # { ring1: { atk: 300 } } @@ -135,6 +259,10 @@ class GridCharacter < ApplicationRecord end end + ## + # Returns a hash mapping over mastery modifier keys to their corresponding attribute names. + # + # @return [Hash{Integer => String}] mapping of modifier codes to attribute names. def over_mastery_modifiers { 1 => 'atk', @@ -155,6 +283,10 @@ class GridCharacter < ApplicationRecord } end + ## + # Returns a hash containing allowed values for over mastery attributes. + # + # @return [Hash{Symbol => Array}] mapping of attribute names to their valid values. def over_mastery_values { atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000], @@ -175,6 +307,9 @@ class GridCharacter < ApplicationRecord } end + # Returns a hash mapping aetherial mastery modifier keys to their corresponding attribute names. + # + # @return [Hash{Integer => String}] mapping of aetherial mastery modifier codes to attribute names. def aetherial_mastery_modifiers { 1 => 'da', @@ -190,6 +325,10 @@ class GridCharacter < ApplicationRecord } end + ## + # Returns a hash containing allowed values for aetherial mastery attributes. + # + # @return [Hash{Symbol => Hash{Symbol => Integer}}] mapping of attribute names to their minimum and maximum values. def aetherial_mastery_values { da: { @@ -235,10 +374,18 @@ class GridCharacter < ApplicationRecord } end + ## + # Returns an array of valid attack values for over mastery validation. + # + # @return [Array] list of allowed attack values. def atk_values [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000] end + ## + # Returns an array of valid HP values for over mastery validation. + # + # @return [Array] list of allowed HP values. def hp_values [150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500] end diff --git a/spec/factories/grid_characters.rb b/spec/factories/grid_characters.rb new file mode 100644 index 0000000..17be204 --- /dev/null +++ b/spec/factories/grid_characters.rb @@ -0,0 +1,20 @@ +FactoryBot.define do + factory :grid_character do + association :party + # Use the canonical (seeded) Character record. + # Make sure your CSV canonical data (loaded via canonical.rb) includes a Character with the specified granblue_id. + character { Character.find_by!(granblue_id: '3040087000') } + position { 0 } + uncap_level { 3 } + transcendence_step { 0 } + # Virtual attributes default to nil. + new_rings { nil } + new_awakening { nil } + # JSON columns for ring data are set to default hashes. + ring1 { { 'modifier' => nil, 'strength' => nil } } + ring2 { { 'modifier' => nil, 'strength' => nil } } + ring3 { { 'modifier' => nil, 'strength' => nil } } + ring4 { { 'modifier' => nil, 'strength' => nil } } + earring { { 'modifier' => nil, 'strength' => nil } } + end +end diff --git a/spec/models/grid_characters_spec.rb b/spec/models/grid_characters_spec.rb new file mode 100644 index 0000000..efbdf82 --- /dev/null +++ b/spec/models/grid_characters_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true +# spec/models/grid_character_spec.rb +# +# This spec verifies the GridCharacter model’s associations, validations, +# and callbacks. It uses FactoryBot for object creation, shoulda-matchers +# for association/validation shortcuts, and a custom matcher (have_error_on) +# for checking that error messages include specific phrases. +# +# In this version we use canonical data loaded from CSV (via our CSV loader) +# rather than generating new Character and Awakening records. +# +require 'rails_helper' + +RSpec.describe GridCharacter, type: :model do + # Association tests using shoulda-matchers. + it { is_expected.to belong_to(:character) } + it { is_expected.to belong_to(:party) } + it { is_expected.to belong_to(:awakening).optional } + + # Use the canonical "Balanced" awakening already loaded from CSV. + before(:all) do + @balanced_awakening = Awakening.find_by!(slug: 'character-balanced') + end + + # Use canonical records loaded from CSV for our character. + let(:party) { create(:party) } + let(:character) do + # Assume canonical test data has been loaded. + Character.find_by!(granblue_id: '3040087000') + end + + let(:valid_attributes) do + { + party: party, + character: character, + position: 0, + uncap_level: 3, + transcendence_step: 0 + } + end + + describe 'Validations and Associations' do + context 'with valid attributes' do + subject { build(:grid_character, valid_attributes) } + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'without a party' do + subject { build(:grid_character, valid_attributes.merge(party: nil)) } + it 'is invalid' do + subject.valid? + expect(subject.errors[:party]).to include("can't be blank") + end + end + end + + describe 'Callbacks' do + context 'before_validation :apply_new_rings' do + it 'sets the ring attributes when new_rings is provided' do + grid_char = build( + :grid_character, + valid_attributes.merge(new_rings: [ + { 'modifier' => '1', 'strength' => 300 }, + { 'modifier' => '2', 'strength' => 150 } + ]) + ) + grid_char.valid? # triggers the before_validation callback + expect(grid_char.ring1).to eq({ 'modifier' => '1', 'strength' => 300 }) + expect(grid_char.ring2).to eq({ 'modifier' => '2', 'strength' => 150 }) + # The rings array is padded to have exactly four entries. + expect(grid_char.ring3).to eq({ 'modifier' => nil, 'strength' => nil }) + expect(grid_char.ring4).to eq({ 'modifier' => nil, 'strength' => nil }) + end + end + + context 'before_validation :apply_new_awakening' do + it 'sets awakening_id and awakening_level when new_awakening is provided using a canonical awakening' do + # Use an existing awakening from the CSV data. + canonical_awakening = Awakening.find_by!(slug: 'character-def') + new_awakening = { id: canonical_awakening.id, level: '5' } + grid_char = build(:grid_character, valid_attributes.merge(new_awakening: new_awakening)) + grid_char.valid? + expect(grid_char.awakening_id).to eq(canonical_awakening.id) + expect(grid_char.awakening_level).to eq(5) + end + end + + context 'before_save :add_awakening' do + it 'sets the awakening to the balanced canonical awakening if none is provided' do + grid_char = build(:grid_character, valid_attributes.merge(awakening: nil)) + grid_char.save! + expect(grid_char.awakening).to eq(@balanced_awakening) + end + + it 'does not override an existing awakening' do + existing_awakening = Awakening.find_by!(slug: 'character-def') + grid_char = build(:grid_character, valid_attributes.merge(awakening: existing_awakening)) + grid_char.save! + expect(grid_char.awakening).to eq(existing_awakening) + end + end + end + + describe 'Update Validations (on :update)' do + before do + # Persist a valid GridCharacter record. + @grid_char = create(:grid_character, valid_attributes) + end + + context 'validate_awakening_level' do + it 'adds an error if awakening_level is below 1' do + @grid_char.awakening_level = 0 + @grid_char.valid?(:update) + expect(@grid_char.errors[:awakening]).to include('awakening level too low') + end + + it 'adds an error if awakening_level is above 9' do + @grid_char.awakening_level = 10 + @grid_char.valid?(:update) + expect(@grid_char.errors[:awakening]).to include('awakening level too high') + end + end + + context 'transcendence validation' do + it 'adds an error if transcendence_step is positive but character.ulb is false' do + @grid_char.character.update!(ulb: false) + @grid_char.transcendence_step = 1 + @grid_char.valid?(:update) + expect(@grid_char.errors[:transcendence_step]).to include('character has no transcendence') + end + + it 'adds an error if transcendence_step is greater than 5 when character.ulb is true' do + @grid_char.character.update!(ulb: true) + @grid_char.transcendence_step = 6 + @grid_char.valid?(:update) + expect(@grid_char.errors[:transcendence_step]).to include('transcendence step too high') + end + + it 'adds an error if transcendence_step is negative when character.ulb is true' do + @grid_char.character.update!(ulb: true) + @grid_char.transcendence_step = -1 + @grid_char.valid?(:update) + expect(@grid_char.errors[:transcendence_step]).to include('transcendence step too low') + end + end + + context 'over_mastery_attack_matches_hp validation' do + it 'adds an error if ring1 and ring2 values are inconsistent' do + @grid_char.ring1 = { modifier: '1', strength: 300 } + # Expected: ring2 strength should be half of 300 (i.e. 150) + @grid_char.ring2 = { modifier: '2', strength: 100 } + @grid_char.valid?(:update) + expect(@grid_char.errors[:over_mastery]).to include('over mastery attack and hp values do not match') + end + + it 'is valid if ring2 strength equals half of ring1 strength' do + @grid_char.ring1 = { modifier: '1', strength: 300 } + @grid_char.ring2 = { modifier: '2', strength: 150 } + @grid_char.valid?(:update) + expect(@grid_char.errors[:over_mastery]).to be_empty + end + end + end +end