diff --git a/Gemfile b/Gemfile index 42d1c55..4131431 100644 --- a/Gemfile +++ b/Gemfile @@ -66,6 +66,8 @@ group :development do # Speed up commands on slow machines / big apps [https://github.com/rails/spring] # gem "spring" end +gem 'activerecord-import' + group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] diff --git a/Gemfile.lock b/Gemfile.lock index 9b81894..84cd245 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -54,6 +54,8 @@ GEM activerecord (7.0.4.3) activemodel (= 7.0.4.3) activesupport (= 7.0.4.3) + activerecord-import (1.4.1) + activerecord (>= 4.2) activestorage (7.0.4.3) actionpack (= 7.0.4.3) activejob (= 7.0.4.3) @@ -211,6 +213,7 @@ PLATFORMS x86_64-darwin-21 DEPENDENCIES + activerecord-import bootsnap capybara cssbundling-rails diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 52c3e8b..2aaf919 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -9,6 +9,7 @@ def create respond_to do |format| if @board.save + @board.generate_board format.html { redirect_to board_url(@board), notice: "Board was successfully created." } else format.html { render :new, status: :unprocessable_entity } @@ -18,12 +19,13 @@ def create def show @board = Board.find(params[:id]) + @mines = @board.mines @user_boards = @board.user.boards.where.not(id: @board.id) end private def board_params - params.require(:board).permit(:name, :width, :height, :mines, :email) + params.require(:board).permit(:name, :width, :height, :mine_count, :email) end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 8b8af15..e0a66ec 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -1,2 +1,5 @@ module BoardsHelper + def cell_contains_mine?(x, y, mines) + mines.any? { |mine| mine.x == x && mine.y == y } + end end diff --git a/app/models/board.rb b/app/models/board.rb index 629ec90..01cc67f 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -1,33 +1,22 @@ class Board < ApplicationRecord belongs_to :user + has_many :mines, dependent: :destroy - validates :name, :width, :height, :mines, presence: true - validates :width, :height, :mines, numericality: { only_integer: true, greater_than: 0 } + validates :name, :width, :height, :mine_count, presence: true + validates :width, :height, :mine_count, numericality: { only_integer: true, greater_than: 0 } validate :mines_less_than_total_cells - serialize :board_state - - before_create :generate_board def mines_less_than_total_cells - if mines.present? && width.present? && height.present? && mines >= width * height - errors.add(:mines, "must be less than total cells") + if mine_count.present? && width.present? && height.present? && mine_count > width * height + errors.add(:mine_count, "must be less or equal to total cells") end end def generate_board - board = Array.new(height) { Array.new(width, '') } - mines_placed = 0 - - while mines_placed < mines - row = rand(height) - col = rand(width) - - if board[row][col] == '' - board[row][col] = '*' - mines_placed += 1 - end - end + generator = MineGeneratorService.new(self.width, self.height, self.mine_count) + mine_positions = generator.generate_mines - self.board_state = board + mines = mine_positions.map { |x, y| Mine.new(board: self, x: x, y: y) } + Mine.import mines, validate: false end end diff --git a/app/models/mine.rb b/app/models/mine.rb new file mode 100644 index 0000000..11b497b --- /dev/null +++ b/app/models/mine.rb @@ -0,0 +1,3 @@ +class Mine < ApplicationRecord + belongs_to :board +end diff --git a/app/services/mine_generator_service.rb b/app/services/mine_generator_service.rb new file mode 100644 index 0000000..e5697fa --- /dev/null +++ b/app/services/mine_generator_service.rb @@ -0,0 +1,22 @@ +class MineGeneratorService + attr_reader :width, :height, :mines + + def initialize(width, height, mines) + @width = width + @height = height + @mines = mines + end + + def generate_mines + positions = (0...width * height).to_a + mine_positions = [] + + mines.times do |i| + random_index = i + rand(positions.length - i) + mine_positions << positions[random_index] + positions[random_index] = positions[i] + end + + mine_positions.map { |pos| [pos % width, pos / width] } + end +end diff --git a/app/views/boards/_form.html.erb b/app/views/boards/_form.html.erb index 3e250bb..d53efd5 100644 --- a/app/views/boards/_form.html.erb +++ b/app/views/boards/_form.html.erb @@ -31,8 +31,8 @@
- <%= f.label :mines, "Number of Mines", class: "form-label" %> - <%= f.number_field :mines, class: "form-control", required: true %> + <%= f.label :mine_count, "Number of Mines", class: "form-label" %> + <%= f.number_field :mine_count, class: "form-control", required: true %>
<%= f.submit "Generate Board", class: "btn btn-primary" %> diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index d88a39c..91aaa8c 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -6,12 +6,18 @@
- <% @board.board_state.each do |row| %> + <% @board.height.times do |y| %>
- <% row.each do |cell| %> -
- <%= cell == '*' ? "💣" : "⬜️" %> -
+ <% @board.width.times do |x| %> + <% if cell_contains_mine?(x, y, @mines) %> +
+ 💣 +
+ <% else %> +
+ ⬜️ +
+ <% end %> <% end %>
<% end %> diff --git a/db/migrate/20230406000648_create_mines.rb b/db/migrate/20230406000648_create_mines.rb new file mode 100644 index 0000000..ac5396f --- /dev/null +++ b/db/migrate/20230406000648_create_mines.rb @@ -0,0 +1,11 @@ +class CreateMines < ActiveRecord::Migration[7.0] + def change + create_table :mines do |t| + t.references :board, null: false, foreign_key: true + t.integer :x + t.integer :y + + t.timestamps + end + end +end diff --git a/db/migrate/20230406002200_remove_board_state_column_from_boards.rb b/db/migrate/20230406002200_remove_board_state_column_from_boards.rb new file mode 100644 index 0000000..82f83fd --- /dev/null +++ b/db/migrate/20230406002200_remove_board_state_column_from_boards.rb @@ -0,0 +1,5 @@ +class RemoveBoardStateColumnFromBoards < ActiveRecord::Migration[7.0] + def change + remove_column :boards, :board_state + end +end diff --git a/db/migrate/20230406002317_rename_mines_column_in_boards.rb b/db/migrate/20230406002317_rename_mines_column_in_boards.rb new file mode 100644 index 0000000..6c8f957 --- /dev/null +++ b/db/migrate/20230406002317_rename_mines_column_in_boards.rb @@ -0,0 +1,5 @@ +class RenameMinesColumnInBoards < ActiveRecord::Migration[7.0] + def change + rename_column :boards, :mines, :mine_count + end +end diff --git a/db/schema.rb b/db/schema.rb index 553a4e6..19d1c76 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_04_03_121704) do +ActiveRecord::Schema[7.0].define(version: 2023_04_06_002317) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -18,14 +18,22 @@ t.string "name" t.integer "width" t.integer "height" - t.integer "mines" + t.integer "mine_count" t.bigint "user_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.text "board_state" t.index ["user_id"], name: "index_boards_on_user_id" end + create_table "mines", force: :cascade do |t| + t.bigint "board_id", null: false + t.integer "x" + t.integer "y" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["board_id"], name: "index_mines_on_board_id" + end + create_table "users", force: :cascade do |t| t.string "email" t.datetime "created_at", null: false @@ -33,4 +41,5 @@ end add_foreign_key "boards", "users" + add_foreign_key "mines", "boards" end diff --git a/lib/tasks/update_boards.rake b/lib/tasks/update_boards.rake new file mode 100644 index 0000000..2608313 --- /dev/null +++ b/lib/tasks/update_boards.rake @@ -0,0 +1,21 @@ +namespace :boards do + desc 'Update previously generated boards to work with the new approach' + task update_boards: :environment do + boards = Board.includes(:mines).all + + boards.each do |board| + if board.mines.size.zero? && board.mine_count > 0 + generator = MineGeneratorService.new(board.width, board.height, board.mine_count) + mine_positions = generator.generate_mines + + mine_positions.each do |x, y| + Mine.create(board: board, x: x, y: y) + end + + puts "Updated board #{board.id}" + end + end + + puts 'All boards updated' + end +end diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index fdb0ddc..c5082ad 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -8,14 +8,14 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest test "should create board" do assert_difference('Board.count', 1) do - post boards_url, params: { board: { email: 'test@example.com', name: 'Test Board', width: 10, height: 10, mines: 10 } } + post boards_url, params: { board: { email: 'test@example.com', name: 'Test Board', width: 10, height: 10, mine_count: 10 } } end assert_redirected_to board_path(Board.last) end test "should not create board with invalid data" do assert_no_difference('Board.count') do - post boards_url, params: { board: { email: 'test@example.com', name: '', width: 10, height: 10, mines: 10 } } + post boards_url, params: { board: { email: 'test@example.com', name: '', width: 10, height: 10, mine_count: 10 } } end assert_response :unprocessable_entity end diff --git a/test/fixtures/boards.yml b/test/fixtures/boards.yml index e66d7cb..e5c796a 100644 --- a/test/fixtures/boards.yml +++ b/test/fixtures/boards.yml @@ -3,13 +3,11 @@ one: name: MyString width: 5 height: 5 - mines: 5 - board_state: [["", "", "", "*", ""], ["", "", "*", "", ""], ["", "", "", "", ""], ["", "*", "", "*", ""], ["", "", "", "*", ""]] + mine_count: 5 two: user: one name: AnotherBoard width: 5 height: 5 - mines: 5 - board_state: [["", "", "", "*", ""], ["", "", "*", "", ""], ["", "", "", "", ""], ["", "*", "", "*", ""], ["", "", "", "*", ""]] + mine_count: 5 diff --git a/test/fixtures/mines.yml b/test/fixtures/mines.yml new file mode 100644 index 0000000..bb36865 --- /dev/null +++ b/test/fixtures/mines.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + board: one + x: 1 + 'y': 1 + +two: + board: two + x: 1 + 'y': 1 diff --git a/test/models/board_test.rb b/test/models/board_test.rb index 6b32c86..94d5f07 100644 --- a/test/models/board_test.rb +++ b/test/models/board_test.rb @@ -3,87 +3,126 @@ class BoardTest < ActiveSupport::TestCase setup do @user = users(:one) + @board = boards(:one) + @board.user = @user end - test "should be valid" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mines: 10) - assert board.valid? + test 'valid board' do + assert @board.valid? end - test "should generate board with correct dimensions" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mines: 10) - - board.generate_board - assert_equal board.height, board.board_state.length, "Board has incorrect height" - assert_equal board.width, board.board_state.first.length, "Board has incorrect width" + test 'invalid without width' do + @board.width = nil + refute @board.valid?, 'board is valid without a width' + assert_not_nil @board.errors[:width], 'no validation error for width' end - test "should generate board with correct number of mines" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mines: 10) - - board.generate_board - mine_count = board.board_state.flatten.count('*') - assert_equal board.mines, mine_count, "Board has incorrect number of mines" + test 'invalid without height' do + @board.height = nil + refute @board.valid?, 'board is valid without a height' + assert_not_nil @board.errors[:height], 'no validation error for height' end - test "should generate empty board with zero mines" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mines: 0) + test 'invalid without name' do + @board.name = nil + refute @board.valid?, 'board is valid without a name' + assert_not_nil @board.errors[:name], 'no validation error for name' + end - board.generate_board - mine_count = board.board_state.flatten.count('*') - assert_equal 0, mine_count, "Board should have no mines" + test 'invalid without user' do + @board.user = nil + refute @board.valid?, 'board is valid without a user' + assert_not_nil @board.errors[:user], 'no validation error for user' end - test "should generate full board with all mines" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mines: 100) + test 'generate_mines' do + @board.mines.destroy_all + @board.save + @board.generate_board - board.generate_board - mine_count = board.board_state.flatten.count('*') - assert_equal board.mines, mine_count, "Board should have all mines" - end + assert_equal @board.mine_count, @board.mines.count, 'Incorrect number of mines generated' - test "should be invalid without user" do - board = Board.new(user: nil, name: 'Test Board', width: 10, height: 10, mines: 10) - assert_not board.valid? + @board.mines.each do |mine| + assert mine.x.between?(0, @board.width - 1), 'Mine x position is outside the board' + assert mine.y.between?(0, @board.height - 1), 'Mine y position is outside the board' + end end - test "should be invalid without name" do - board = Board.new(user: @user, name: nil, width: 10, height: 10, mines: 10) - assert_not board.valid? + test "should be valid" do + board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mine_count: 10) + assert board.valid? end - test "should be invalid without width" do - board = Board.new(user: @user, name: 'Test Board', width: nil, height: 10, mines: 10) - assert_not board.valid? + test "should generate board with correct number of mines" do + @board.width = 10 + @board.height = 10 + @board.mine_count = 10 + @board.mines.destroy_all + + @board.generate_board + mine_count = @board.mines.count + assert_equal @board.mine_count, mine_count, "Board has incorrect number of mines" end - test "should be invalid without height" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: nil, mines: 10) - assert_not board.valid? + test "should generate empty board with zero mines" do + @board.width = 10 + @board.height = 10 + @board.mine_count = 0 + @board.mines.destroy_all + + @board.generate_board + mine_count = @board.mines.count + assert_equal 0, mine_count, "Board should have no mines" end - test "should be invalid without mines" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mines: nil) - assert_not board.valid? + test "should generate full board with all mines" do + @board.width = 10 + @board.height = 10 + @board.mine_count = 10 + @board.mines.destroy_all + + @board.generate_board + mine_count = @board.mines.count + assert_equal @board.mine_count, mine_count, "Board should have all mines" end - test "should not allow negative width" do - board = Board.new(user: @user, name: 'Test Board', width: -10, height: 10, mines: 10) - assert_not board.valid? + test "should generate board efficiently with high mine-to-cell ratio" do + @board.width = 100 + @board.height = 100 + @board.mine_count = 10000 + @board.mines.destroy_all + start_time = Time.now + @board.generate_board + end_time = Time.now + mine_count = @board.mines.count + + assert_equal @board.mine_count, mine_count, "Board has incorrect number of mines" + assert (end_time - start_time) < 2, "Board generation took too long" end - test "should not allow negative height" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: -10, mines: 10) - assert_not board.valid? + test "should generate board efficiently with low mine-to-cell ratio" do + @board.mines.destroy_all + @board.width = 100 + @board.height = 100 + @board.mine_count = 1 + start_time = Time.now + @board.generate_board + end_time = Time.now + mine_count = @board.mines.count + + assert_equal @board.mine_count, mine_count, "Board has incorrect number of mines" + assert (end_time - start_time) < 1, "Board generation took too long" end test "should not allow negative mines" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mines: -10) - assert_not board.valid? + @board.mine_count = -10 + assert_not @board.valid? end test "should not allow more mines than cells" do - board = Board.new(user: @user, name: 'Test Board', width: 10, height: 10, mines: 101) - assert_not board.valid? + @board.width = 10 + @board.height = 10 + @board.mine_count = 101 + assert_not @board.valid? end end diff --git a/test/models/mine_test.rb b/test/models/mine_test.rb new file mode 100644 index 0000000..ff95683 --- /dev/null +++ b/test/models/mine_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MineTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end