Add tests and docs to GridCharacter

We added a factory, spec and documentation to the GridCharacter model
This commit is contained in:
Justin Edmund 2025-02-11 03:06:42 -08:00
parent ea4ce4dcdc
commit 65e03f62d3
3 changed files with 352 additions and 19 deletions

View file

@ -1,24 +1,43 @@
# frozen_string_literal: true # 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 class GridCharacter < ApplicationRecord
# Associations
belongs_to :character, foreign_key: :character_id, primary_key: :id belongs_to :character, foreign_key: :character_id, primary_key: :id
belongs_to :awakening, optional: true belongs_to :awakening, optional: true
belongs_to :party, belongs_to :party,
counter_cache: :characters_count, counter_cache: :characters_count,
inverse_of: :characters inverse_of: :characters
# Validations
validates_presence_of :party 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 :validate_awakening_level, on: :update
validate :transcendence, on: :update validate :transcendence, on: :update
validate :validate_over_mastery_values, on: :update validate :validate_over_mastery_values, on: :update
validate :validate_aetherial_mastery_value, 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 attr_accessor :new_rings
# Virtual attribute for the new awakening structure
attr_accessor :new_awakening attr_accessor :new_awakening
##### Amoeba configuration ##### Amoeba configuration
@ -31,52 +50,121 @@ class GridCharacter < ApplicationRecord
set perpetuity: false set perpetuity: false
end end
# Hooks
before_validation :apply_new_rings, if: -> { new_rings.present? } before_validation :apply_new_rings, if: -> { new_rings.present? }
before_validation :apply_new_awakening, if: -> { new_awakening.present? } before_validation :apply_new_awakening, if: -> { new_awakening.present? }
# Add awakening before the model saves
before_save :add_awakening 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 def validate_awakening_level
errors.add(:awakening, 'awakening level too low') if awakening_level < 1 errors.add(:awakening, 'awakening level too low') if awakening_level < 1
errors.add(:awakening, 'awakening level too high') if awakening_level > 9 errors.add(:awakening, 'awakening level too high') if awakening_level > 9
end 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 def transcendence
errors.add(:transcendence_step, 'character has no transcendence') if transcendence_step.positive? && !character.ulb 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 high') if transcendence_step > 5 && character.ulb
errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb errors.add(:transcendence_step, 'transcendence step too low') if transcendence_step.negative? && character.ulb
end 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 def over_mastery_attack
errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength']) errors.add(:ring1, 'invalid value') unless ring1['modifier'].nil? || atk_values.include?(ring1['strength'])
end 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 def over_mastery_hp
return if ring2['modifier'].nil? return if ring2['modifier'].nil?
errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength']) errors.add(:ring2, 'invalid value') unless hp_values.include?(ring2['strength'])
end end
def over_mastery_attack_matches_hp ##
return if ring1[:modifier].nil? && ring2[:modifier].nil? # Validates over mastery values by invoking individual and cross-field validations.
#
return if ring2[:strength] == (ring1[:strength] / 2) # This method triggers:
# - Validation for individual over mastery values for rings 1-4.
errors.add(:over_mastery, # - Validation ensuring that ring1's attack and ring2's HP values are consistent.
'over mastery attack and hp values do not match') #
# @return [void]
def validate_over_mastery_values
validate_individual_over_mastery_values
validate_over_mastery_attack_matches_hp
end 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 rings value.
[ring1, ring2, ring3, ring4].each_with_index do |ring, index| [ring1, ring2, ring3, ring4].each_with_index do |ring, index|
next if ring['modifier'].nil? next if ring['modifier'].nil?
modifier = over_mastery_modifiers[ring['modifier']] modifier = over_mastery_modifiers[ring['modifier']]
check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, # Use a helper to add errors if the value is out-of-range.
'over_mastery') check_value({ "ring#{index}": { ring[modifier] => ring['strength'] } }, 'over_mastery')
end end
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 def validate_aetherial_mastery_value
return if earring['modifier'].nil? return if earring['modifier'].nil?
@ -87,22 +175,40 @@ class GridCharacter < ApplicationRecord
'aetherial_mastery') 'aetherial_mastery')
end end
##
# Returns the blueprint for rendering the grid character.
#
# @return [GridCharacterBlueprint] the blueprint class used for grid character representation.
def blueprint def blueprint
GridCharacterBlueprint GridCharacterBlueprint
end end
private 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 def add_awakening
return unless awakening.nil? return unless awakening.nil?
self.awakening = Awakening.where(slug: 'character-balanced').sole self.awakening = Awakening.where(slug: 'character-balanced').sole
end 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 def apply_new_rings
# Expect new_rings to be an array of hashes, e.g., # Expect new_rings to be an array of hashes, e.g.,
# [{"modifier" => "1", "strength" => "1500"}, {"modifier" => "2", "strength" => "750"}] # [{"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) rings_array = Array(new_rings).map(&:to_h)
# Pad with defaults so there are exactly four rings # Pad with defaults so there are exactly four rings
rings_array.fill(default_ring, rings_array.size...4) rings_array.fill(default_ring, rings_array.size...4)
@ -112,11 +218,29 @@ class GridCharacter < ApplicationRecord
self.ring4 = rings_array[3] self.ring4 = rings_array[3]
end 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 def apply_new_awakening
self.awakening_id = new_awakening[:id] self.awakening_id = new_awakening[:id]
self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1 self.awakening_level = new_awakening[:level].present? ? new_awakening[:level].to_i : 1
end 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) def check_value(property, type)
# Input format # Input format
# { ring1: { atk: 300 } } # { ring1: { atk: 300 } }
@ -135,6 +259,10 @@ class GridCharacter < ApplicationRecord
end end
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 def over_mastery_modifiers
{ {
1 => 'atk', 1 => 'atk',
@ -155,6 +283,10 @@ class GridCharacter < ApplicationRecord
} }
end end
##
# Returns a hash containing allowed values for over mastery attributes.
#
# @return [Hash{Symbol => Array<Integer>}] mapping of attribute names to their valid values.
def over_mastery_values def over_mastery_values
{ {
atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000], atk: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000],
@ -175,6 +307,9 @@ class GridCharacter < ApplicationRecord
} }
end 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 def aetherial_mastery_modifiers
{ {
1 => 'da', 1 => 'da',
@ -190,6 +325,10 @@ class GridCharacter < ApplicationRecord
} }
end 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 def aetherial_mastery_values
{ {
da: { da: {
@ -235,10 +374,18 @@ class GridCharacter < ApplicationRecord
} }
end end
##
# Returns an array of valid attack values for over mastery validation.
#
# @return [Array<Integer>] list of allowed attack values.
def atk_values def atk_values
[300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000] [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000]
end end
##
# Returns an array of valid HP values for over mastery validation.
#
# @return [Array<Integer>] list of allowed HP values.
def hp_values def hp_values
[150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500] [150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500]
end end

View file

@ -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

View file

@ -0,0 +1,166 @@
# frozen_string_literal: true
# spec/models/grid_character_spec.rb
#
# This spec verifies the GridCharacter models 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