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)