Skip to content

Commit

Permalink
Abstract out Mine Density Validation for Custom Games
Browse files Browse the repository at this point in the history
Reek prompted me to take this action, with its warning about
Board::Settings having too many methods. This is definitely a good chunk
of logic that is related to Board::Settings, but isn't core to it. i.e.
SRP was being violated before. This also just helps reduce the class
size + cognitive load of opening this file, so I'm in.

Also:
- Reorganize Board::Settings' methods a bit into a more sensible order
  (for me, as of now :P)
- Add a couple of missing tests to Board::SettingsTest
  • Loading branch information
Paul DobbinSchmaltz committed Nov 11, 2024
1 parent 413f587 commit eaf3d9c
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 76 deletions.
80 changes: 23 additions & 57 deletions app/models/board/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ class Board::Settings
allow_blank: true,
}

unless App.dev_mode? # rubocop:disable Style/IfUnlessModifier
validate :validate_mine_density, if: :custom?
unless App.dev_mode?
validates_with(Board::Settings::MineDensityValidator, if: :custom?)
end

def self.preset(type)
Expand All @@ -66,14 +66,6 @@ def self.preset(type)
new(type:, **settings_for(type))
end

def self.random
if Pattern.none? || PercentChance.(PERCENT_CHANCE_FOR_RANDOM_PRESET)
preset(PRESETS.keys.sample)
else # 10% of the time:
random_pattern
end
end

def self.beginner = new(**settings_for("Beginner"))
def self.intermediate = new(**settings_for("Intermediate"))
def self.expert = new(**settings_for("Expert"))
Expand All @@ -84,15 +76,12 @@ def self.settings_for(type)
end
private_class_method :settings_for

# Custom Settings: See {RANGES}.
def self.custom(**) = new(type: Game::CUSTOM_TYPE, **)

# Shortcut for Custom Settings: See {RANGES}.
#
# @example
# Board::Settings[12, 20, 30]
def self.[](*args)
custom(width: args[0], height: args[1], mines: args[2])
def self.random
if Pattern.none? || PercentChance.(PERCENT_CHANCE_FOR_RANDOM_PRESET)
preset(PRESETS.keys.sample)
else # 10% of the time:
random_pattern
end
end

def self.random_pattern
Expand All @@ -110,13 +99,24 @@ def self.pattern(name)
mines: pattern.mines)
end

# Custom Settings: See {RANGES}.
def self.custom(**) = new(type: Game::CUSTOM_TYPE, **)

# Shortcut for Custom Settings: See {RANGES}.
#
# @example
# Board::Settings[12, 20, 30]
def self.[](*args)
custom(width: args[0], height: args[1], mines: args[2])
end

def pattern? = type == Game::PATTERN_TYPE
def custom? = type == Game::CUSTOM_TYPE

def to_s = type
def to_h = { type:, name:, width:, height:, mines: }.tap(&:compact!)
def to_a = to_h.values
def as_json = to_h

def custom? = type == Game::CUSTOM_TYPE
def pattern? = type == Game::PATTERN_TYPE
def to_a = to_h.values

private

Expand All @@ -125,38 +125,4 @@ def inspect_identification = identify(:width, :height, :mines)
def inspect_name
pattern? ? "#{type} (#{name.inspect})" : type
end

def validate_mine_density
return if errors.any?

if too_sparse?
errors.add(
:mines,
"must be >= #{minimum_mines} "\
"(#{minimum_density_percentage} of total area)")
end

if too_dense?
errors.add(
:mines,
"must be <= #{maximum_mines} (#{maximum_density} of total area)")
end
end

def area = width * height
def density = mines / area.to_f

def too_sparse? = density < minimum_density
def minimum_mines = (area * minimum_density).ceil
def minimum_density = RANGES[:mine_density].begin
def minimum_density_percentage = to_percentage(minimum_density * 100.0)

def too_dense? = density > maximum_density
def maximum_mines = (area * maximum_density).floor
def maximum_density = RANGES[:mine_density].end

def to_percentage(number, precision: 0, **)
ActiveSupport::NumberHelper::NumberToPercentageConverter.convert(
number, precision:, **)
end
end
60 changes: 60 additions & 0 deletions app/models/board/settings/mine_density_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

# Board::Settings::MineDensityValidator validates Mine Density limits on Custom
# {Game}s.
class Board::Settings::MineDensityValidator < ActiveModel::Validator
# :reek:UtilityFunction
def validate(settings)
Validate.(settings)
end

# Board::Settings::MineDensityValidator::Validate performs the actual
# Validation for {Board::Settings::MineDensityValidator}.
class Validate
include CallMethodBehaviors

def initialize(settings)
@settings = settings
end

def call
return if errors.any?

if too_sparse?
errors.add(
:mines,
"must be >= #{minimum_mines} "\
"(#{minimum_density_percentage} of total area)")
end

if too_dense?
errors.add(
:mines,
"must be <= #{maximum_mines} (#{maximum_density} of total area)")
end
end

private

attr_reader :settings

def errors = settings.errors

def area = settings.width * settings.height
def density = settings.mines / area.to_f

def too_sparse? = density < minimum_density
def minimum_mines = (area * minimum_density).ceil
def minimum_density = Board::Settings::RANGES[:mine_density].begin
def minimum_density_percentage = to_percentage(minimum_density * 100.0)

def too_dense? = density > maximum_density
def maximum_mines = (area * maximum_density).floor
def maximum_density = Board::Settings::RANGES[:mine_density].end

def to_percentage(number, precision: 0, **)
ActiveSupport::NumberHelper::NumberToPercentageConverter.convert(
number, precision:, **)
end
end
end
76 changes: 57 additions & 19 deletions test/models/board/settings_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,26 @@ class Board::SettingsTest < ActiveSupport::TestCase
end
end

describe "#name" do
subject { unit_class.beginner }

context "GIVEN a preset" do
subject { unit_class.beginner }

it "returns nil" do
_(subject.name).must_be_nil
end
end

context "GIVEN a pattern" do
subject { unit_class.pattern("Test Pattern 1") }

it "returns the expected String" do
_(subject.name).must_equal("Test Pattern 1")
end
end
end

describe "#width" do
subject { unit_class.beginner }

Expand All @@ -327,34 +347,39 @@ class Board::SettingsTest < ActiveSupport::TestCase
end
end

describe "#to_h" do
describe "#custom?" do
context "GIVEN a preset" do
subject { unit_class.beginner }

it "returns the expected Hash" do
_(subject.to_h).must_equal(
{ type: "Beginner", width: 9, height: 9, mines: 10 })
it "returns false" do
_(subject.custom?).must_equal(false)
end
end

context "GIVEN custom attributes" do
subject { unit_class[6, 6, 9] }

describe "#to_h" do
it "returns the expected Hash" do
_(subject.to_h).must_equal(
{ type: "Custom", width: 6, height: 6, mines: 9 })
end
it "returns true" do
_(subject.custom?).must_equal(true)
end
end
end

describe "#as_json" do
subject { unit_class.beginner }
describe "#pattern?" do
context "GIVEN a preset" do
subject { unit_class.beginner }

it "returns the expected Hash" do
_(subject.as_json).must_equal(
{ type: "Beginner", width: 9, height: 9, mines: 10 })
it "returns false" do
_(subject.pattern?).must_equal(false)
end
end

context "GIVEN a pattern" do
subject { unit_class.pattern("Test Pattern 1") }

it "returns true" do
_(subject.pattern?).must_equal(true)
end
end
end

Expand All @@ -376,22 +401,35 @@ class Board::SettingsTest < ActiveSupport::TestCase
end
end

describe "#custom?" do
describe "#to_h" do
context "GIVEN a preset" do
subject { unit_class.beginner }

it "returns false" do
_(subject.custom?).must_equal(false)
it "returns the expected Hash" do
_(subject.to_h).must_equal(
{ type: "Beginner", width: 9, height: 9, mines: 10 })
end
end

context "GIVEN custom attributes" do
subject { unit_class[6, 6, 9] }

it "returns true" do
_(subject.custom?).must_equal(true)
describe "#to_h" do
it "returns the expected Hash" do
_(subject.to_h).must_equal(
{ type: "Custom", width: 6, height: 6, mines: 9 })
end
end
end
end

describe "#as_json" do
subject { unit_class.beginner }

it "returns the expected Hash" do
_(subject.as_json).must_equal(
{ type: "Beginner", width: 9, height: 9, mines: 10 })
end
end
end
end

0 comments on commit eaf3d9c

Please sign in to comment.