diff --git a/.rubocop.yml b/.rubocop.yml index 462884a..5765d73 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -147,3 +147,6 @@ Metrics/ClassLength: Metrics/AbcSize: Enabled: false + +Metrics/CyclomaticComplexity: + Max: 8 diff --git a/Gemfile.lock b/Gemfile.lock index a8db37e..ec3e0f4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,7 +113,7 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.2.2) + brakeman (7.0.2) racc builder (3.3.0) capybara (3.40.0) diff --git a/app/assets/stylesheets/scoreboard.css b/app/assets/stylesheets/scoreboard.css index 0522e4d..f684055 100644 --- a/app/assets/stylesheets/scoreboard.css +++ b/app/assets/stylesheets/scoreboard.css @@ -1,6 +1,5 @@ .scoreboard-container { max-height: calc(100vh - 4rem); - overflow-y: auto; margin-top: 5rem; } @@ -36,6 +35,12 @@ font-weight: bold; } +.scoreboard li.losers { + color: #999; + pointer-events: none; + opacity: 0.6; +} + .scoreboard-player-info { display: flex; align-items: center; @@ -48,7 +53,7 @@ height: 40px; border-radius: 50%; object-fit: cover; - border-radius: var(--pico-border-radius, 2rem); + border-radius: var(--pico-border-radius, 2rem); } .scoreboard-player-name { diff --git a/app/assets/stylesheets/streaks.css b/app/assets/stylesheets/streaks.css new file mode 100644 index 0000000..9db6428 --- /dev/null +++ b/app/assets/stylesheets/streaks.css @@ -0,0 +1,65 @@ +:root { + --game-font: "Press Start 2P", cursive; + --sassy: #bf4080; + --coral: #fe5f55; + --royal: #6f2ed8; + --tiffany: #00b4d8; + --peach: #e6f14a; + --off-white: #fffff7; +} + +.wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-content: center; + margin: auto; + text-align: center; +} + +.neon-text { + position: relative; + margin: 0; + font-family: var(--game-font); + font-size: 1rem; + width: 100%; + text-transform: uppercase; + -webkit-text-stroke: 1px var(--sassy); + color: var(--off-white); + z-index: 10; +} + +.neon-text::before { + content: attr(data-text); + position: absolute; + bottom: 8px; + right: 8px; + font-family: inherit; + font-size: 1rem; + color: var(--sassy); + width: 100%; + height: 100%; + animation: animateTextColor 3s infinite linear; + z-index: -1; +} + +@keyframes animateTextColor { + 0% { + color: var(--tiffany); + } + 20% { + color: var(--coral); + } + 50% { + color: var(--royal); + } + 60% { + color: var(--peach); + } + 80% { + color: var(--sassy); + } + 100% { + color: var(--tiffany); + } +} diff --git a/app/controllers/game_player_answers_controller.rb b/app/controllers/game_player_answers_controller.rb index 6be7632..2fe56f3 100644 --- a/app/controllers/game_player_answers_controller.rb +++ b/app/controllers/game_player_answers_controller.rb @@ -9,18 +9,18 @@ class GamePlayerAnswersController < ApplicationController # POST /games/:id/player_answer def create + time_at_answer = Time.current + time_taken = @game_question.started_at.present? ? (time_at_answer - @game_question.started_at).round : nil + player_answer = PlayerAnswer.build( game_player: @game_player, game_question: @game_question, answer: @answer, - correct: @answer.correct + correct: @answer.correct, + time_taken: time_taken ) if player_answer.save - if @answer.correct - @game_player.update(points: @game_player.points + @game_question.question.points) - end - render json: { message: "Answer submitted successfully" }, status: :ok else render json: { error: @player_answer.errors.full_messages }, status: :unprocessable_entity diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 02a4544..d3fb52d 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -4,12 +4,13 @@ # # Table name: game_players # -# id :integer not null, primary key -# points :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# game_id :integer not null -# user_id :integer not null +# id :integer not null, primary key +# current_streak :integer default(0), not null +# points :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# game_id :integer not null +# user_id :integer not null # # Indexes # @@ -26,8 +27,68 @@ class GamePlayer < ApplicationRecord validates :points, numericality: { greater_than_or_equal_to: 0 } + # @param [GameQuestion] + # @return [PlayerAnswer, nil] def find_answer_for(game_question) player_answers.find_by(game_question: game_question) end + # Awards points to the game player by incrementing their total points with + # the sum of the current question points with all the bonuses they can get + # + # @param [Integer] question_points + # @param [Integer, nil] time_taken + # @return [GamePlayer] + def award_points!(question_points, time_taken) + streak_bonus = calculate_streak_bonus(question_points) + speed_bonus = calculate_speed_bonus(time_taken) + + with_lock do + update_columns( + points: points + question_points + streak_bonus + speed_bonus, + current_streak: streak_length + ) + end + end + + private + + # Calculates the bonus points a player earns for answering quickly. + # A simple tiered bonus is applied. + # + # time_taken is expected to be >= 0, or nil + # + # @param [Integer, nil] time_taken + # @return [Integer] + def calculate_speed_bonus(time_taken) + return 0 if time_taken.blank? || time_taken.negative? || time_taken > 8 + + case time_taken + when 0..2 then 3 + when 2..4 then 2 + when 4..6 then 1 + else 0 + end + end + + # Calculates streak bonus based on the player's current + # streak length, the bonus is calculated by + # multiplying the question points with the streak length + # and the bonus multiplier which is 25% + # + # @param [Integer] question_points + # @return [Integer] + def calculate_streak_bonus(question_points) + return 0 if streak_length == 1 + + (question_points * streak_length * 0.25).round + end + + # @return [Integer] + def streak_length + recent_answers = player_answers.order(created_at: :desc).pluck(:correct) + streak = recent_answers.take_while { |correct| correct }.count + [streak, 1].max + end + end diff --git a/app/models/game_question.rb b/app/models/game_question.rb index c9ac96c..d8a2815 100644 --- a/app/models/game_question.rb +++ b/app/models/game_question.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # current_phase :integer default("idle"), not null +# started_at :datetime # created_at :datetime not null # updated_at :datetime not null # game_id :integer not null @@ -24,8 +25,40 @@ class GameQuestion < ApplicationRecord belongs_to :question has_many :player_answers, dependent: :destroy + before_save :set_started_at_if_answering + before_save :reward_players_if_completed + def answers_count player_answers.group(:answer_id).count end + private + + def set_started_at_if_answering + return unless current_phase_changed?(from: :reading, to: :answering) + return unless started_at.nil? + + self.started_at = Time.current + end + + def reward_players_if_completed + return unless current_phase_changed?(from: :answering, to: :completed) + + all_players = game.game_players.includes(:player_answers) + all_players.find_each do |player| + current_question_answer = player.player_answers.find_by(game_question_id: id) + unless current_question_answer + player.player_answers.create(game_question: self, answer_id: -1, correct: false) + end + + if current_question_answer&.correct + player.award_points!(question.points, current_question_answer.time_taken) + else + player.update(current_streak: 0) + end + end + + true + end + end diff --git a/app/models/player_answer.rb b/app/models/player_answer.rb index 664900f..e2dbc9a 100644 --- a/app/models/player_answer.rb +++ b/app/models/player_answer.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # correct :boolean not null +# time_taken :integer # created_at :datetime not null # updated_at :datetime not null # answer_id :integer not null @@ -20,7 +21,7 @@ # class PlayerAnswer < ApplicationRecord - belongs_to :answer + belongs_to :answer, optional: true belongs_to :game_player belongs_to :game_question diff --git a/app/models/question.rb b/app/models/question.rb index bc32021..2f619c7 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -5,7 +5,7 @@ # Table name: questions # # id :integer not null, primary key -# duration :integer default(120), not null +# duration :integer default(40), not null # points :integer default(1) # position :integer # text :text @@ -36,13 +36,13 @@ class Question < ApplicationRecord numericality: { only_integer: true, greater_than: 0, - less_than_or_equal_to: 100 + less_than_or_equal_to: 10 } validates :duration, numericality: { only_integer: true, - greater_than: 30, - less_than_or_equal_to: 240 + greater_than_or_equal_to: 10, + less_than_or_equal_to: 100 } validate :image_size diff --git a/app/views/games/components/_players_status.html.erb b/app/views/games/components/_players_status.html.erb index d1199e2..a24b9a7 100644 --- a/app/views/games/components/_players_status.html.erb +++ b/app/views/games/components/_players_status.html.erb @@ -11,6 +11,8 @@ <% end %> +
+

Players joined (<%= game.players.count %>)

<% else %>

Waiting for players to join...

<% end %> diff --git a/app/views/games/components/_scoreboard.html.erb b/app/views/games/components/_scoreboard.html.erb index d614ff4..68cbd1d 100644 --- a/app/views/games/components/_scoreboard.html.erb +++ b/app/views/games/components/_scoreboard.html.erb @@ -9,6 +9,12 @@
<%= image_tag player.user.image, alt: player.user.name, width: 40, class: "scoreboard-players-avatar" %> <%= player.user.name %> + <% if player.current_streak > 1 && game.ended_at.nil? %> +
+ <% streak_text = "Streak of #{player.current_streak}!" %> +

<%= streak_text %>

+
+ <% end %>
<%= player.points %> pts diff --git a/app/views/games/screens/_start.html.erb b/app/views/games/screens/_start.html.erb index eab0dbd..87086fb 100644 --- a/app/views/games/screens/_start.html.erb +++ b/app/views/games/screens/_start.html.erb @@ -4,7 +4,7 @@ <% if host_user? %>

- Invite players by sending them a + Invite players by sending them a <%= link_to root_url + @game.key, data: { controller: "clipboard", action: "click->clipboard#copy", tooltip: "copy" } do %> link to the game <%= image_tag "copy.svg", width: 20, alt: "Copy link" %> diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index 03c5e81..600d331 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -11,6 +11,7 @@ + <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> diff --git a/app/views/questions/_form.html.erb b/app/views/questions/_form.html.erb index 6b1bb2b..2b28441 100644 --- a/app/views/questions/_form.html.erb +++ b/app/views/questions/_form.html.erb @@ -6,10 +6,10 @@ <%= form.textarea :text, required: true %> <%= form.label :points %> - <%= form.number_field :points, min: 0, max: 100 %> + <%= form.number_field :points, min: 0, max: 10 %> <%= form.label :duration %> - <%= form.number_field :duration, min: 30, max: 240 %> + <%= form.number_field :duration, min: 10, max: 100 %> <%= form.label :image %> <% if question.image.representable? %> diff --git a/db/migrate/20250401145708_add_started_at_to_game_questions.rb b/db/migrate/20250401145708_add_started_at_to_game_questions.rb new file mode 100644 index 0000000..7673ffd --- /dev/null +++ b/db/migrate/20250401145708_add_started_at_to_game_questions.rb @@ -0,0 +1,7 @@ +class AddStartedAtToGameQuestions < ActiveRecord::Migration[8.1] + + def change + add_column :game_questions, :started_at, :datetime + end + +end diff --git a/db/migrate/20250403171237_add_current_streak_to_game_players.rb b/db/migrate/20250403171237_add_current_streak_to_game_players.rb new file mode 100644 index 0000000..4ac6017 --- /dev/null +++ b/db/migrate/20250403171237_add_current_streak_to_game_players.rb @@ -0,0 +1,7 @@ +class AddCurrentStreakToGamePlayers < ActiveRecord::Migration[8.1] + + def change + add_column :game_players, :current_streak, :integer, default: 0, null: false + end + +end diff --git a/db/migrate/20250408114324_add_time_taken_to_player_answers.rb b/db/migrate/20250408114324_add_time_taken_to_player_answers.rb new file mode 100644 index 0000000..0a64571 --- /dev/null +++ b/db/migrate/20250408114324_add_time_taken_to_player_answers.rb @@ -0,0 +1,7 @@ +class AddTimeTakenToPlayerAnswers < ActiveRecord::Migration[8.1] + + def change + add_column :player_answers, :time_taken, :integer + end + +end diff --git a/db/migrate/20250426220951_change_questions_duration_default_value.rb b/db/migrate/20250426220951_change_questions_duration_default_value.rb new file mode 100644 index 0000000..e83e771 --- /dev/null +++ b/db/migrate/20250426220951_change_questions_duration_default_value.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ChangeQuestionsDurationDefaultValue < ActiveRecord::Migration[8.1] + + def up + change_column_default :questions, :duration, from: 120, to: 40 + end + + def down + change_column_default :questions, :duration, from: 40, to: 120 + end + +end diff --git a/db/schema.rb b/db/schema.rb index c435b1e..639529c 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[8.1].define(version: 2024_11_23_143824) do +ActiveRecord::Schema[8.1].define(version: 2025_04_26_220951) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -56,6 +56,7 @@ t.integer "points", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "current_streak", default: 0, null: false t.index ["game_id"], name: "index_game_players_on_game_id" t.index ["user_id"], name: "index_game_players_on_user_id" end @@ -66,6 +67,7 @@ t.integer "current_phase", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "started_at" t.index ["game_id"], name: "index_game_questions_on_game_id" t.index ["question_id"], name: "index_game_questions_on_question_id" end @@ -91,6 +93,7 @@ t.boolean "correct", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "time_taken" t.index ["answer_id"], name: "index_player_answers_on_answer_id" t.index ["game_player_id"], name: "index_player_answers_on_game_player_id" t.index ["game_question_id"], name: "index_player_answers_on_game_question_id" @@ -99,7 +102,7 @@ create_table "questions", force: :cascade do |t| t.integer "quiz_id", null: false t.text "text" - t.integer "duration", default: 120, null: false + t.integer "duration", default: 40, null: false t.integer "points", default: 1 t.integer "position" t.datetime "created_at", null: false diff --git a/test/controllers/game_player_answers_controller_test.rb b/test/controllers/game_player_answers_controller_test.rb new file mode 100644 index 0000000..6d5d53d --- /dev/null +++ b/test/controllers/game_player_answers_controller_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "test_helper" + +class GamePlayerAnswersControllerTest < ActionDispatch::IntegrationTest + + setup do + @user = users(:one) + @game = games(:one) + @game_question = game_questions(:one) + @correct_answer = answers(:correct) + @incorrect_answer = answers(:incorrect) + @game_player = game_players(:one) + + set_current_user(@user) + end + + test "should create player answer" do + @game_question.update(started_at: 10.seconds.ago) + + assert_difference("PlayerAnswer.count") do + post player_answer_game_path(@game), params: { + game_question_id: @game_question.id, + selected_answer_id: @correct_answer.id + } + end + + assert_response :success + response_json = JSON.parse(response.body) + assert_equal "Answer submitted successfully", response_json["message"] + end + +end diff --git a/test/fixtures/answers.yml b/test/fixtures/answers.yml index 2ca58a6..2eefdab 100644 --- a/test/fixtures/answers.yml +++ b/test/fixtures/answers.yml @@ -20,7 +20,7 @@ one: question: one - text: Answer One + text: Answer One correct: true two: @@ -32,3 +32,13 @@ three: question: three text: Answer Three correct: false + +correct: + question: one + text: Correct Answer + correct: true + +incorrect: + question: one + text: Incorrect answer + correct: false diff --git a/test/fixtures/game_players.yml b/test/fixtures/game_players.yml index 7a8a110..68843b7 100644 --- a/test/fixtures/game_players.yml +++ b/test/fixtures/game_players.yml @@ -4,12 +4,13 @@ # # Table name: game_players # -# id :integer not null, primary key -# points :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# game_id :integer not null -# user_id :integer not null +# id :integer not null, primary key +# current_streak :integer default(0), not null +# points :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# game_id :integer not null +# user_id :integer not null # # Indexes # @@ -20,8 +21,16 @@ one: game: one user: one points: 0 + current_streak: 3 two: game: one user: two points: 0 + current_streak: 2 + +three: + game: one + user: three + points: 0 + current_streak: 1 diff --git a/test/fixtures/game_questions.yml b/test/fixtures/game_questions.yml index 68efc62..a59e997 100644 --- a/test/fixtures/game_questions.yml +++ b/test/fixtures/game_questions.yml @@ -10,6 +10,7 @@ # # id :integer not null, primary key # current_phase :integer default("idle"), not null +# started_at :datetime # created_at :datetime not null # updated_at :datetime not null # game_id :integer not null @@ -22,22 +23,22 @@ # one: game: one - question: one + question: one current_phase: idle two: game: one - question: two + question: two current_phase: idle first_in_one: id: 1 game: one - question: one + question: one current_phase: idle second_in_one: id: 2 game: one - question: two + question: two current_phase: idle diff --git a/test/fixtures/player_answers.yml b/test/fixtures/player_answers.yml index 31080a5..b7c2162 100644 --- a/test/fixtures/player_answers.yml +++ b/test/fixtures/player_answers.yml @@ -10,6 +10,7 @@ # # id :integer not null, primary key # correct :boolean not null +# time_taken :integer # created_at :datetime not null # updated_at :datetime not null # answer_id :integer not null diff --git a/test/fixtures/questions.yml b/test/fixtures/questions.yml index 6e2fc1d..bf57c92 100644 --- a/test/fixtures/questions.yml +++ b/test/fixtures/questions.yml @@ -3,7 +3,7 @@ # Table name: questions # # id :integer not null, primary key -# duration :integer default(120), not null +# duration :integer default(40), not null # points :integer default(1) # position :integer # text :text @@ -21,14 +21,14 @@ one: quiz: one text: Question One - points: 10 + points: 3 two: quiz: two - text: Question Two - points: 20 + text: Question Two + points: 6 three: quiz: one text: Question Three - points: 30 + points: 9 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 474bf43..2f75745 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -20,7 +20,7 @@ one: name: John Doe - email: john.doe@email.dev + email: john.doe@email.dev first_name: John last_name: Doe image: https://example.com @@ -31,3 +31,10 @@ two: first_name: Doe last_name: John image: https://example.com + +three: + name: George Three + email: george.three@email.dev + first_name: George + last_name: Three + image: https://example.com diff --git a/test/models/answer_test.rb b/test/models/answer_test.rb index 5cd3595..f6a5672 100644 --- a/test/models/answer_test.rb +++ b/test/models/answer_test.rb @@ -48,7 +48,9 @@ def setup test "should not allow more than MAX_ANSWERS answers for a question" do question = questions(:one) - 5.times { question.answers.create!(text: "Sample answer") } + question.answers.delete_all + + 6.times { question.answers.create!(text: "Sample answer") } extra_answer = question.answers.build(text: "Extra answer") assert_not extra_answer.valid?, "Answer should be invalid as it exceeds the answer limit" diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index 4914b81..095ad30 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -4,12 +4,13 @@ # # Table name: game_players # -# id :integer not null, primary key -# points :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# game_id :integer not null -# user_id :integer not null +# id :integer not null, primary key +# current_streak :integer default(0), not null +# points :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# game_id :integer not null +# user_id :integer not null # # Indexes # @@ -26,6 +27,7 @@ def setup end # Associations + test "should belong to a game" do assert_respond_to @game_player, :game assert_not_nil @game_player.game @@ -41,6 +43,7 @@ def setup end # Validations + test "points should be non-negative" do @game_player.points = -1 assert_not @game_player.valid? @@ -53,17 +56,19 @@ def setup end # Scope + test "by_points scope should order by points in descending order" do player1 = game_players(:one) player2 = game_players(:two) player1.update!(points: 20) player2.update!(points: 50) - result = GamePlayer.by_points + result = GamePlayer.where(id: [player1, player2]).by_points assert_equal [player2, player1], result end - # Instance Methods + ############ Instance Methods + # find_answer_for test "find_answer_for should return the correct player answer" do player_answer = player_answers(:one) assert_equal player_answer, @game_player.find_answer_for(@game_question) @@ -74,4 +79,204 @@ def setup assert_nil @game_player.find_answer_for(game_question) end + # award_points + test "awards zero points when both are zero" do + @game_player.update!(points: 10) + @game_player.award_points!(1, 30) # too late = 0 bonus + assert_equal 11, @game_player.reload.points + end + + test "awards only question points" do + @game_player.update!(points: 10) + @game_player.award_points!(10, 100) # too late = 0 bonus + assert_equal 20, @game_player.reload.points + end + + test "awards question and bonus points" do + @game_player.update!(points: 0) + @game_player.award_points!(10, 0) # max bonus + assert_equal 13, @game_player.reload.points + end + + test "awards max bonus for high-value question answered fast" do + @game_player.update!(points: 0) + @game_player.award_points!(10, 2) # full bonus 2s + assert_equal 13, @game_player.reload.points + end + + test "handles bonus cutoff at 8 seconds" do + @game_player.update!(points: 0) + @game_player.award_points!(9, 8) # bonus cutoff + assert_equal 9, @game_player.reload.points + end + + test "handles multiple awards correctly" do + @game_player.update!(points: 0) + 3.times { @game_player.award_points!(10, 2) } + # 10 + 3 (bonus) = 13 * 3 = 39 + assert_equal 39, @game_player.reload.points + end + + test "awards minimum legal question points with no bonus" do + @game_player.update!(points: 5) + @game_player.award_points!(1, 10) # way too late + assert_equal 6, @game_player.reload.points + end + + test "awards minimum legal question points with max bonus" do + @game_player.update!(points: 0) + @game_player.award_points!(1, 1) # fast answer + # low-point logic: bonus = 3 + assert_equal 4, @game_player.reload.points + end + + test "awards max bonus at edge boundary time 2s" do + @game_player.update!(points: 0) + @game_player.award_points!(10, 2) + # still considered full scale bonus + assert_equal 13, @game_player.reload.points + end + + test "awards partial bonus at midpoint time 5s" do + @game_player.update!(points: 0) + @game_player.award_points!(10, 5) + assert_equal 11, @game_player.reload.points + end + + test "awards 1 bonus point for low-value question at 5s" do + @game_player.update!(points: 0) + @game_player.award_points!(10, 5) + # falls into 4..6 range → bonus = 1 + assert_equal 11, @game_player.reload.points + end + + test "no bonus exactly at cutoff time 8s" do + @game_player.update!(points: 0) + @game_player.award_points!(10, 8) + assert_equal 10, @game_player.reload.points + end + + test "clamps negative time_taken to zero bonus" do + @game_player.update!(points: 0) + @game_player.award_points!(10, -3) + # time_taken is negative, but logic returns 0 bonus + assert_equal 10, @game_player.reload.points + end + + test "awards correct bonus just under 4s (3s => 2 bonus)" do + @game_player.update!(points: 0) + @game_player.award_points!(10, 3) + assert_equal 12, @game_player.reload.points + end + + test "awards correct bonus just under 6s (5s => 1 bonus)" do + @game_player.update!(points: 0) + @game_player.award_points!(10, 5) + assert_equal 11, @game_player.reload.points + end + + # Private methods + + # calculate_speed_bonus, + + test "low-point exact 2s" do + bonus = @game_player.send(:calculate_speed_bonus, 2) + assert_equal 3, bonus + end + + test "low-point exact 4s" do + bonus = @game_player.send(:calculate_speed_bonus, 4) + assert_equal 2, bonus # falls into 2..4 range (.. is inclusive) + end + + test "low-point edge 6s" do + bonus = @game_player.send(:calculate_speed_bonus, 6) + assert_equal 1, bonus + end + + test "low-point above 6s" do + bonus = @game_player.send(:calculate_speed_bonus, 6.1) + assert_equal 0, bonus + end + + test "low-point just under 2s" do + bonus = @game_player.send(:calculate_speed_bonus, 1.9) + assert_equal 3, bonus + end + + test "low-point negative time (should still max tier)" do + bonus = @game_player.send(:calculate_speed_bonus, -1) + assert_equal 0, bonus + end + + test "zero-point question" do + bonus = @game_player.send(:calculate_speed_bonus, 1) + assert_equal 3, bonus # falls under low-point logic + end + + test "negative-point question" do + bonus = @game_player.send(:calculate_speed_bonus, 1) + assert_equal 3, bonus # still low-point logic + end + + test "bonus never negative even with high time" do + bonus = @game_player.send(:calculate_speed_bonus, 100) + assert_equal 0, bonus + end + + # calculate_streak_bonus + + test "calculate_streak_bonus returns 0 when streak is 1" do + @game_player.stub :streak_length, 1 do + result = @game_player.send(:calculate_streak_bonus, 50) + assert_equal 0, result + end + end + + test "calculate_streak_bonus returns correct bonus when streak is > 1" do + @game_player.stub :streak_length, 4 do + result = @game_player.send(:calculate_streak_bonus, 40) + # 40 * 4 * 0.25 = 40 + assert_equal 40, result + end + end + + test "calculate_streak_bonus rounds correctly" do + @game_player.stub :streak_length, 3 do + result = @game_player.send(:calculate_streak_bonus, 25) + # 25 * 3 * 0.25 = 18.75 → 19 + assert_equal 19, result + end + end + + # streak_length + + test "streak_length returns 1 if no correct answers" do + @game_player.stub :player_answers, stub(order: stub(pluck: [false, false, true])) do + result = @game_player.send(:streak_length) + assert_equal 1, result + end + end + + test "streak_length returns correct streak count - 3" do + @game_player.stub :player_answers, stub(order: stub(pluck: [true, true, true, false, true])) do + result = @game_player.send(:streak_length) + assert_equal 3, result + end + end + + test "streak_length returns correct streak count - 2" do + @game_player.stub :player_answers, stub(order: stub(pluck: [true, true, false, true])) do + result = @game_player.send(:streak_length) + assert_equal 2, result + end + end + + test "streak_length minimum is 1 even if all are wrong" do + @game_player.stub :player_answers, stub(order: stub(pluck: [false, false, false])) do + result = @game_player.send(:streak_length) + assert_equal 1, result + end + end + end diff --git a/test/models/game_question_test.rb b/test/models/game_question_test.rb index 7732681..b341652 100644 --- a/test/models/game_question_test.rb +++ b/test/models/game_question_test.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # current_phase :integer default("idle"), not null +# started_at :datetime # created_at :datetime not null # updated_at :datetime not null # game_id :integer not null @@ -60,6 +61,58 @@ def setup assert_equal "idle", new_game_question.current_phase end + # Callbacks + test "does not change started_at when transitioning from answering to completed" do + original_time = 2.minutes.ago + @game_question.update!(current_phase: "answering", started_at: original_time) + + @game_question.current_phase = "completed" + @game_question.save! + + assert_equal original_time.to_i, @game_question.started_at.to_i + end + + test "does not set started_at when directly creating with answering phase" do + freeze_time do + Time.current + new_question = GameQuestion.create!( + game: games(:one), + question: questions(:one), + current_phase: "answering" + ) + + assert_nil new_question.started_at + end + end + + test "does not set started_at when updating other attributes while in answering phase" do + original_time = 2.minutes.ago + @game_question.update!(current_phase: "answering", started_at: original_time) + + @game_question.updated_at = Time.current + @game_question.save! + + assert_equal original_time.to_i, @game_question.started_at.to_i + end + + test "preserves nil started_at when changing between non-answering phases" do + @game_question.update!(current_phase: "idle", started_at: nil) + + @game_question.current_phase = "reading" + @game_question.save! + + assert_nil @game_question.started_at + end + + test "started_at is not set when changing to answering but validation fails" do + @game_question.game = nil + @game_question.current_phase = "answering" + + assert_not @game_question.save + + assert_nil @game_question.started_at + end + # Methods test "answers_count should return correct counts" do player_answer = player_answers(:one) @@ -69,4 +122,65 @@ def setup assert_equal 1, counts[player_answer.answer_id] end + test "sets started_at when changing to answering and it's nil" do + @game_question.reading! + assert_nil @game_question.started_at + + @game_question.current_phase = "answering" + freeze_time do + now = Time.current + @game_question.save! + + assert_equal now, @game_question.started_at + end + end + + test "does not set started_at if already set" do + @game_question.update!(started_at: 2.minutes.ago, current_phase: "reading") + + @game_question.current_phase = "answering" + @game_question.save! + + # Should not overwrite started_at + assert_in_delta 2.minutes.ago.to_i, @game_question.started_at.to_i, 1 + end + + test "does not set started_at if phase is not changing to answering" do + @game_question.current_phase = "reading" # not transitioning to 'answering' + @game_question.save! + + assert_nil @game_question.started_at + end + + test "reward_players_if_completed awards points to correct answers and resets streaks for incorrect or no answers" do + @game_question.answering! + @game = @game_question.game + @question = @game_question.question + + # Create players + @player1 = game_players(:one) + @player2 = game_players(:two) + @player3 = game_players(:three) # Player 3 does not answer + + GamePlayer.any_instance.stubs(:streak_length).returns(4) # increase length by 1 + + # Transition the phase to completed + @game_question.current_phase = :completed + @game_question.save! + + # Reload players to check updates + @player1.reload + @player2.reload + @player3.reload + + # Assertions for player1 (correct answer) + assert_equal 4, @player1.current_streak # Streak remains unchanged + + # Assertions for player2 (incorrect answer) + assert_equal 0, @player2.current_streak # Streak reset to 0 + + # Assertions for player3 (no answer) + assert_equal 0, @player3.current_streak # Streak reset to 0 + end + end diff --git a/test/models/player_answer_test.rb b/test/models/player_answer_test.rb index 4b9ec2d..675023b 100644 --- a/test/models/player_answer_test.rb +++ b/test/models/player_answer_test.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # correct :boolean not null +# time_taken :integer # created_at :datetime not null # updated_at :datetime not null # answer_id :integer not null diff --git a/test/models/question_test.rb b/test/models/question_test.rb index ecdc9bb..4a0b7b0 100644 --- a/test/models/question_test.rb +++ b/test/models/question_test.rb @@ -5,7 +5,7 @@ # Table name: questions # # id :integer not null, primary key -# duration :integer default(120), not null +# duration :integer default(40), not null # points :integer default(1) # position :integer # text :text @@ -50,32 +50,32 @@ def setup end test "validates points within range" do - question = Question.new(text: "Sample Question", quiz: quizzes(:one), duration: 120) + question = Question.new(text: "Sample Question", quiz: quizzes(:one)) question.points = 0 assert_not question.valid? assert_includes question.errors[:points], "must be greater than 0" - question.points = 101 + question.points = 11 assert_not question.valid? - assert_includes question.errors[:points], "must be less than or equal to 100" + assert_includes question.errors[:points], "must be less than or equal to 10" - question.points = 50 + question.points = 5 assert question.valid? end test "validates duration within range" do question = Question.new(text: "Sample Question", quiz: quizzes(:one), points: 10) - question.duration = 29 + question.duration = 9 assert_not question.valid? - assert_includes question.errors[:duration], "must be greater than 30" + assert_includes question.errors[:duration], "must be greater than or equal to 10" - question.duration = 241 + question.duration = 101 assert_not question.valid? - assert_includes question.errors[:duration], "must be less than or equal to 240" + assert_includes question.errors[:duration], "must be less than or equal to 100" - question.duration = 120 + question.duration = 90 assert question.valid? end diff --git a/test/models/quiz_test.rb b/test/models/quiz_test.rb index 13d6deb..96d573b 100644 --- a/test/models/quiz_test.rb +++ b/test/models/quiz_test.rb @@ -35,7 +35,7 @@ def setup assert_not @quiz.valid? end - test "playable? should return true when quiz has questions, all have at least 2 answers, and all have correct answers" do + test "playable? should return true when quiz has questions, all have at least 2 answers, and all have correct answers" do # rubocop:disable Layout/LineLength @quiz = Quiz.new( title: "Example quiz 1", user: users(:one)