mirror of
https://github.com/jcwimer/wrestlingApp
synced 2026-04-11 16:01:56 +00:00
New stats page, scoreboard, and live scores pages.
This commit is contained in:
110
test/channels/mat_scoreboard_channel_test.rb
Normal file
110
test/channels/mat_scoreboard_channel_test.rb
Normal file
@@ -0,0 +1,110 @@
|
||||
require "test_helper"
|
||||
|
||||
class MatScoreboardChannelTest < ActionCable::Channel::TestCase
|
||||
setup do
|
||||
@mat = mats(:one)
|
||||
@match = matches(:tournament_1_bout_1000)
|
||||
@alternate_match = matches(:tournament_1_bout_1001)
|
||||
Rails.cache.clear
|
||||
@mat.update!(queue1: @match.id, queue2: @alternate_match.id, queue3: nil, queue4: nil)
|
||||
@mat.set_selected_scoreboard_match!(@match)
|
||||
@mat.set_last_match_result!("106 lbs - Example Winner Decision Example Loser 3-1")
|
||||
end
|
||||
|
||||
test "subscribes to a valid mat stream and transmits scoreboard payload" do
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert subscription.confirmed?
|
||||
assert_has_stream_for @mat
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => @match.bout_number,
|
||||
"queue1_match_id" => @match.id,
|
||||
"selected_bout_number" => @match.bout_number,
|
||||
"selected_match_id" => @match.id,
|
||||
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
|
||||
test "rejects subscription for an invalid mat" do
|
||||
subscribe(mat_id: -1)
|
||||
|
||||
assert subscription.rejected?
|
||||
end
|
||||
|
||||
test "transmits payload with queue1 and no selected match" do
|
||||
@mat.set_selected_scoreboard_match!(nil)
|
||||
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => @match.bout_number,
|
||||
"queue1_match_id" => @match.id,
|
||||
"selected_bout_number" => nil,
|
||||
"selected_match_id" => nil,
|
||||
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
|
||||
test "transmits payload when selected match differs from queue1" do
|
||||
@mat.set_selected_scoreboard_match!(@alternate_match)
|
||||
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => @match.bout_number,
|
||||
"queue1_match_id" => @match.id,
|
||||
"selected_bout_number" => @alternate_match.bout_number,
|
||||
"selected_match_id" => @alternate_match.id,
|
||||
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
|
||||
test "transmits payload when no queue1 match exists" do
|
||||
@mat.update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
|
||||
@mat.set_selected_scoreboard_match!(nil)
|
||||
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => nil,
|
||||
"queue1_match_id" => nil,
|
||||
"selected_bout_number" => nil,
|
||||
"selected_match_id" => nil,
|
||||
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
|
||||
test "transmits payload with blank last match result" do
|
||||
@mat.set_last_match_result!(nil)
|
||||
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => @match.bout_number,
|
||||
"queue1_match_id" => @match.id,
|
||||
"selected_bout_number" => @match.bout_number,
|
||||
"selected_match_id" => @match.id,
|
||||
"last_match_result" => nil
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,152 @@
|
||||
require "test_helper"
|
||||
|
||||
class MatchChannelTest < ActionCable::Channel::TestCase
|
||||
# test "subscribes" do
|
||||
# subscribe
|
||||
# assert subscription.confirmed?
|
||||
# end
|
||||
setup do
|
||||
@match = matches(:tournament_1_bout_1000)
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
test "subscribes to a valid match stream" do
|
||||
subscribe(match_id: @match.id)
|
||||
|
||||
assert subscription.confirmed?
|
||||
assert_has_stream_for @match
|
||||
end
|
||||
|
||||
test "invalid match subscription confirms but does not stream" do
|
||||
subscribe(match_id: -1)
|
||||
|
||||
assert subscription.confirmed?
|
||||
assert_empty subscription.streams
|
||||
end
|
||||
|
||||
test "send_stat updates the match and broadcasts stats" do
|
||||
subscribe(match_id: @match.id)
|
||||
|
||||
assert_broadcast_on(@match, { w1_stat: "T3", w2_stat: "E1" }) do
|
||||
perform :send_stat, {
|
||||
new_w1_stat: "T3",
|
||||
new_w2_stat: "E1"
|
||||
}
|
||||
end
|
||||
|
||||
@match.reload
|
||||
assert_equal "T3", @match.w1_stat
|
||||
assert_equal "E1", @match.w2_stat
|
||||
end
|
||||
|
||||
test "send_stat updates only w1 stat when only w1 is provided" do
|
||||
subscribe(match_id: @match.id)
|
||||
|
||||
assert_broadcast_on(@match, { w1_stat: "T3", w2_stat: nil }.compact) do
|
||||
perform :send_stat, { new_w1_stat: "T3" }
|
||||
end
|
||||
|
||||
@match.reload
|
||||
assert_equal "T3", @match.w1_stat
|
||||
assert_nil @match.w2_stat
|
||||
end
|
||||
|
||||
test "send_stat updates only w2 stat when only w2 is provided" do
|
||||
subscribe(match_id: @match.id)
|
||||
|
||||
assert_broadcast_on(@match, { w1_stat: nil, w2_stat: "E1" }.compact) do
|
||||
perform :send_stat, { new_w2_stat: "E1" }
|
||||
end
|
||||
|
||||
@match.reload
|
||||
assert_nil @match.w1_stat
|
||||
assert_equal "E1", @match.w2_stat
|
||||
end
|
||||
|
||||
test "send_stat with empty payload does not update or broadcast" do
|
||||
subscribe(match_id: @match.id)
|
||||
stream = MatchChannel.broadcasting_for(@match)
|
||||
ActionCable.server.pubsub.broadcasts(stream).clear
|
||||
|
||||
perform :send_stat, {}
|
||||
|
||||
@match.reload
|
||||
assert_nil @match.w1_stat
|
||||
assert_nil @match.w2_stat
|
||||
assert_empty ActionCable.server.pubsub.broadcasts(stream)
|
||||
end
|
||||
|
||||
test "send_scoreboard caches and broadcasts scoreboard state" do
|
||||
subscribe(match_id: @match.id)
|
||||
scoreboard_state = {
|
||||
"participantScores" => { "w1" => 2, "w2" => 0 },
|
||||
"metadata" => { "boutNumber" => @match.bout_number }
|
||||
}
|
||||
|
||||
assert_broadcast_on(@match, { scoreboard_state: scoreboard_state }) do
|
||||
perform :send_scoreboard, { scoreboard_state: scoreboard_state }
|
||||
end
|
||||
|
||||
cached_state = Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
|
||||
assert_equal scoreboard_state, cached_state
|
||||
end
|
||||
|
||||
test "send_scoreboard with blank payload does not cache or broadcast" do
|
||||
subscribe(match_id: @match.id)
|
||||
stream = MatchChannel.broadcasting_for(@match)
|
||||
ActionCable.server.pubsub.broadcasts(stream).clear
|
||||
|
||||
perform :send_scoreboard, { scoreboard_state: nil }
|
||||
|
||||
assert_nil Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
|
||||
assert_empty ActionCable.server.pubsub.broadcasts(stream)
|
||||
end
|
||||
|
||||
test "request_sync transmits match data and cached scoreboard state" do
|
||||
@match.update!(
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
)
|
||||
scoreboard_state = {
|
||||
"participantScores" => { "w1" => 3, "w2" => 1 },
|
||||
"metadata" => { "boutNumber" => @match.bout_number }
|
||||
}
|
||||
Rails.cache.write(
|
||||
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state",
|
||||
scoreboard_state
|
||||
)
|
||||
|
||||
subscribe(match_id: @match.id)
|
||||
perform :request_sync
|
||||
|
||||
assert_equal({
|
||||
"w1_stat" => "T3",
|
||||
"w2_stat" => "E1",
|
||||
"score" => "3-1",
|
||||
"win_type" => "Decision",
|
||||
"winner_name" => @match.wrestler1.name,
|
||||
"winner_id" => @match.w1,
|
||||
"finished" => 1,
|
||||
"scoreboard_state" => scoreboard_state
|
||||
}, transmissions.last)
|
||||
end
|
||||
|
||||
test "request_sync transmits unfinished match data without scoreboard cache" do
|
||||
@match.update!(
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: nil,
|
||||
win_type: nil,
|
||||
winner_id: nil,
|
||||
finished: nil
|
||||
)
|
||||
|
||||
subscribe(match_id: @match.id)
|
||||
perform :request_sync
|
||||
|
||||
assert_equal({
|
||||
"w1_stat" => "T3",
|
||||
"w2_stat" => "E1"
|
||||
}, transmissions.last)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
require 'test_helper'
|
||||
require "json"
|
||||
|
||||
class MatchesControllerTest < ActionController::TestCase
|
||||
# Remove Devise helpers since we're no longer using Devise
|
||||
# include Devise::Test::ControllerHelpers # Needed to sign in
|
||||
include ActionView::Helpers::DateHelper # Needed for time ago in words
|
||||
include ActionCable::TestHelper
|
||||
|
||||
setup do
|
||||
@tournament = Tournament.find(1)
|
||||
@@ -34,6 +36,18 @@ class MatchesControllerTest < ActionController::TestCase
|
||||
get :stat, params: { id: @match.id }
|
||||
end
|
||||
|
||||
def get_state
|
||||
get :state, params: { id: @match.id }
|
||||
end
|
||||
|
||||
def get_state_with_params(extra_params = {})
|
||||
get :state, params: { id: @match.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def get_spectate(extra_params = {})
|
||||
get :spectate, params: { id: @match.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def get_edit_assignment(extra_params = {})
|
||||
get :edit_assignment, params: { id: @match.id }.merge(extra_params)
|
||||
end
|
||||
@@ -106,11 +120,44 @@ class MatchesControllerTest < ActionController::TestCase
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user should not get state match page if not owner" do
|
||||
sign_in_non_owner
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not get state match page if not owner" do
|
||||
sign_in_school_delegate
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not get stat match page" do
|
||||
get_stat
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not get state match page" do
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "valid school permission key cannot get state match page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_state_with_params(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot get state match page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_state_with_params(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "non logged in user should get post update match" do
|
||||
post_update
|
||||
assert_redirected_to '/static_pages/not_allowed'
|
||||
@@ -139,6 +186,202 @@ class MatchesControllerTest < ActionController::TestCase
|
||||
get_stat
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner should get state match page" do
|
||||
sign_in_owner
|
||||
get_state
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should get state match page" do
|
||||
sign_in_tournament_delegate
|
||||
get_state
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in school delegate cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
sign_in_school_delegate
|
||||
get_spectate
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
sign_in_non_owner
|
||||
get_spectate
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
sign_in_tournament_delegate
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
sign_in_owner
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "non logged in user cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
get_spectate
|
||||
redirect
|
||||
end
|
||||
|
||||
test "valid school permission key cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_spectate(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_spectate(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "logged in school delegate can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
sign_in_school_delegate
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in user can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
sign_in_non_owner
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
sign_in_tournament_delegate
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
sign_in_owner
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "non logged in user can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "spectate renders embedded scoreboard when match is unfinished" do
|
||||
@tournament.update!(is_public: true)
|
||||
@match.update!(finished: nil)
|
||||
|
||||
get_spectate
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "data-match-spectate-target=\"scoreboardContainer\""
|
||||
assert_includes response.body, "data-controller=\"match-scoreboard\""
|
||||
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"websocket\""
|
||||
assert_includes response.body, "data-match-scoreboard-display-mode-value=\"embedded\""
|
||||
assert_includes response.body, "data-match-scoreboard-target=\"greenTimerIndicator\""
|
||||
assert_includes response.body, "data-match-scoreboard-target=\"redTimerIndicator\""
|
||||
end
|
||||
|
||||
test "spectate hides embedded scoreboard when match is finished" do
|
||||
@tournament.update!(is_public: true)
|
||||
@match.update!(finished: 1, winner_id: @match.w1, win_type: "Decision", score: "3-1")
|
||||
|
||||
get_spectate
|
||||
|
||||
assert_response :success
|
||||
assert_not_includes response.body, "data-match-spectate-target=\"scoreboardContainer\""
|
||||
end
|
||||
|
||||
test "posting a match update from match state redirects to all matches" do
|
||||
sign_in_owner
|
||||
get :state, params: { id: @match.id }
|
||||
patch :update, params: { id: @match.id, match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 } }
|
||||
|
||||
assert_redirected_to "/tournaments/#{@tournament.id}/matches"
|
||||
end
|
||||
|
||||
test "state page renders hidden stat fields for generated stats submission" do
|
||||
sign_in_owner
|
||||
get_state
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, 'name="match[w1_stat]"'
|
||||
assert_includes response.body, 'name="match[w2_stat]"'
|
||||
assert_includes response.body, 'data-match-state-target="w1StatField"'
|
||||
assert_includes response.body, 'data-match-state-target="w2StatField"'
|
||||
end
|
||||
|
||||
test "posting a match update from match state respects redirect_to param" do
|
||||
sign_in_owner
|
||||
get :state, params: { id: @match.id, redirect_to: "/mats/#{@match.mat_id}" }
|
||||
patch :update, params: {
|
||||
id: @match.id,
|
||||
redirect_to: "/mats/#{@match.mat_id}",
|
||||
match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 }
|
||||
}
|
||||
|
||||
assert_redirected_to "/mats/#{@match.mat_id}"
|
||||
end
|
||||
|
||||
test "posting a match update broadcasts match data and cached scoreboard state" do
|
||||
sign_in_owner
|
||||
scoreboard_state = {
|
||||
"participantScores" => { "w1" => 3, "w2" => 1 },
|
||||
"metadata" => { "boutNumber" => @match.bout_number }
|
||||
}
|
||||
stream = MatchChannel.broadcasting_for(@match)
|
||||
ActionCable.server.pubsub.clear
|
||||
ActionCable.server.pubsub.broadcasts(stream).clear
|
||||
Rails.cache.write(
|
||||
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state",
|
||||
scoreboard_state
|
||||
)
|
||||
|
||||
patch :update, params: {
|
||||
id: @match.id,
|
||||
match: {
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
}
|
||||
}
|
||||
|
||||
payload = JSON.parse(ActionCable.server.pubsub.broadcasts(stream).last)
|
||||
assert_equal(
|
||||
{
|
||||
"w1_stat" => "T3",
|
||||
"w2_stat" => "E1",
|
||||
"score" => "3-1",
|
||||
"win_type" => "Decision",
|
||||
"winner_id" => @match.w1,
|
||||
"winner_name" => @match.wrestler1.name,
|
||||
"finished" => 1,
|
||||
"scoreboard_state" => scoreboard_state
|
||||
},
|
||||
payload
|
||||
)
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should post update match" do
|
||||
sign_in_tournament_delegate
|
||||
|
||||
@@ -31,6 +31,30 @@ class MatsControllerTest < ActionController::TestCase
|
||||
get :show, params: { id: 1 }
|
||||
end
|
||||
|
||||
def get_state
|
||||
get :state, params: { id: @mat.id }
|
||||
end
|
||||
|
||||
def get_state_with_params(extra_params = {})
|
||||
get :state, params: { id: @mat.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def get_scoreboard
|
||||
get :scoreboard, params: { id: @mat.id }
|
||||
end
|
||||
|
||||
def get_scoreboard_with_params(extra_params = {})
|
||||
get :scoreboard, params: { id: @mat.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def post_select_match(extra_params = {})
|
||||
post :select_match, params: { id: @mat.id, match_id: @match.id, bout_number: @match.bout_number }.merge(extra_params)
|
||||
end
|
||||
|
||||
def post_select_match_with_params(extra_params = {})
|
||||
post :select_match, params: { id: @mat.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def post_update
|
||||
patch :update, params: { id: @mat.id, mat: {name: @mat.name, tournament_id: @mat.tournament_id} }
|
||||
end
|
||||
@@ -211,6 +235,18 @@ class MatsControllerTest < ActionController::TestCase
|
||||
show
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user should not get state mat page" do
|
||||
sign_in_non_owner
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user should not get scoreboard mat page" do
|
||||
sign_in_non_owner
|
||||
get_scoreboard
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not get show mat" do
|
||||
sign_in_school_delegate
|
||||
@@ -218,11 +254,116 @@ class MatsControllerTest < ActionController::TestCase
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not get state mat page" do
|
||||
sign_in_school_delegate
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not get scoreboard mat page" do
|
||||
sign_in_school_delegate
|
||||
get_scoreboard
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not get state mat page" do
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not get scoreboard mat page" do
|
||||
get_scoreboard
|
||||
redirect
|
||||
end
|
||||
|
||||
test "valid school permission key cannot get state mat page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_state_with_params(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot get state mat page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_state_with_params(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "valid school permission key cannot get scoreboard mat page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_scoreboard_with_params(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot get scoreboard mat page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_scoreboard_with_params(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "logged in user should not post select_match on mat" do
|
||||
sign_in_non_owner
|
||||
post_select_match
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not post select_match on mat" do
|
||||
sign_in_school_delegate
|
||||
post_select_match
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not post select_match on mat" do
|
||||
post_select_match
|
||||
redirect
|
||||
end
|
||||
|
||||
test "valid school permission key cannot post select_match on mat" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
post_select_match_with_params(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot post select_match on mat" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
post_select_match_with_params(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "logged in tournament owner should get show mat" do
|
||||
sign_in_owner
|
||||
show
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner should get state mat page" do
|
||||
sign_in_owner
|
||||
get_state
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner should get scoreboard mat page" do
|
||||
sign_in_owner
|
||||
get_scoreboard
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can post select_match on mat" do
|
||||
sign_in_owner
|
||||
post_select_match
|
||||
assert_response :no_content
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should get show mat" do
|
||||
sign_in_tournament_delegate
|
||||
@@ -230,6 +371,118 @@ class MatsControllerTest < ActionController::TestCase
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should get state mat page" do
|
||||
sign_in_tournament_delegate
|
||||
get_state
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should get scoreboard mat page" do
|
||||
sign_in_tournament_delegate
|
||||
get_scoreboard
|
||||
success
|
||||
end
|
||||
|
||||
test "state mat page renders queue buttons and mat-state controller" do
|
||||
sign_in_owner
|
||||
get_state
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "data-controller=\"mat-state\""
|
||||
assert_includes response.body, "Queue 1:"
|
||||
assert_includes response.body, "Queue 2:"
|
||||
assert_includes response.body, "Queue 3:"
|
||||
assert_includes response.body, "Queue 4:"
|
||||
end
|
||||
|
||||
test "scoreboard mat page renders match-scoreboard controller" do
|
||||
sign_in_owner
|
||||
get_scoreboard_with_params(print: true)
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "data-controller=\"match-scoreboard\""
|
||||
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"localstorage\""
|
||||
end
|
||||
|
||||
test "scoreboard mat page uses selected scoreboard match as initial bout" do
|
||||
sign_in_owner
|
||||
alternate_match = @mat.queue2_match
|
||||
if alternate_match.nil?
|
||||
alternate_match = @tournament.matches.where(mat_id: nil).first
|
||||
@mat.assign_match_to_queue!(alternate_match, 2)
|
||||
alternate_match = @mat.reload.queue2_match
|
||||
end
|
||||
@mat.set_selected_scoreboard_match!(alternate_match)
|
||||
|
||||
get_scoreboard
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "data-match-scoreboard-initial-bout-number-value=\"#{alternate_match.bout_number}\""
|
||||
end
|
||||
|
||||
test "state mat page renders no matches assigned when queue is empty" do
|
||||
sign_in_owner
|
||||
@mat.clear_queue!
|
||||
|
||||
get_state
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "No matches assigned to this mat."
|
||||
end
|
||||
|
||||
test "posting a match update from mat state redirects back to mat state" do
|
||||
sign_in_owner
|
||||
get :state, params: { id: @mat.id, bout_number: @match.bout_number }
|
||||
|
||||
old_controller = @controller
|
||||
@controller = MatchesController.new
|
||||
patch :update, params: { id: @match.id, match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 } }
|
||||
@controller = old_controller
|
||||
|
||||
assert_redirected_to "/mats/#{@mat.id}/state"
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can post select_match on mat" do
|
||||
sign_in_tournament_delegate
|
||||
post_select_match
|
||||
assert_response :no_content
|
||||
end
|
||||
|
||||
test "select_match updates selected scoreboard match" do
|
||||
sign_in_owner
|
||||
alternate_match = @mat.queue2_match
|
||||
if alternate_match.nil?
|
||||
alternate_match = @tournament.matches.where(mat_id: nil).first
|
||||
@mat.assign_match_to_queue!(alternate_match, 2)
|
||||
alternate_match = @mat.reload.queue2_match
|
||||
end
|
||||
|
||||
post :select_match, params: { id: @mat.id, match_id: alternate_match.id, bout_number: alternate_match.bout_number }
|
||||
|
||||
assert_response :no_content
|
||||
assert_equal alternate_match.id, @mat.selected_scoreboard_match&.id
|
||||
end
|
||||
|
||||
test "select_match updates last match result without changing selected match" do
|
||||
sign_in_owner
|
||||
@mat.set_selected_scoreboard_match!(@match)
|
||||
|
||||
post :select_match, params: { id: @mat.id, last_match_result: "106 lbs - Winner Decision Loser 3-1" }
|
||||
|
||||
assert_response :no_content
|
||||
assert_equal @match.id, @mat.selected_scoreboard_match&.id
|
||||
assert_equal "106 lbs - Winner Decision Loser 3-1", @mat.last_match_result_text
|
||||
end
|
||||
|
||||
test "select_match returns unprocessable entity for a non queued match without last result" do
|
||||
sign_in_owner
|
||||
non_queued_match = @tournament.matches.where(mat_id: nil).first
|
||||
|
||||
post :select_match, params: { id: @mat.id, match_id: non_queued_match.id, bout_number: non_queued_match.bout_number }
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "ads are hidden on mat show" do
|
||||
sign_in_owner
|
||||
show
|
||||
|
||||
@@ -28,6 +28,10 @@ class TournamentsControllerTest < ActionController::TestCase
|
||||
get :up_matches, params: { id: 1 }
|
||||
end
|
||||
|
||||
def get_live_scores
|
||||
get :live_scores, params: { id: 1 }
|
||||
end
|
||||
|
||||
def get_qrcode(params = {})
|
||||
get :qrcode, params: { id: 1 }.merge(params)
|
||||
end
|
||||
@@ -591,6 +595,116 @@ class TournamentsControllerTest < ActionController::TestCase
|
||||
end
|
||||
# END UP MATCHES PAGE PERMISSIONS
|
||||
|
||||
# LIVE SCORES PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC
|
||||
test "logged in school delegate cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
sign_in_school_delegate
|
||||
get_live_scores
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
sign_in_non_owner
|
||||
get_live_scores
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
sign_in_delegate
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
sign_in_owner
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "non logged in user cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
get_live_scores
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user with valid school permission key cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
@school.update(permission_key: "valid-key")
|
||||
get :live_scores, params: { id: @tournament.id, school_permission_key: "valid-key" }
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user with invalid school permission key cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
@school.update(permission_key: "valid-key")
|
||||
get :live_scores, params: { id: @tournament.id, school_permission_key: "invalid-key" }
|
||||
redirect
|
||||
end
|
||||
|
||||
# LIVE SCORES PAGE PERMISSIONS WHEN TOURNAMENT IS PUBLIC
|
||||
test "logged in school delegate can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
sign_in_school_delegate
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in user can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
sign_in_non_owner
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
sign_in_delegate
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
sign_in_owner
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "non logged in user can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "live scores page renders mat cards and scoreboard controller" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
get_live_scores
|
||||
success
|
||||
assert_includes response.body, "Live Scores"
|
||||
assert_includes response.body, "data-controller=\"match-scoreboard\""
|
||||
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"mat_websocket\""
|
||||
assert_includes response.body, "data-match-scoreboard-display-mode-value=\"embedded\""
|
||||
assert_includes response.body, "Last Match Result"
|
||||
assert_includes response.body, "Stats"
|
||||
assert_not_includes response.body, "Result</h4>"
|
||||
end
|
||||
|
||||
# ALL_RESULTS PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC
|
||||
test "logged in school delegate cannot get all_results page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
|
||||
85
test/integration/state_page_redirect_flow_test.rb
Normal file
85
test/integration/state_page_redirect_flow_test.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
require "test_helper"
|
||||
|
||||
class StatePageRedirectFlowTest < ActionDispatch::IntegrationTest
|
||||
fixtures :all
|
||||
|
||||
setup do
|
||||
@tournament = tournaments(:one)
|
||||
@mat = mats(:one)
|
||||
@match = matches(:tournament_1_bout_1000)
|
||||
@owner = users(:one)
|
||||
ensure_login_password(@owner)
|
||||
end
|
||||
|
||||
test "match state update redirects to all matches by default" do
|
||||
log_in(@owner)
|
||||
|
||||
get state_match_path(@match)
|
||||
assert_response :success
|
||||
|
||||
patch match_path(@match), params: {
|
||||
match: {
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to "/tournaments/#{@tournament.id}/matches"
|
||||
end
|
||||
|
||||
test "match state update respects redirect_to param through request flow" do
|
||||
log_in(@owner)
|
||||
|
||||
get state_match_path(@match), params: { redirect_to: mat_path(@mat) }
|
||||
assert_response :success
|
||||
|
||||
patch match_path(@match), params: {
|
||||
redirect_to: mat_path(@mat),
|
||||
match: {
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to mat_path(@mat)
|
||||
end
|
||||
|
||||
test "mat state update redirects back to mat state through request flow" do
|
||||
log_in(@owner)
|
||||
|
||||
get state_mat_path(@mat), params: { bout_number: @match.bout_number }
|
||||
assert_response :success
|
||||
|
||||
patch match_path(@match), params: {
|
||||
match: {
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to state_mat_path(@mat)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_login_password(user)
|
||||
return if user.password_digest.present?
|
||||
|
||||
user.update_column(:password_digest, BCrypt::Password.create("password"))
|
||||
end
|
||||
|
||||
def log_in(user)
|
||||
post login_path, params: {
|
||||
session: {
|
||||
email: user.email,
|
||||
password: "password"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
177
test/javascript/controllers/mat_state_controller.test.js
Normal file
177
test/javascript/controllers/mat_state_controller.test.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatStateController from "../../../app/assets/javascripts/controllers/mat_state_controller.js"
|
||||
|
||||
class FakeFormElement {}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatStateController()
|
||||
controller.element = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
controller.tournamentIdValue = 8
|
||||
controller.matIdValue = 3
|
||||
controller.boutNumberValue = 1001
|
||||
controller.matchIdValue = 22
|
||||
controller.selectMatchUrlValue = "/mats/3/select_match"
|
||||
controller.hasSelectMatchUrlValue = true
|
||||
controller.hasWeightLabelValue = true
|
||||
controller.weightLabelValue = "106"
|
||||
controller.w1IdValue = 11
|
||||
controller.w2IdValue = 12
|
||||
controller.w1NameValue = "Alpha"
|
||||
controller.w2NameValue = "Bravo"
|
||||
controller.w1SchoolValue = "School A"
|
||||
controller.w2SchoolValue = "School B"
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("mat state controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
global.HTMLFormElement = FakeFormElement
|
||||
global.window = {
|
||||
localStorage: {
|
||||
setItem: vi.fn(),
|
||||
getItem: vi.fn(() => ""),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
}
|
||||
global.document = {
|
||||
querySelector: vi.fn(() => ({ content: "csrf-token" }))
|
||||
}
|
||||
global.fetch = vi.fn(() => Promise.resolve())
|
||||
})
|
||||
|
||||
it("connect saves and broadcasts the selected bout and binds submit handling", () => {
|
||||
const controller = buildController()
|
||||
controller.saveSelectedBout = vi.fn()
|
||||
controller.broadcastSelectedBout = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.saveSelectedBout).toHaveBeenCalledTimes(1)
|
||||
expect(controller.broadcastSelectedBout).toHaveBeenCalledTimes(1)
|
||||
expect(controller.element.addEventListener).toHaveBeenCalledWith("submit", controller.boundHandleSubmit)
|
||||
})
|
||||
|
||||
it("saves the selected bout in tournament-scoped localStorage", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.saveSelectedBout()
|
||||
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledTimes(1)
|
||||
const [key, value] = window.localStorage.setItem.mock.calls[0]
|
||||
expect(key).toBe("mat-selected-bout:8:3")
|
||||
expect(JSON.parse(value)).toMatchObject({
|
||||
boutNumber: 1001,
|
||||
matchId: 22
|
||||
})
|
||||
})
|
||||
|
||||
it("broadcasts the selected bout with the last saved result", () => {
|
||||
const controller = buildController()
|
||||
controller.loadLastMatchResult = vi.fn(() => "Last result")
|
||||
|
||||
controller.broadcastSelectedBout()
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetch.mock.calls[0]
|
||||
expect(url).toBe("/mats/3/select_match")
|
||||
expect(options.method).toBe("POST")
|
||||
expect(options.body.get("match_id")).toBe("22")
|
||||
expect(options.body.get("bout_number")).toBe("1001")
|
||||
expect(options.body.get("last_match_result")).toBe("Last result")
|
||||
})
|
||||
|
||||
it("builds the last match result string from the results form", () => {
|
||||
const controller = buildController()
|
||||
const values = {
|
||||
"#match_winner_id": "11",
|
||||
"#match_win_type": "Pin",
|
||||
"#final-score-field": "01:09"
|
||||
}
|
||||
const form = new FakeFormElement()
|
||||
form.querySelector = vi.fn((selector) => ({ value: values[selector] }))
|
||||
|
||||
expect(controller.buildLastMatchResult(form)).toBe(
|
||||
"106 lbs - Alpha (School A) Pin Bravo (School B) 01:09"
|
||||
)
|
||||
})
|
||||
|
||||
it("handleSubmit saves and broadcasts the last match result", () => {
|
||||
const controller = buildController()
|
||||
const form = new FakeFormElement()
|
||||
controller.buildLastMatchResult = vi.fn(() => "Result text")
|
||||
controller.saveLastMatchResult = vi.fn()
|
||||
controller.broadcastCurrentState = vi.fn()
|
||||
|
||||
controller.handleSubmit({ target: form })
|
||||
|
||||
expect(controller.saveLastMatchResult).toHaveBeenCalledWith("Result text")
|
||||
expect(controller.broadcastCurrentState).toHaveBeenCalledWith("Result text")
|
||||
})
|
||||
|
||||
it("broadcastCurrentState posts the selected match and latest result", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.broadcastCurrentState("Result text")
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetch.mock.calls[0]
|
||||
expect(url).toBe("/mats/3/select_match")
|
||||
expect(options.keepalive).toBe(true)
|
||||
expect(options.body.get("match_id")).toBe("22")
|
||||
expect(options.body.get("bout_number")).toBe("1001")
|
||||
expect(options.body.get("last_match_result")).toBe("Result text")
|
||||
})
|
||||
|
||||
it("does not write selected bout storage without a mat id", () => {
|
||||
const controller = buildController()
|
||||
controller.matIdValue = 0
|
||||
|
||||
controller.saveSelectedBout()
|
||||
|
||||
expect(window.localStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not broadcast selected bout without a select-match url", () => {
|
||||
const controller = buildController()
|
||||
controller.hasSelectMatchUrlValue = false
|
||||
controller.selectMatchUrlValue = ""
|
||||
|
||||
controller.broadcastSelectedBout()
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("saves and clears the last match result in localStorage", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.saveLastMatchResult("Result text")
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith("mat-last-match-result:8:3", "Result text")
|
||||
|
||||
controller.saveLastMatchResult("")
|
||||
expect(window.localStorage.removeItem).toHaveBeenCalledWith("mat-last-match-result:8:3")
|
||||
})
|
||||
|
||||
it("returns blank last match result when required form values are missing or unknown", () => {
|
||||
const controller = buildController()
|
||||
const form = new FakeFormElement()
|
||||
form.querySelector = vi.fn((selector) => {
|
||||
if (selector === "#match_winner_id") return { value: "" }
|
||||
if (selector === "#match_win_type") return { value: "Pin" }
|
||||
return { value: "01:09" }
|
||||
})
|
||||
|
||||
expect(controller.buildLastMatchResult(form)).toBe("")
|
||||
|
||||
form.querySelector = vi.fn((selector) => {
|
||||
if (selector === "#match_winner_id") return { value: "999" }
|
||||
if (selector === "#match_win_type") return { value: "Pin" }
|
||||
return { value: "01:09" }
|
||||
})
|
||||
|
||||
expect(controller.buildLastMatchResult(form)).toBe("")
|
||||
})
|
||||
})
|
||||
179
test/javascript/controllers/match_data_controller.test.js
Normal file
179
test/javascript/controllers/match_data_controller.test.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatchDataController from "../../../app/assets/javascripts/controllers/match_data_controller.js"
|
||||
|
||||
function makeTarget(value = "") {
|
||||
return {
|
||||
value,
|
||||
innerText: "",
|
||||
addEventListener: vi.fn(),
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchDataController()
|
||||
controller.tournamentIdValue = 8
|
||||
controller.boutNumberValue = 1001
|
||||
controller.matchIdValue = 22
|
||||
controller.w1StatTarget = makeTarget("Initial W1")
|
||||
controller.w2StatTarget = makeTarget("Initial W2")
|
||||
controller.statusIndicatorTarget = makeTarget()
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match data controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(Date.prototype, "toISOString").mockReturnValue("2026-04-10T00:00:00.000Z")
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
global.window = {
|
||||
App: null
|
||||
}
|
||||
global.document = {
|
||||
getElementById: vi.fn(() => null)
|
||||
}
|
||||
global.setTimeout = vi.fn((fn) => {
|
||||
fn()
|
||||
return 1
|
||||
})
|
||||
global.clearTimeout = vi.fn()
|
||||
global.setInterval = vi.fn(() => 123)
|
||||
global.clearInterval = vi.fn()
|
||||
})
|
||||
|
||||
it("connect initializes wrestler state, localStorage, textarea handlers, and subscription", () => {
|
||||
const controller = buildController()
|
||||
controller.initializeFromLocalStorage = vi.fn()
|
||||
controller.setupSubscription = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.w1.stats).toBe("Initial W1")
|
||||
expect(controller.w2.stats).toBe("Initial W2")
|
||||
expect(controller.w1StatTarget.addEventListener).toHaveBeenCalledWith("input", expect.any(Function))
|
||||
expect(controller.w2StatTarget.addEventListener).toHaveBeenCalledWith("input", expect.any(Function))
|
||||
expect(controller.initializeFromLocalStorage).toHaveBeenCalledTimes(1)
|
||||
expect(controller.setupSubscription).toHaveBeenCalledWith(22)
|
||||
})
|
||||
|
||||
it("generates tournament and bout scoped localStorage keys", () => {
|
||||
const controller = buildController()
|
||||
|
||||
expect(controller.generateKey("w1")).toBe("w1-8-1001")
|
||||
expect(controller.generateKey("w2")).toBe("w2-8-1001")
|
||||
})
|
||||
|
||||
it("updateStats updates textareas, localStorage, and sends websocket stat payloads", () => {
|
||||
const controller = buildController()
|
||||
controller.connect()
|
||||
controller.matchSubscription = { perform: vi.fn() }
|
||||
|
||||
controller.updateStats(controller.w1, "T3")
|
||||
|
||||
expect(controller.w1.stats).toBe("Initial W1T3 ")
|
||||
expect(controller.w1StatTarget.value).toBe("Initial W1T3 ")
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
"w1-8-1001",
|
||||
expect.stringContaining('"stats":"Initial W1T3 "')
|
||||
)
|
||||
expect(controller.matchSubscription.perform).toHaveBeenCalledWith("send_stat", {
|
||||
new_w1_stat: "Initial W1T3 "
|
||||
})
|
||||
})
|
||||
|
||||
it("textarea input saves local state and marks pending sync when disconnected", () => {
|
||||
const controller = buildController()
|
||||
controller.connect()
|
||||
controller.isConnected = false
|
||||
controller.matchSubscription = { perform: vi.fn() }
|
||||
|
||||
controller.handleTextAreaInput({ value: "Manual stat" }, controller.w2)
|
||||
|
||||
expect(controller.w2.stats).toBe("Manual stat")
|
||||
expect(controller.pendingLocalSync.w2).toBe(true)
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
"w2-8-1001",
|
||||
expect.stringContaining('"stats":"Manual stat"')
|
||||
)
|
||||
expect(controller.matchSubscription.perform).toHaveBeenCalledWith("send_stat", {
|
||||
new_w2_stat: "Manual stat"
|
||||
})
|
||||
})
|
||||
|
||||
it("loads persisted localStorage stats and timers into the textareas", () => {
|
||||
const controller = buildController()
|
||||
localStorage.getItem = vi.fn((key) => {
|
||||
if (key === "w1-8-1001") {
|
||||
return JSON.stringify({
|
||||
stats: "Saved W1",
|
||||
updated_at: "timestamp",
|
||||
timers: {
|
||||
injury: { time: 5, startTime: null, interval: null },
|
||||
blood: { time: 0, startTime: null, interval: null }
|
||||
}
|
||||
})
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.w1.stats).toBe("Saved W1")
|
||||
expect(controller.w1StatTarget.value).toBe("Saved W1")
|
||||
expect(controller.w1.timers.injury.time).toBe(5)
|
||||
})
|
||||
|
||||
it("subscription callbacks update status, request sync, and apply received stats", () => {
|
||||
const controller = buildController()
|
||||
const subscription = { perform: vi.fn(), unsubscribe: vi.fn() }
|
||||
let callbacks
|
||||
global.App = {
|
||||
cable: {
|
||||
subscriptions: {
|
||||
create: vi.fn((_identifier, receivedCallbacks) => {
|
||||
callbacks = receivedCallbacks
|
||||
return subscription
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
window.App = global.App
|
||||
|
||||
controller.connect()
|
||||
controller.w1.stats = "Local W1"
|
||||
controller.w2.stats = "Local W2"
|
||||
callbacks.connected()
|
||||
|
||||
expect(controller.isConnected).toBe(true)
|
||||
expect(controller.statusIndicatorTarget.innerText).toBe("Connected: Stats will update in real-time.")
|
||||
expect(subscription.perform).toHaveBeenCalledWith("send_stat", {
|
||||
new_w1_stat: "Local W1",
|
||||
new_w2_stat: "Local W2"
|
||||
})
|
||||
|
||||
controller.pendingLocalSync.w1 = false
|
||||
controller.pendingLocalSync.w2 = false
|
||||
callbacks.received({ w1_stat: "Remote W1", w2_stat: "Remote W2" })
|
||||
|
||||
expect(controller.w1StatTarget.value).toBe("Remote W1")
|
||||
expect(controller.w2StatTarget.value).toBe("Remote W2")
|
||||
|
||||
callbacks.disconnected()
|
||||
expect(controller.isConnected).toBe(false)
|
||||
expect(controller.statusIndicatorTarget.innerText).toBe("Disconnected: Stats updates paused.")
|
||||
})
|
||||
|
||||
it("setupSubscription reports websocket unavailable when ActionCable is missing", () => {
|
||||
const controller = buildController()
|
||||
controller.connect()
|
||||
|
||||
expect(controller.statusIndicatorTarget.innerText).toBe("Error: WebSockets unavailable. Stats won't update in real-time.")
|
||||
expect(controller.statusIndicatorTarget.classList.add).toHaveBeenCalledWith("alert-danger")
|
||||
})
|
||||
})
|
||||
269
test/javascript/controllers/match_score_controller.test.js
Normal file
269
test/javascript/controllers/match_score_controller.test.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatchScoreController from "../../../app/assets/javascripts/controllers/match_score_controller.js"
|
||||
|
||||
class FakeNode {
|
||||
constructor(tagName = "div") {
|
||||
this.tagName = tagName
|
||||
this.children = []
|
||||
this.value = ""
|
||||
this.innerHTML = ""
|
||||
this.innerText = ""
|
||||
this.id = ""
|
||||
this.placeholder = ""
|
||||
this.type = ""
|
||||
this.style = {}
|
||||
this.listeners = {}
|
||||
this.classList = {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
this.children.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this[name] = value
|
||||
}
|
||||
|
||||
addEventListener(eventName, callback) {
|
||||
this.listeners[eventName] = callback
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
if (selector === "input") return this.allInputs()[0] || null
|
||||
if (!selector.startsWith("#")) return null
|
||||
return this.findById(selector.slice(1))
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
if (selector !== "input") return []
|
||||
return this.allInputs()
|
||||
}
|
||||
|
||||
findById(id) {
|
||||
if (this.id === id) return this
|
||||
for (const child of this.children) {
|
||||
const match = child.findById?.(id)
|
||||
if (match) return match
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
allInputs() {
|
||||
const matches = this.tagName === "input" ? [this] : []
|
||||
for (const child of this.children) {
|
||||
matches.push(...(child.allInputs?.() || []))
|
||||
}
|
||||
return matches
|
||||
}
|
||||
}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchScoreController()
|
||||
controller.element = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
controller.winTypeTarget = { value: "Decision" }
|
||||
controller.winnerSelectTarget = { value: "", options: [{ value: "" }, { value: "11" }], selectedIndex: 1 }
|
||||
controller.overtimeSelectTarget = { value: "", options: [{ value: "" }, { value: "SV-1" }] }
|
||||
controller.submitButtonTarget = { disabled: false }
|
||||
controller.dynamicScoreInputTarget = new FakeNode("div")
|
||||
controller.finalScoreFieldTarget = { value: "" }
|
||||
controller.validationAlertsTarget = {
|
||||
innerHTML: "",
|
||||
style: {},
|
||||
classList: { add: vi.fn(), remove: vi.fn() }
|
||||
}
|
||||
controller.pinTimeTipTarget = { style: {} }
|
||||
controller.manualOverrideValue = false
|
||||
controller.finishedValue = false
|
||||
controller.winnerScoreValue = "0"
|
||||
controller.loserScoreValue = "0"
|
||||
controller.pinMinutesValue = "0"
|
||||
controller.pinSecondsValue = "00"
|
||||
controller.hasWinnerSelectTarget = true
|
||||
controller.hasOvertimeSelectTarget = true
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match score controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
global.document = {
|
||||
createElement: vi.fn((tagName) => new FakeNode(tagName))
|
||||
}
|
||||
})
|
||||
|
||||
it("connect binds manual-override listeners and initializes unfinished forms", () => {
|
||||
const controller = buildController()
|
||||
controller.updateScoreInput = vi.fn()
|
||||
controller.validateForm = vi.fn()
|
||||
vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => {
|
||||
fn()
|
||||
return 1
|
||||
})
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.element.addEventListener).toHaveBeenCalledWith("input", controller.boundMarkManualOverride)
|
||||
expect(controller.element.addEventListener).toHaveBeenCalledWith("change", controller.boundMarkManualOverride)
|
||||
expect(controller.updateScoreInput).toHaveBeenCalledTimes(1)
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("connect skips score initialization for finished forms", () => {
|
||||
const controller = buildController()
|
||||
controller.finishedValue = true
|
||||
controller.updateScoreInput = vi.fn()
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.updateScoreInput).not.toHaveBeenCalled()
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("applyDefaultResults fills derived defaults until the user overrides them", () => {
|
||||
const controller = buildController()
|
||||
controller.updateScoreInput = vi.fn()
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.applyDefaultResults({
|
||||
winnerId: 11,
|
||||
overtimeType: "SV-1",
|
||||
winnerScore: 5,
|
||||
loserScore: 2,
|
||||
pinMinutes: 1,
|
||||
pinSeconds: 9
|
||||
})
|
||||
|
||||
expect(controller.winnerSelectTarget.value).toBe("11")
|
||||
expect(controller.overtimeSelectTarget.value).toBe("SV-1")
|
||||
expect(controller.winnerScoreValue).toBe("5")
|
||||
expect(controller.loserScoreValue).toBe("2")
|
||||
expect(controller.pinMinutesValue).toBe("1")
|
||||
expect(controller.pinSecondsValue).toBe("09")
|
||||
expect(controller.updateScoreInput).toHaveBeenCalledTimes(1)
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("applyDefaultResults does nothing after manual override", () => {
|
||||
const controller = buildController()
|
||||
controller.manualOverrideValue = true
|
||||
controller.updateScoreInput = vi.fn()
|
||||
|
||||
controller.applyDefaultResults({ winnerId: 11, winnerScore: 5 })
|
||||
|
||||
expect(controller.winnerSelectTarget.value).toBe("")
|
||||
expect(controller.updateScoreInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("markManualOverride only reacts to trusted events", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.markManualOverride({ isTrusted: false })
|
||||
expect(controller.manualOverrideValue).toBe(false)
|
||||
|
||||
controller.markManualOverride({ isTrusted: true })
|
||||
expect(controller.manualOverrideValue).toBe(true)
|
||||
})
|
||||
|
||||
it("validateForm disables submit for invalid decision scores and enables it for valid ones", () => {
|
||||
const controller = buildController()
|
||||
controller.winTypeTarget.value = "Decision"
|
||||
controller.winnerSelectTarget.value = "11"
|
||||
controller.winnerScoreValue = "2"
|
||||
controller.loserScoreValue = "3"
|
||||
|
||||
expect(controller.validateForm()).toBe(false)
|
||||
expect(controller.submitButtonTarget.disabled).toBe(true)
|
||||
expect(controller.validationAlertsTarget.style.display).toBe("block")
|
||||
|
||||
controller.winnerScoreValue = "5"
|
||||
controller.loserScoreValue = "2"
|
||||
controller.validationAlertsTarget.style = {}
|
||||
|
||||
expect(controller.validateForm()).toBe(true)
|
||||
expect(controller.submitButtonTarget.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it("winnerChanged and winTypeChanged revalidate the form", () => {
|
||||
const controller = buildController()
|
||||
controller.updateScoreInput = vi.fn()
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.winTypeChanged()
|
||||
expect(controller.updateScoreInput).toHaveBeenCalledTimes(1)
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(1)
|
||||
|
||||
controller.winnerChanged()
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("updateScoreInput builds pin inputs and writes pin time score", () => {
|
||||
const controller = buildController()
|
||||
controller.winTypeTarget.value = "Pin"
|
||||
controller.pinMinutesValue = "1"
|
||||
controller.pinSecondsValue = "09"
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.updateScoreInput()
|
||||
|
||||
expect(controller.pinTimeTipTarget.style.display).toBe("block")
|
||||
expect(controller.dynamicScoreInputTarget.querySelector("#minutes").value).toBe("1")
|
||||
expect(controller.dynamicScoreInputTarget.querySelector("#seconds").value).toBe("09")
|
||||
expect(controller.finalScoreFieldTarget.value).toBe("01:09")
|
||||
})
|
||||
|
||||
it("updateScoreInput builds point inputs and writes point score", () => {
|
||||
const controller = buildController()
|
||||
controller.winTypeTarget.value = "Decision"
|
||||
controller.winnerScoreValue = "7"
|
||||
controller.loserScoreValue = "3"
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.updateScoreInput()
|
||||
|
||||
expect(controller.pinTimeTipTarget.style.display).toBe("none")
|
||||
expect(controller.dynamicScoreInputTarget.querySelector("#winner-score").value).toBe("7")
|
||||
expect(controller.dynamicScoreInputTarget.querySelector("#loser-score").value).toBe("3")
|
||||
expect(controller.finalScoreFieldTarget.value).toBe("7-3")
|
||||
})
|
||||
|
||||
it("updateScoreInput clears score for non-score win types", () => {
|
||||
const controller = buildController()
|
||||
controller.winTypeTarget.value = "Forfeit"
|
||||
controller.finalScoreFieldTarget.value = "7-3"
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.updateScoreInput()
|
||||
|
||||
expect(controller.pinTimeTipTarget.style.display).toBe("none")
|
||||
expect(controller.finalScoreFieldTarget.value).toBe("")
|
||||
expect(controller.dynamicScoreInputTarget.children.at(-1).innerText).toBe("No score required for Forfeit win type.")
|
||||
})
|
||||
|
||||
it("validateForm enforces major and tech fall score boundaries", () => {
|
||||
const controller = buildController()
|
||||
controller.winnerSelectTarget.value = "11"
|
||||
controller.winTypeTarget.value = "Decision"
|
||||
controller.winnerScoreValue = "10"
|
||||
controller.loserScoreValue = "2"
|
||||
|
||||
expect(controller.validateForm()).toBe(false)
|
||||
expect(controller.validationAlertsTarget.innerHTML).toContain("Major")
|
||||
|
||||
controller.winTypeTarget.value = "Tech Fall"
|
||||
controller.winnerScoreValue = "17"
|
||||
controller.loserScoreValue = "2"
|
||||
controller.validationAlertsTarget.innerHTML = ""
|
||||
controller.validationAlertsTarget.style = {}
|
||||
|
||||
expect(controller.validateForm()).toBe(true)
|
||||
expect(controller.submitButtonTarget.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
355
test/javascript/controllers/match_scoreboard_controller.test.js
Normal file
355
test/javascript/controllers/match_scoreboard_controller.test.js
Normal file
@@ -0,0 +1,355 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatchScoreboardController from "../../../app/assets/javascripts/controllers/match_scoreboard_controller.js"
|
||||
|
||||
function makeTarget() {
|
||||
return {
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
style: {}
|
||||
}
|
||||
}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchScoreboardController()
|
||||
|
||||
controller.sourceModeValue = "localstorage"
|
||||
controller.displayModeValue = "fullscreen"
|
||||
controller.initialBoutNumberValue = 1001
|
||||
controller.matchIdValue = 22
|
||||
controller.matIdValue = 3
|
||||
controller.tournamentIdValue = 8
|
||||
|
||||
controller.hasRedSectionTarget = true
|
||||
controller.hasCenterSectionTarget = true
|
||||
controller.hasGreenSectionTarget = true
|
||||
controller.hasEmptyStateTarget = true
|
||||
controller.hasRedNameTarget = true
|
||||
controller.hasRedSchoolTarget = true
|
||||
controller.hasRedScoreTarget = true
|
||||
controller.hasRedTimerIndicatorTarget = true
|
||||
controller.hasGreenNameTarget = true
|
||||
controller.hasGreenSchoolTarget = true
|
||||
controller.hasGreenScoreTarget = true
|
||||
controller.hasGreenTimerIndicatorTarget = true
|
||||
controller.hasClockTarget = true
|
||||
controller.hasPeriodLabelTarget = true
|
||||
controller.hasWeightLabelTarget = true
|
||||
controller.hasBoutLabelTarget = true
|
||||
controller.hasTimerBannerTarget = true
|
||||
controller.hasTimerBannerLabelTarget = true
|
||||
controller.hasTimerBannerClockTarget = true
|
||||
controller.hasRedStatsTarget = true
|
||||
controller.hasGreenStatsTarget = true
|
||||
controller.hasLastMatchResultTarget = true
|
||||
|
||||
controller.redSectionTarget = makeTarget()
|
||||
controller.centerSectionTarget = makeTarget()
|
||||
controller.greenSectionTarget = makeTarget()
|
||||
controller.emptyStateTarget = makeTarget()
|
||||
controller.redNameTarget = makeTarget()
|
||||
controller.redSchoolTarget = makeTarget()
|
||||
controller.redScoreTarget = makeTarget()
|
||||
controller.redTimerIndicatorTarget = makeTarget()
|
||||
controller.greenNameTarget = makeTarget()
|
||||
controller.greenSchoolTarget = makeTarget()
|
||||
controller.greenScoreTarget = makeTarget()
|
||||
controller.greenTimerIndicatorTarget = makeTarget()
|
||||
controller.clockTarget = makeTarget()
|
||||
controller.periodLabelTarget = makeTarget()
|
||||
controller.weightLabelTarget = makeTarget()
|
||||
controller.boutLabelTarget = makeTarget()
|
||||
controller.timerBannerTarget = makeTarget()
|
||||
controller.timerBannerLabelTarget = makeTarget()
|
||||
controller.timerBannerClockTarget = makeTarget()
|
||||
controller.redStatsTarget = makeTarget()
|
||||
controller.greenStatsTarget = makeTarget()
|
||||
controller.lastMatchResultTarget = makeTarget()
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match scoreboard controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_000)
|
||||
global.window = {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => null)
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
setInterval: vi.fn(() => 123),
|
||||
clearInterval: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
it("connects in localstorage mode using the extracted connection plan", () => {
|
||||
const controller = buildController()
|
||||
controller.setupMatSubscription = vi.fn()
|
||||
controller.setupMatchSubscription = vi.fn()
|
||||
controller.loadSelectedBoutNumber = vi.fn()
|
||||
controller.loadStateFromLocalStorage = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(window.addEventListener).toHaveBeenCalledWith("storage", controller.storageListener)
|
||||
expect(controller.loadSelectedBoutNumber).toHaveBeenCalledTimes(1)
|
||||
expect(controller.loadStateFromLocalStorage).toHaveBeenCalledTimes(1)
|
||||
expect(controller.setupMatSubscription).toHaveBeenCalledTimes(1)
|
||||
expect(controller.setupMatchSubscription).not.toHaveBeenCalled()
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("connects with populated localStorage state before any timer snapshot exists", () => {
|
||||
const controller = buildController()
|
||||
const persistedState = {
|
||||
metadata: {
|
||||
boutNumber: 1001,
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
w1Name: "Alpha",
|
||||
w2Name: "Bravo"
|
||||
},
|
||||
assignment: { w1: "green", w2: "red" },
|
||||
participantScores: { w1: 0, w2: 0 },
|
||||
phaseIndex: 0,
|
||||
clocksByPhase: {
|
||||
period_1: { remainingSeconds: 120, running: false, startedAt: null }
|
||||
},
|
||||
timers: {
|
||||
w1: { blood: { remainingSeconds: 300, running: false, startedAt: null } },
|
||||
w2: { blood: { remainingSeconds: 300, running: false, startedAt: null } }
|
||||
}
|
||||
}
|
||||
|
||||
window.localStorage.getItem = vi.fn((key) => {
|
||||
if (key === "match-state:8:1001") return JSON.stringify(persistedState)
|
||||
return null
|
||||
})
|
||||
controller.setupMatSubscription = vi.fn()
|
||||
|
||||
expect(() => controller.connect()).not.toThrow()
|
||||
expect(controller.previousTimerSnapshot).toEqual({
|
||||
"w1:blood": false,
|
||||
"w2:blood": false
|
||||
})
|
||||
controller.disconnect()
|
||||
})
|
||||
|
||||
it("renders populated scoreboard targets from state", () => {
|
||||
const controller = buildController()
|
||||
controller.currentBoutNumber = 1001
|
||||
controller.liveMatchData = { w1_stat: "T3", w2_stat: "E1" }
|
||||
controller.lastMatchResult = "Previous result"
|
||||
controller.state = {
|
||||
metadata: {
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
weightLabel: "106",
|
||||
w1Name: "Alpha",
|
||||
w2Name: "Bravo",
|
||||
w1School: "School A",
|
||||
w2School: "School B"
|
||||
},
|
||||
assignment: { w1: "green", w2: "red" },
|
||||
participantScores: { w1: 3, w2: 1 },
|
||||
phaseIndex: 0,
|
||||
clocksByPhase: {
|
||||
period_1: { remainingSeconds: 120, running: false, startedAt: null }
|
||||
},
|
||||
timers: { w1: {}, w2: {} }
|
||||
}
|
||||
controller.timerBannerState = null
|
||||
|
||||
controller.render()
|
||||
|
||||
expect(controller.emptyStateTarget.style.display).toBe("none")
|
||||
expect(controller.redNameTarget.textContent).toBe("Bravo")
|
||||
expect(controller.redSchoolTarget.textContent).toBe("School B")
|
||||
expect(controller.redScoreTarget.textContent).toBe("1")
|
||||
expect(controller.greenNameTarget.textContent).toBe("Alpha")
|
||||
expect(controller.greenSchoolTarget.textContent).toBe("School A")
|
||||
expect(controller.greenScoreTarget.textContent).toBe("3")
|
||||
expect(controller.clockTarget.textContent).toBe("2:00")
|
||||
expect(controller.periodLabelTarget.textContent).toBe("Period 1")
|
||||
expect(controller.weightLabelTarget.textContent).toBe("Weight 106")
|
||||
expect(controller.boutLabelTarget.textContent).toBe("Bout 1001")
|
||||
expect(controller.redStatsTarget.textContent).toBe("E1")
|
||||
expect(controller.greenStatsTarget.textContent).toBe("T3")
|
||||
expect(controller.lastMatchResultTarget.textContent).toBe("Previous result")
|
||||
expect(controller.redSectionTarget.style.background).toBe("#c91f1f")
|
||||
expect(controller.greenSectionTarget.style.background).toBe("#1cab2d")
|
||||
})
|
||||
|
||||
it("renders empty scoreboard targets when there is no match state", () => {
|
||||
const controller = buildController()
|
||||
controller.currentBoutNumber = 1005
|
||||
controller.lastMatchResult = "Last result"
|
||||
controller.state = null
|
||||
|
||||
controller.render()
|
||||
|
||||
expect(controller.emptyStateTarget.style.display).toBe("block")
|
||||
expect(controller.redNameTarget.textContent).toBe("NO MATCH")
|
||||
expect(controller.greenNameTarget.textContent).toBe("NO MATCH")
|
||||
expect(controller.clockTarget.textContent).toBe("-")
|
||||
expect(controller.periodLabelTarget.textContent).toBe("No Match")
|
||||
expect(controller.boutLabelTarget.textContent).toBe("Bout 1005")
|
||||
expect(controller.lastMatchResultTarget.textContent).toBe("Last result")
|
||||
expect(controller.redSectionTarget.style.background).toBe("#000")
|
||||
expect(controller.greenSectionTarget.style.background).toBe("#000")
|
||||
})
|
||||
|
||||
it("renders a visible timer banner when the match clock is not running", () => {
|
||||
const controller = buildController()
|
||||
controller.config = {
|
||||
phaseSequence: [{ key: "period_1", type: "period" }],
|
||||
timers: { blood: { label: "Blood", maxSeconds: 300 } }
|
||||
}
|
||||
controller.state = {
|
||||
phaseIndex: 0,
|
||||
metadata: { w1Name: "Alpha" },
|
||||
assignment: { w1: "green", w2: "red" },
|
||||
clocksByPhase: { period_1: { running: false, remainingSeconds: 120, startedAt: null } },
|
||||
timers: {
|
||||
w1: { blood: { running: true, remainingSeconds: 300, startedAt: 1_000 } },
|
||||
w2: {}
|
||||
}
|
||||
}
|
||||
controller.timerBannerState = { participantKey: "w1", timerKey: "blood", expiresAt: null }
|
||||
|
||||
controller.renderTimerBanner()
|
||||
|
||||
expect(controller.timerBannerTarget.style.display).toBe("block")
|
||||
expect(controller.timerBannerLabelTarget.textContent).toBe("Green Alpha Blood Running")
|
||||
expect(controller.timerBannerClockTarget.textContent).toBe("0:00")
|
||||
})
|
||||
|
||||
it("hides an expiring timer banner when the main clock is running", () => {
|
||||
const controller = buildController()
|
||||
controller.config = {
|
||||
phaseSequence: [{ key: "period_1", type: "period" }],
|
||||
timers: { blood: { label: "Blood", maxSeconds: 300 } }
|
||||
}
|
||||
controller.state = {
|
||||
phaseIndex: 0,
|
||||
metadata: { w1Name: "Alpha" },
|
||||
assignment: { w1: "green", w2: "red" },
|
||||
clocksByPhase: { period_1: { running: true, remainingSeconds: 120, startedAt: 1_000 } },
|
||||
timers: {
|
||||
w1: { blood: { running: false, remainingSeconds: 280, startedAt: null } },
|
||||
w2: {}
|
||||
}
|
||||
}
|
||||
controller.timerBannerState = { participantKey: "w1", timerKey: "blood", expiresAt: 5_000 }
|
||||
|
||||
controller.renderTimerBanner()
|
||||
|
||||
expect(controller.timerBannerState).toBe(null)
|
||||
expect(controller.timerBannerTarget.style.display).toBe("none")
|
||||
})
|
||||
|
||||
it("handles mat payload changes by switching match subscriptions and rendering", () => {
|
||||
const controller = buildController()
|
||||
controller.sourceModeValue = "mat_websocket"
|
||||
controller.currentQueueBoutNumber = 1001
|
||||
controller.currentBoutNumber = 1001
|
||||
controller.currentMatchId = 10
|
||||
controller.liveMatchData = { w1_stat: "Old" }
|
||||
controller.state = { metadata: { boutNumber: 1001 } }
|
||||
controller.setupMatchSubscription = vi.fn()
|
||||
controller.unsubscribeMatchSubscription = vi.fn()
|
||||
controller.loadSelectedBoutNumber = vi.fn()
|
||||
controller.loadStateFromLocalStorage = vi.fn()
|
||||
controller.resetTimerBannerState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.handleMatPayload({
|
||||
queue1_bout_number: 1001,
|
||||
queue1_match_id: 10,
|
||||
selected_bout_number: 1002,
|
||||
selected_match_id: 11,
|
||||
last_match_result: "Result"
|
||||
})
|
||||
|
||||
expect(controller.currentMatchId).toBe(11)
|
||||
expect(controller.currentBoutNumber).toBe(1002)
|
||||
expect(controller.lastMatchResult).toBe("Result")
|
||||
expect(controller.resetTimerBannerState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.setupMatchSubscription).toHaveBeenCalledWith(11)
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("loads selected mat payload state from localStorage when the selected-bout key is missing", () => {
|
||||
const controller = buildController()
|
||||
const selectedState = {
|
||||
metadata: {
|
||||
boutNumber: 1002,
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64"
|
||||
},
|
||||
matchResult: { finished: false }
|
||||
}
|
||||
|
||||
window.localStorage.getItem = vi.fn((key) => {
|
||||
if (key === "match-state:8:1002") return JSON.stringify(selectedState)
|
||||
return null
|
||||
})
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.handleMatPayload({
|
||||
queue1_bout_number: 1001,
|
||||
selected_bout_number: 1002,
|
||||
last_match_result: ""
|
||||
})
|
||||
|
||||
expect(controller.currentBoutNumber).toBe(1002)
|
||||
expect(controller.state).toEqual(selectedState)
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("reloads selected bout and local state on selected-bout storage changes", () => {
|
||||
const controller = buildController()
|
||||
controller.currentQueueBoutNumber = 1001
|
||||
controller.currentBoutNumber = 1001
|
||||
controller.loadSelectedBoutNumber = vi.fn()
|
||||
controller.loadStateFromLocalStorage = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.handleStorageChange({ key: "mat-selected-bout:8:3" })
|
||||
|
||||
expect(controller.loadSelectedBoutNumber).toHaveBeenCalledTimes(1)
|
||||
expect(controller.loadStateFromLocalStorage).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("applies match websocket payloads into live match data and scoreboard state", () => {
|
||||
const controller = buildController()
|
||||
controller.currentBoutNumber = 1001
|
||||
controller.liveMatchData = { w1_stat: "Old" }
|
||||
controller.state = null
|
||||
|
||||
controller.handleMatchPayload({
|
||||
scoreboard_state: {
|
||||
metadata: { boutNumber: 1002 },
|
||||
matchResult: { finished: true }
|
||||
},
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
finished: 1
|
||||
})
|
||||
|
||||
expect(controller.currentBoutNumber).toBe(1002)
|
||||
expect(controller.finished).toBe(true)
|
||||
expect(controller.liveMatchData).toEqual({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
finished: 1
|
||||
})
|
||||
expect(controller.state).toEqual({
|
||||
metadata: { boutNumber: 1002 },
|
||||
matchResult: { finished: true }
|
||||
})
|
||||
})
|
||||
})
|
||||
140
test/javascript/controllers/match_spectate_controller.test.js
Normal file
140
test/javascript/controllers/match_spectate_controller.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatchSpectateController from "../../../app/assets/javascripts/controllers/match_spectate_controller.js"
|
||||
|
||||
function makeTarget() {
|
||||
return {
|
||||
textContent: "",
|
||||
style: {},
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchSpectateController()
|
||||
controller.matchIdValue = 22
|
||||
controller.hasW1StatsTarget = true
|
||||
controller.hasW2StatsTarget = true
|
||||
controller.hasWinnerTarget = true
|
||||
controller.hasWinTypeTarget = true
|
||||
controller.hasScoreTarget = true
|
||||
controller.hasFinishedTarget = true
|
||||
controller.hasStatusIndicatorTarget = true
|
||||
controller.hasScoreboardContainerTarget = true
|
||||
controller.w1StatsTarget = makeTarget()
|
||||
controller.w2StatsTarget = makeTarget()
|
||||
controller.winnerTarget = makeTarget()
|
||||
controller.winTypeTarget = makeTarget()
|
||||
controller.scoreTarget = makeTarget()
|
||||
controller.finishedTarget = makeTarget()
|
||||
controller.statusIndicatorTarget = makeTarget()
|
||||
controller.scoreboardContainerTarget = makeTarget()
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match spectate controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete global.App
|
||||
})
|
||||
|
||||
it("connect subscribes to the match when a match id is present", () => {
|
||||
const controller = buildController()
|
||||
controller.setupSubscription = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.setupSubscription).toHaveBeenCalledWith(22)
|
||||
})
|
||||
|
||||
it("setupSubscription reports ActionCable missing", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.setupSubscription(22)
|
||||
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Error: AC Not Loaded")
|
||||
expect(controller.statusIndicatorTarget.classList.add).toHaveBeenCalledWith("alert-danger", "text-danger")
|
||||
})
|
||||
|
||||
it("subscription callbacks update status and request initial sync", () => {
|
||||
const controller = buildController()
|
||||
const subscription = { perform: vi.fn(), unsubscribe: vi.fn() }
|
||||
let callbacks
|
||||
global.App = {
|
||||
cable: {
|
||||
subscriptions: {
|
||||
create: vi.fn((_identifier, receivedCallbacks) => {
|
||||
callbacks = receivedCallbacks
|
||||
return subscription
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.setupSubscription(22)
|
||||
callbacks.initialized()
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Connecting to backend for live updates...")
|
||||
|
||||
callbacks.connected()
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Connected to backend for live updates...")
|
||||
expect(subscription.perform).toHaveBeenCalledWith("request_sync")
|
||||
|
||||
callbacks.disconnected()
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Disconnected from backend for live updates. Retrying...")
|
||||
|
||||
callbacks.rejected()
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Connection to backend rejected")
|
||||
expect(controller.matchSubscription).toBe(null)
|
||||
})
|
||||
|
||||
it("received websocket payloads update stats and result fields", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.updateDisplayElements({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_name: "Alpha",
|
||||
finished: 0
|
||||
})
|
||||
|
||||
expect(controller.w1StatsTarget.textContent).toBe("T3")
|
||||
expect(controller.w2StatsTarget.textContent).toBe("E1")
|
||||
expect(controller.scoreTarget.textContent).toBe("3-1")
|
||||
expect(controller.winTypeTarget.textContent).toBe("Decision")
|
||||
expect(controller.winnerTarget.textContent).toBe("Alpha")
|
||||
expect(controller.finishedTarget.textContent).toBe("No")
|
||||
expect(controller.scoreboardContainerTarget.style.display).toBe("block")
|
||||
})
|
||||
|
||||
it("finished websocket payload hides the embedded scoreboard", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.updateDisplayElements({
|
||||
winner_id: 11,
|
||||
score: "",
|
||||
win_type: "",
|
||||
finished: 1
|
||||
})
|
||||
|
||||
expect(controller.winnerTarget.textContent).toBe("ID: 11")
|
||||
expect(controller.scoreTarget.textContent).toBe("-")
|
||||
expect(controller.winTypeTarget.textContent).toBe("-")
|
||||
expect(controller.finishedTarget.textContent).toBe("Yes")
|
||||
expect(controller.scoreboardContainerTarget.style.display).toBe("none")
|
||||
})
|
||||
|
||||
it("disconnect unsubscribes from the match channel", () => {
|
||||
const controller = buildController()
|
||||
const subscription = { unsubscribe: vi.fn() }
|
||||
controller.matchSubscription = subscription
|
||||
|
||||
controller.disconnect()
|
||||
|
||||
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(controller.matchSubscription).toBe(null)
|
||||
})
|
||||
})
|
||||
315
test/javascript/controllers/match_state_controller.test.js
Normal file
315
test/javascript/controllers/match_state_controller.test.js
Normal file
@@ -0,0 +1,315 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
import MatchStateController from "../../../app/assets/javascripts/controllers/match_state_controller.js"
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchStateController()
|
||||
controller.element = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
querySelector: vi.fn(() => null)
|
||||
}
|
||||
controller.application = {
|
||||
getControllerForElementAndIdentifier: vi.fn()
|
||||
}
|
||||
controller.matchIdValue = 22
|
||||
controller.tournamentIdValue = 8
|
||||
controller.boutNumberValue = 1001
|
||||
controller.weightLabelValue = "106"
|
||||
controller.bracketPositionValue = "Bracket Round of 64"
|
||||
controller.rulesetValue = "folkstyle_usa"
|
||||
controller.w1IdValue = 11
|
||||
controller.w2IdValue = 12
|
||||
controller.w1NameValue = "Alpha"
|
||||
controller.w2NameValue = "Bravo"
|
||||
controller.w1SchoolValue = "School A"
|
||||
controller.w2SchoolValue = "School B"
|
||||
controller.hasW1StatFieldTarget = true
|
||||
controller.hasW2StatFieldTarget = true
|
||||
controller.w1StatFieldTarget = { value: "" }
|
||||
controller.w2StatFieldTarget = { value: "" }
|
||||
controller.hasMatchResultsPanelTarget = true
|
||||
controller.matchResultsPanelTarget = { querySelector: vi.fn(() => ({})) }
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match state controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
global.window = {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
},
|
||||
setInterval: vi.fn(() => 123),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout: vi.fn((fn) => {
|
||||
fn()
|
||||
return 1
|
||||
}),
|
||||
clearTimeout: vi.fn(),
|
||||
confirm: vi.fn(() => true)
|
||||
}
|
||||
})
|
||||
|
||||
it("connect initializes state, restores persistence, renders, and subscribes", () => {
|
||||
const controller = buildController()
|
||||
controller.initializeState = vi.fn()
|
||||
controller.loadPersistedState = vi.fn()
|
||||
controller.syncClockFromActivePhase = vi.fn()
|
||||
controller.hasRunningClockOrTimer = vi.fn(() => true)
|
||||
controller.startTicking = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
controller.setupSubscription = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.element.addEventListener).toHaveBeenCalledWith("click", controller.boundHandleClick)
|
||||
expect(controller.initializeState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.loadPersistedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.syncClockFromActivePhase).toHaveBeenCalledTimes(1)
|
||||
expect(controller.startTicking).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledWith({ rebuildControls: true })
|
||||
expect(controller.setupSubscription).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("applyAction recomputes and rerenders when a match action is accepted", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.recomputeDerivedState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.applyAction({
|
||||
dataset: {
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
}
|
||||
})
|
||||
|
||||
expect(controller.recomputeDerivedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledWith({ rebuildControls: true })
|
||||
})
|
||||
|
||||
it("applyChoice rerenders on defer and advances on a committed choice", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.state.phaseIndex = 1
|
||||
controller.recomputeDerivedState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
controller.advancePhase = vi.fn()
|
||||
|
||||
controller.applyChoice({
|
||||
dataset: {
|
||||
participantKey: "w1",
|
||||
choiceKey: "defer"
|
||||
}
|
||||
})
|
||||
|
||||
expect(controller.recomputeDerivedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledWith({ rebuildControls: true })
|
||||
expect(controller.advancePhase).not.toHaveBeenCalled()
|
||||
|
||||
controller.recomputeDerivedState.mockClear()
|
||||
controller.render.mockClear()
|
||||
|
||||
controller.applyChoice({
|
||||
dataset: {
|
||||
participantKey: "w1",
|
||||
choiceKey: "top"
|
||||
}
|
||||
})
|
||||
|
||||
expect(controller.advancePhase).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("updateStatFieldsAndBroadcast writes hidden fields and pushes both channel payloads", () => {
|
||||
const controller = buildController()
|
||||
controller.derivedStats = vi.fn(() => ({ w1: "Period 1: T3", w2: "Period 1: E1" }))
|
||||
controller.pushDerivedStatsToChannel = vi.fn()
|
||||
controller.pushScoreboardStateToChannel = vi.fn()
|
||||
|
||||
controller.updateStatFieldsAndBroadcast()
|
||||
|
||||
expect(controller.w1StatFieldTarget.value).toBe("Period 1: T3")
|
||||
expect(controller.w2StatFieldTarget.value).toBe("Period 1: E1")
|
||||
expect(controller.lastDerivedStats).toEqual({ w1: "Period 1: T3", w2: "Period 1: E1" })
|
||||
expect(controller.pushDerivedStatsToChannel).toHaveBeenCalledTimes(1)
|
||||
expect(controller.pushScoreboardStateToChannel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("pushes derived stats and scoreboard payloads through the match subscription with dedupe", () => {
|
||||
const controller = buildController()
|
||||
controller.matchSubscription = { perform: vi.fn() }
|
||||
controller.lastDerivedStats = { w1: "Period 1: T3", w2: "Period 1: E1" }
|
||||
controller.scoreboardStatePayload = vi.fn(() => ({ participantScores: { w1: 3, w2: 1 } }))
|
||||
|
||||
controller.pushDerivedStatsToChannel()
|
||||
controller.pushScoreboardStateToChannel()
|
||||
controller.pushDerivedStatsToChannel()
|
||||
controller.pushScoreboardStateToChannel()
|
||||
|
||||
expect(controller.matchSubscription.perform).toHaveBeenCalledTimes(2)
|
||||
expect(controller.matchSubscription.perform).toHaveBeenNthCalledWith(1, "send_stat", {
|
||||
new_w1_stat: "Period 1: T3",
|
||||
new_w2_stat: "Period 1: E1"
|
||||
})
|
||||
expect(controller.matchSubscription.perform).toHaveBeenNthCalledWith(2, "send_scoreboard", {
|
||||
scoreboard_state: { participantScores: { w1: 3, w2: 1 } }
|
||||
})
|
||||
})
|
||||
|
||||
it("starts, stops, and adjusts the active match clock", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.render = vi.fn()
|
||||
controller.startTicking = vi.fn()
|
||||
controller.stopTicking = vi.fn()
|
||||
|
||||
controller.startClock()
|
||||
expect(controller.activeClock().running).toBe(true)
|
||||
expect(controller.startTicking).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
|
||||
controller.stopClock()
|
||||
expect(controller.activeClock().running).toBe(false)
|
||||
expect(controller.stopTicking).toHaveBeenCalledTimes(1)
|
||||
|
||||
const beforeAdjustment = controller.activeClock().remainingSeconds
|
||||
controller.adjustClock(-1)
|
||||
expect(controller.activeClock().remainingSeconds).toBe(beforeAdjustment - 1)
|
||||
})
|
||||
|
||||
it("stopping an auxiliary timer records a timer-used event", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.state.timers.w1.blood.running = true
|
||||
controller.state.timers.w1.blood.startedAt = 1_000
|
||||
controller.state.timers.w1.blood.remainingSeconds = 300
|
||||
controller.render = vi.fn()
|
||||
vi.spyOn(Date, "now").mockReturnValue(6_000)
|
||||
|
||||
controller.stopAuxiliaryTimer("w1", "blood")
|
||||
|
||||
expect(controller.state.timers.w1.blood.running).toBe(false)
|
||||
expect(controller.state.events).toHaveLength(1)
|
||||
expect(controller.state.events[0]).toMatchObject({
|
||||
participantKey: "w1",
|
||||
actionKey: "timer_used_blood",
|
||||
elapsedSeconds: 5
|
||||
})
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("deletes, swaps events, and swaps whole phases through button dataset ids", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.state.events = [
|
||||
{
|
||||
id: 1,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 120,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 100,
|
||||
participantKey: "w2",
|
||||
actionKey: "escape_1"
|
||||
}
|
||||
]
|
||||
controller.recomputeDerivedState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.swapEvent({ dataset: { eventId: "1" } })
|
||||
expect(controller.state.events[0].participantKey).toBe("w2")
|
||||
|
||||
controller.swapPhase({ dataset: { phaseKey: "period_1" } })
|
||||
expect(controller.state.events.map((eventRecord) => eventRecord.participantKey)).toEqual(["w1", "w1"])
|
||||
|
||||
controller.deleteEvent({ dataset: { eventId: "2" } })
|
||||
expect(controller.state.events.map((eventRecord) => eventRecord.id)).toEqual([1])
|
||||
expect(controller.recomputeDerivedState).toHaveBeenCalledTimes(3)
|
||||
expect(controller.render).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("delegated click dispatches dynamic state buttons", () => {
|
||||
const controller = buildController()
|
||||
controller.applyAction = vi.fn()
|
||||
const button = {
|
||||
dataset: { matchStateButton: "score-action" }
|
||||
}
|
||||
|
||||
controller.handleDelegatedClick({
|
||||
target: {
|
||||
closest: vi.fn(() => button)
|
||||
}
|
||||
})
|
||||
|
||||
expect(controller.applyAction).toHaveBeenCalledWith(button)
|
||||
})
|
||||
|
||||
it("applyMatchResultDefaults forwards derived defaults to the nested match-score controller", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.state.participantScores = { w1: 4, w2: 2 }
|
||||
controller.currentPhase = vi.fn(() => controller.config.phaseSequence[0])
|
||||
controller.accumulatedMatchSeconds = vi.fn(() => 69)
|
||||
const nested = { applyDefaultResults: vi.fn() }
|
||||
controller.application.getControllerForElementAndIdentifier.mockReturnValue(nested)
|
||||
|
||||
controller.applyMatchResultDefaults()
|
||||
|
||||
expect(nested.applyDefaultResults).toHaveBeenCalledTimes(1)
|
||||
expect(nested.applyDefaultResults.mock.calls[0][0]).toMatchObject({
|
||||
winnerId: 11,
|
||||
winnerScore: 4,
|
||||
loserScore: 2
|
||||
})
|
||||
})
|
||||
|
||||
it("resetMatch reinitializes state and clears persistence when confirmed", () => {
|
||||
const controller = buildController()
|
||||
controller.initializeState = vi.fn()
|
||||
controller.syncClockFromActivePhase = vi.fn()
|
||||
controller.clearPersistedState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
controller.stopTicking = vi.fn()
|
||||
|
||||
controller.resetMatch()
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledTimes(1)
|
||||
expect(controller.stopTicking).toHaveBeenCalledTimes(1)
|
||||
expect(controller.initializeState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.syncClockFromActivePhase).toHaveBeenCalledTimes(1)
|
||||
expect(controller.clearPersistedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledWith({ rebuildControls: true })
|
||||
})
|
||||
|
||||
it("loadPersistedState restores saved state and clears invalid saved state", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.buildInitialState = vi.fn(() => ({ fresh: true }))
|
||||
controller.clearPersistedState = vi.fn()
|
||||
|
||||
window.localStorage.getItem.mockReturnValueOnce('{"participantScores":{"w1":3,"w2":1}}')
|
||||
controller.loadPersistedState()
|
||||
expect(controller.state.participantScores).toEqual({ w1: 3, w2: 1 })
|
||||
|
||||
window.localStorage.getItem.mockReturnValue("{bad-json")
|
||||
controller.loadPersistedState()
|
||||
expect(controller.clearPersistedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.state).toEqual({ fresh: true })
|
||||
})
|
||||
})
|
||||
435
test/javascript/match_state/engine.test.js
Normal file
435
test/javascript/match_state/engine.test.js
Normal file
@@ -0,0 +1,435 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import {
|
||||
accumulatedMatchSeconds,
|
||||
activeClockForPhase,
|
||||
adjustClockState,
|
||||
applyChoiceAction,
|
||||
applyMatchAction,
|
||||
buildInitialState,
|
||||
deleteEventFromState,
|
||||
derivedStats,
|
||||
hasRunningClockOrTimer,
|
||||
matchResultDefaults,
|
||||
moveToNextPhase,
|
||||
moveToPreviousPhase,
|
||||
recordProgressiveAction,
|
||||
recomputeDerivedState,
|
||||
scoreboardStatePayload,
|
||||
startAuxiliaryTimerState,
|
||||
startClockState,
|
||||
stopAuxiliaryTimerState,
|
||||
stopClockState,
|
||||
stopAllAuxiliaryTimers,
|
||||
swapEventParticipants,
|
||||
syncClockSnapshot,
|
||||
swapPhaseParticipants
|
||||
} from "match-state-engine"
|
||||
|
||||
function buildEvent(overrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 120,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3",
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe("match state engine", () => {
|
||||
it("replays takedown and escape into score and control", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
state.events = [
|
||||
buildEvent(),
|
||||
buildEvent({
|
||||
id: 2,
|
||||
participantKey: "w2",
|
||||
actionKey: "escape_1",
|
||||
clockSeconds: 80
|
||||
})
|
||||
]
|
||||
|
||||
recomputeDerivedState(config, state)
|
||||
|
||||
expect(state.participantScores).toEqual({ w1: 3, w2: 1 })
|
||||
expect(state.control).toBe("neutral")
|
||||
expect(state.displayControl).toBe("neutral")
|
||||
})
|
||||
|
||||
it("stores non defer choices and applies chosen starting control to later periods", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
state.phaseIndex = 2
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
phaseKey: "choice_1",
|
||||
phaseLabel: "Choice 1",
|
||||
clockSeconds: 0,
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_top"
|
||||
})
|
||||
]
|
||||
|
||||
recomputeDerivedState(config, state)
|
||||
|
||||
expect(state.selections.choice_1).toEqual({ participantKey: "w1", choiceKey: "top" })
|
||||
expect(state.control).toBe("w1_control")
|
||||
expect(state.displayControl).toBe("w1_control")
|
||||
})
|
||||
|
||||
it("ignores defer as a final selection", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
state.phaseIndex = 2
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
phaseKey: "choice_1",
|
||||
phaseLabel: "Choice 1",
|
||||
clockSeconds: 0,
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_defer"
|
||||
})
|
||||
]
|
||||
|
||||
recomputeDerivedState(config, state)
|
||||
|
||||
expect(state.selections).toEqual({})
|
||||
expect(state.control).toBe("neutral")
|
||||
expect(state.displayControl).toBe("neutral")
|
||||
})
|
||||
|
||||
it("derives legacy stats grouped by period", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const stats = derivedStats(config, [
|
||||
buildEvent(),
|
||||
buildEvent({
|
||||
id: 2,
|
||||
participantKey: "w2",
|
||||
actionKey: "escape_1",
|
||||
clockSeconds: 80
|
||||
}),
|
||||
buildEvent({
|
||||
id: 3,
|
||||
phaseKey: "choice_1",
|
||||
phaseLabel: "Choice 1",
|
||||
clockSeconds: 0,
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_defer"
|
||||
})
|
||||
])
|
||||
|
||||
expect(stats.w1).toBe("Period 1: T3\nChoice 1: |Deferred|")
|
||||
expect(stats.w2).toBe("Period 1: E1")
|
||||
})
|
||||
|
||||
it("derives accumulated match time from period clocks", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
state.phaseIndex = 2
|
||||
state.clocksByPhase.period_1.remainingSeconds = 42
|
||||
state.clocksByPhase.period_2.remainingSeconds = 75
|
||||
state.clocksByPhase.period_3.remainingSeconds = 120
|
||||
|
||||
const total = accumulatedMatchSeconds(config, state, "period_2")
|
||||
|
||||
expect(total).toBe((120 - 42) + (120 - 75))
|
||||
})
|
||||
|
||||
it("builds scoreboard payload from canonical state and metadata", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.participantScores = { w1: 6, w2: 2 }
|
||||
state.phaseIndex = 2
|
||||
|
||||
const payload = scoreboardStatePayload(config, state, {
|
||||
tournamentId: 1,
|
||||
boutNumber: 1001,
|
||||
weightLabel: "106",
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
w1Name: "Wrestler 1",
|
||||
w2Name: "Wrestler 2",
|
||||
w1School: "School A",
|
||||
w2School: "School B"
|
||||
})
|
||||
|
||||
expect(payload.participantScores).toEqual({ w1: 6, w2: 2 })
|
||||
expect(payload.phaseIndex).toBe(2)
|
||||
expect(payload.metadata.boutNumber).toBe(1001)
|
||||
expect(payload.metadata.w1Name).toBe("Wrestler 1")
|
||||
expect(payload.matchResult).toEqual({ finished: false })
|
||||
})
|
||||
|
||||
it("records progressive penalty with linked awarded points", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.nextEventId = 1
|
||||
state.nextEventGroupId = 1
|
||||
|
||||
const buildControllerStyleEvent = (participantKey, actionKey, options = {}) => ({
|
||||
id: state.nextEventId++,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 120,
|
||||
participantKey,
|
||||
actionKey,
|
||||
actionGroupId: options.actionGroupId
|
||||
})
|
||||
|
||||
recordProgressiveAction(config, state, "w1", "penalty", buildControllerStyleEvent)
|
||||
recordProgressiveAction(config, state, "w1", "penalty", buildControllerStyleEvent)
|
||||
recordProgressiveAction(config, state, "w1", "penalty", buildControllerStyleEvent)
|
||||
|
||||
expect(state.events.map((eventRecord) => [eventRecord.participantKey, eventRecord.actionKey])).toEqual([
|
||||
["w1", "penalty"],
|
||||
["w2", "plus_1"],
|
||||
["w1", "penalty"],
|
||||
["w2", "plus_1"],
|
||||
["w1", "penalty"],
|
||||
["w2", "plus_2"]
|
||||
])
|
||||
})
|
||||
|
||||
it("applies a normal match action by creating one event", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
const applied = applyMatchAction(config, state, config.phaseSequence[0], 118, "w1", "takedown_3")
|
||||
|
||||
expect(applied).toBe(true)
|
||||
expect(state.events).toHaveLength(1)
|
||||
expect(state.events[0]).toMatchObject({
|
||||
phaseKey: "period_1",
|
||||
clockSeconds: 118,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
})
|
||||
})
|
||||
|
||||
it("applies a progressive action by creating offense and linked award events when earned", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
expect(applyMatchAction(config, state, config.phaseSequence[0], 118, "w1", "stalling")).toBe(true)
|
||||
expect(applyMatchAction(config, state, config.phaseSequence[0], 110, "w1", "stalling")).toBe(true)
|
||||
|
||||
expect(state.events.map((eventRecord) => [eventRecord.participantKey, eventRecord.actionKey])).toEqual([
|
||||
["w1", "stalling"],
|
||||
["w1", "stalling"],
|
||||
["w2", "plus_1"]
|
||||
])
|
||||
})
|
||||
|
||||
it("applies a defer choice without storing a final selection", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const choicePhase = config.phaseSequence[1]
|
||||
|
||||
const result = applyChoiceAction(state, choicePhase, 0, "w1", "defer")
|
||||
|
||||
expect(result).toEqual({ applied: true, deferred: true })
|
||||
expect(state.events).toHaveLength(1)
|
||||
expect(state.events[0].actionKey).toBe("choice_defer")
|
||||
expect(state.selections).toEqual({})
|
||||
})
|
||||
|
||||
it("applies a non defer choice and stores the selection", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const choicePhase = config.phaseSequence[1]
|
||||
|
||||
const result = applyChoiceAction(state, choicePhase, 0, "w2", "bottom")
|
||||
|
||||
expect(result).toEqual({ applied: true, deferred: false })
|
||||
expect(state.events[0].actionKey).toBe("choice_bottom")
|
||||
expect(state.selections.choice_1).toEqual({ participantKey: "w2", choiceKey: "bottom" })
|
||||
})
|
||||
|
||||
it("deleting a timer-used event restores timer time", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.timers.w1.injury.remainingSeconds = 60
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
participantKey: "w1",
|
||||
actionKey: "timer_used_injury",
|
||||
elapsedSeconds: 20
|
||||
})
|
||||
]
|
||||
|
||||
const deleted = deleteEventFromState(config, state, 1)
|
||||
|
||||
expect(deleted).toBe(true)
|
||||
expect(state.events).toEqual([])
|
||||
expect(state.timers.w1.injury.remainingSeconds).toBe(80)
|
||||
})
|
||||
|
||||
it("swapping a timer-used event moves the used time to the other wrestler", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.timers.w1.blood.remainingSeconds = 240
|
||||
state.timers.w2.blood.remainingSeconds = 260
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
participantKey: "w1",
|
||||
actionKey: "timer_used_blood",
|
||||
elapsedSeconds: 30
|
||||
})
|
||||
]
|
||||
|
||||
const swapped = swapEventParticipants(config, state, 1)
|
||||
|
||||
expect(swapped).toBe(true)
|
||||
expect(state.events[0].participantKey).toBe("w2")
|
||||
expect(state.timers.w1.blood.remainingSeconds).toBe(270)
|
||||
expect(state.timers.w2.blood.remainingSeconds).toBe(230)
|
||||
})
|
||||
|
||||
it("swapping a whole period flips all participants in that period", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
}),
|
||||
buildEvent({
|
||||
id: 2,
|
||||
participantKey: "w2",
|
||||
actionKey: "escape_1",
|
||||
clockSeconds: 80
|
||||
}),
|
||||
buildEvent({
|
||||
id: 3,
|
||||
phaseKey: "choice_1",
|
||||
phaseLabel: "Choice 1",
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_defer",
|
||||
clockSeconds: 0
|
||||
})
|
||||
]
|
||||
|
||||
const swapped = swapPhaseParticipants(config, state, "period_1")
|
||||
|
||||
expect(swapped).toBe(true)
|
||||
expect(state.events.slice(0, 2).map((eventRecord) => eventRecord.participantKey)).toEqual(["w2", "w1"])
|
||||
expect(state.events[2].participantKey).toBe("w1")
|
||||
})
|
||||
|
||||
it("starts, stops, and adjusts a running match clock", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const activeClock = state.clocksByPhase.period_1
|
||||
|
||||
expect(startClockState(activeClock, 1_000)).toBe(true)
|
||||
expect(activeClock.running).toBe(true)
|
||||
expect(activeClock.startedAt).toBe(1_000)
|
||||
|
||||
expect(adjustClockState(activeClock, -10, 6_000)).toBe(true)
|
||||
expect(activeClock.remainingSeconds).toBe(105)
|
||||
expect(activeClock.startedAt).toBe(6_000)
|
||||
|
||||
expect(stopClockState(activeClock, 11_000)).toBe(true)
|
||||
expect(activeClock.running).toBe(false)
|
||||
expect(activeClock.remainingSeconds).toBe(100)
|
||||
expect(syncClockSnapshot(activeClock)).toEqual({
|
||||
durationSeconds: 120,
|
||||
remainingSeconds: 100,
|
||||
running: false,
|
||||
startedAt: null
|
||||
})
|
||||
})
|
||||
|
||||
it("starts and stops an auxiliary timer with elapsed time", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const timerState = buildInitialState(config).timers.w1.injury
|
||||
|
||||
expect(startAuxiliaryTimerState(timerState, 2_000)).toBe(true)
|
||||
const result = stopAuxiliaryTimerState(timerState, 17_000)
|
||||
|
||||
expect(result).toEqual({ stopped: true, elapsedSeconds: 15 })
|
||||
expect(timerState.running).toBe(false)
|
||||
expect(timerState.remainingSeconds).toBe(75)
|
||||
})
|
||||
|
||||
it("derives match result defaults from score and overtime context", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.participantScores = { w1: 4, w2: 2 }
|
||||
|
||||
const defaults = matchResultDefaults(state, {
|
||||
w1Id: 11,
|
||||
w2Id: 22,
|
||||
currentPhase: config.phaseSequence.find((phase) => phase.key === "sv_1"),
|
||||
accumulationSeconds: 83
|
||||
})
|
||||
|
||||
expect(defaults).toEqual({
|
||||
winnerId: 11,
|
||||
overtimeType: "SV-1",
|
||||
winnerScore: 4,
|
||||
loserScore: 2,
|
||||
pinMinutes: 1,
|
||||
pinSeconds: 23
|
||||
})
|
||||
})
|
||||
|
||||
it("moves between phases and resets control to the new phase base", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.control = "w1_control"
|
||||
|
||||
expect(moveToNextPhase(config, state)).toBe(true)
|
||||
expect(state.phaseIndex).toBe(1)
|
||||
|
||||
state.selections.choice_1 = { participantKey: "w2", choiceKey: "bottom" }
|
||||
expect(moveToNextPhase(config, state)).toBe(true)
|
||||
expect(state.phaseIndex).toBe(2)
|
||||
expect(state.control).toBe("w1_control")
|
||||
|
||||
expect(moveToPreviousPhase(config, state)).toBe(true)
|
||||
expect(state.phaseIndex).toBe(1)
|
||||
})
|
||||
|
||||
it("finds the active clock for a timed phase and reports running state", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const periodOne = config.phaseSequence[0]
|
||||
const choiceOne = config.phaseSequence[1]
|
||||
|
||||
expect(activeClockForPhase(state, periodOne)).toBe(state.clocksByPhase.period_1)
|
||||
expect(activeClockForPhase(state, choiceOne)).toBe(null)
|
||||
expect(hasRunningClockOrTimer(state)).toBe(false)
|
||||
|
||||
state.clocksByPhase.period_1.running = true
|
||||
expect(hasRunningClockOrTimer(state)).toBe(true)
|
||||
})
|
||||
|
||||
it("stops all running auxiliary timers in place", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.timers.w1.blood.running = true
|
||||
state.timers.w1.blood.startedAt = 1_000
|
||||
state.timers.w1.blood.remainingSeconds = 300
|
||||
state.timers.w2.injury.running = true
|
||||
state.timers.w2.injury.startedAt = 5_000
|
||||
state.timers.w2.injury.remainingSeconds = 90
|
||||
|
||||
stopAllAuxiliaryTimers(state, 11_000)
|
||||
|
||||
expect(state.timers.w1.blood).toMatchObject({ running: false, startedAt: null, remainingSeconds: 290 })
|
||||
expect(state.timers.w2.injury).toMatchObject({ running: false, startedAt: null, remainingSeconds: 84 })
|
||||
})
|
||||
})
|
||||
94
test/javascript/match_state/presenters.test.js
Normal file
94
test/javascript/match_state/presenters.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
import {
|
||||
buttonClassForParticipant,
|
||||
choiceViewModel,
|
||||
displayLabelForParticipant,
|
||||
eventLogSections,
|
||||
humanizeChoice
|
||||
} from "match-state-presenters"
|
||||
|
||||
describe("match state presenters", () => {
|
||||
it("maps assignment to display labels and button classes", () => {
|
||||
const assignment = { w1: "green", w2: "red" }
|
||||
|
||||
expect(displayLabelForParticipant(assignment, "w1")).toBe("Green")
|
||||
expect(displayLabelForParticipant(assignment, "w2")).toBe("Red")
|
||||
expect(buttonClassForParticipant(assignment, "w1")).toBe("btn-success")
|
||||
expect(buttonClassForParticipant(assignment, "w2")).toBe("btn-danger")
|
||||
expect(humanizeChoice("defer")).toBe("Defer")
|
||||
})
|
||||
|
||||
it("builds choice view model with defer blocking another defer", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const phase = config.phaseSequence[1]
|
||||
state.events = [{
|
||||
id: 1,
|
||||
phaseKey: phase.key,
|
||||
phaseLabel: phase.label,
|
||||
clockSeconds: 0,
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_defer"
|
||||
}]
|
||||
|
||||
const viewModel = choiceViewModel(config, state, phase, {
|
||||
w1: { name: "Wrestler 1" },
|
||||
w2: { name: "Wrestler 2" }
|
||||
})
|
||||
|
||||
expect(viewModel.label).toBe("Choose wrestler and position")
|
||||
expect(viewModel.selectionText).toContain("Green deferred")
|
||||
expect(viewModel.buttons.map((button) => [button.participantKey, button.choiceKey])).toEqual([
|
||||
["w2", "top"],
|
||||
["w2", "bottom"],
|
||||
["w2", "neutral"]
|
||||
])
|
||||
})
|
||||
|
||||
it("builds event log sections with formatted action labels", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.events = [
|
||||
{
|
||||
id: 1,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 100,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 80,
|
||||
participantKey: "w2",
|
||||
actionKey: "timer_used_blood",
|
||||
elapsedSeconds: 15
|
||||
}
|
||||
]
|
||||
|
||||
const sections = eventLogSections(config, state, (seconds) => `F-${seconds}`)
|
||||
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0].label).toBe("Period 1")
|
||||
expect(sections[0].items).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
participantKey: "w2",
|
||||
colorLabel: "Red",
|
||||
actionLabel: "Blood Time Used: F-15",
|
||||
clockLabel: "F-80"
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
participantKey: "w1",
|
||||
colorLabel: "Green",
|
||||
actionLabel: "Takedown +3",
|
||||
clockLabel: "F-100"
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
227
test/javascript/match_state/scoreboard_presenters.test.js
Normal file
227
test/javascript/match_state/scoreboard_presenters.test.js
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
import {
|
||||
boardColors,
|
||||
buildRunningTimerSnapshot,
|
||||
emptyBoardViewModel,
|
||||
currentClockText,
|
||||
detectRecentlyStoppedTimer,
|
||||
mainClockRunning,
|
||||
nextTimerBannerState,
|
||||
participantDisplayLabel,
|
||||
participantForColor,
|
||||
populatedBoardViewModel,
|
||||
timerBannerRenderState,
|
||||
timerBannerViewModel,
|
||||
timerIndicatorLabel
|
||||
} from "match-state-scoreboard-presenters"
|
||||
|
||||
describe("match state scoreboard presenters", () => {
|
||||
it("maps colors to participants and labels", () => {
|
||||
const state = {
|
||||
assignment: { w1: "red", w2: "green" },
|
||||
metadata: { w1Name: "Alpha", w2Name: "Bravo" }
|
||||
}
|
||||
|
||||
expect(participantForColor(state, "red")).toBe("w1")
|
||||
expect(participantForColor(state, "green")).toBe("w2")
|
||||
expect(participantDisplayLabel(state, "w1")).toBe("Red Alpha")
|
||||
expect(participantDisplayLabel(state, "w2")).toBe("Green Bravo")
|
||||
})
|
||||
|
||||
it("formats the current clock from running phase state", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.phaseIndex = 0
|
||||
state.clocksByPhase.period_1.running = true
|
||||
state.clocksByPhase.period_1.startedAt = 1_000
|
||||
state.clocksByPhase.period_1.remainingSeconds = 120
|
||||
|
||||
expect(currentClockText(config, state, (seconds) => `F-${seconds}`, 11_000)).toBe("F-110")
|
||||
expect(mainClockRunning(config, state)).toBe(true)
|
||||
})
|
||||
|
||||
it("builds timer indicator and banner view models from running timers", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.metadata = { w1Name: "Alpha", w2Name: "Bravo" }
|
||||
state.timers.w1.blood.running = true
|
||||
state.timers.w1.blood.startedAt = 1_000
|
||||
state.timers.w1.blood.remainingSeconds = 300
|
||||
|
||||
expect(timerIndicatorLabel(config, state, "w1", (seconds) => `F-${seconds}`, 21_000)).toBe("Blood: F-20")
|
||||
|
||||
const banner = timerBannerViewModel(config, state, { participantKey: "w1", timerKey: "blood", expiresAt: null }, (seconds) => `F-${seconds}`, 21_000)
|
||||
expect(banner).toEqual({
|
||||
color: "green",
|
||||
label: "Green Alpha Blood Running",
|
||||
clockText: "F-20"
|
||||
})
|
||||
})
|
||||
|
||||
it("detects recently stopped timers from the snapshot", () => {
|
||||
const state = {
|
||||
timers: {
|
||||
w1: { blood: { running: false } },
|
||||
w2: { injury: { running: true } }
|
||||
}
|
||||
}
|
||||
const snapshot = {
|
||||
"w1:blood": true,
|
||||
"w2:injury": true
|
||||
}
|
||||
|
||||
expect(detectRecentlyStoppedTimer(state, snapshot)).toEqual({ participantKey: "w1", timerKey: "blood" })
|
||||
expect(buildRunningTimerSnapshot(state)).toEqual({
|
||||
"w1:blood": false,
|
||||
"w2:injury": true
|
||||
})
|
||||
})
|
||||
|
||||
it("builds populated and empty board view models", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.metadata = {
|
||||
w1Name: "Alpha",
|
||||
w2Name: "Bravo",
|
||||
w1School: "School A",
|
||||
w2School: "School B",
|
||||
weightLabel: "106"
|
||||
}
|
||||
state.participantScores = { w1: 4, w2: 1 }
|
||||
state.assignment = { w1: "green", w2: "red" }
|
||||
|
||||
const populated = populatedBoardViewModel(
|
||||
config,
|
||||
state,
|
||||
{ w1_stat: "T3", w2_stat: "E1" },
|
||||
1001,
|
||||
(seconds) => `F-${seconds}`
|
||||
)
|
||||
|
||||
expect(populated).toMatchObject({
|
||||
isEmpty: false,
|
||||
redName: "Bravo",
|
||||
redSchool: "School B",
|
||||
redScore: "1",
|
||||
greenName: "Alpha",
|
||||
greenSchool: "School A",
|
||||
greenScore: "4",
|
||||
weightLabel: "Weight 106",
|
||||
boutLabel: "Bout 1001",
|
||||
redStats: "E1",
|
||||
greenStats: "T3"
|
||||
})
|
||||
|
||||
expect(emptyBoardViewModel(1002, "Last result")).toEqual({
|
||||
isEmpty: true,
|
||||
redName: "NO MATCH",
|
||||
redSchool: "",
|
||||
redScore: "0",
|
||||
redTimerIndicator: "",
|
||||
greenName: "NO MATCH",
|
||||
greenSchool: "",
|
||||
greenScore: "0",
|
||||
greenTimerIndicator: "",
|
||||
clockText: "-",
|
||||
phaseLabel: "No Match",
|
||||
weightLabel: "Weight -",
|
||||
boutLabel: "Bout 1002",
|
||||
redStats: "",
|
||||
greenStats: "",
|
||||
lastMatchResult: "Last result"
|
||||
})
|
||||
})
|
||||
|
||||
it("builds next timer banner state for running and recently stopped timers", () => {
|
||||
const runningState = {
|
||||
timers: {
|
||||
w1: { blood: { running: true } },
|
||||
w2: {}
|
||||
}
|
||||
}
|
||||
|
||||
expect(nextTimerBannerState(runningState, {})).toEqual({
|
||||
timerBannerState: {
|
||||
participantKey: "w1",
|
||||
timerKey: "blood",
|
||||
expiresAt: null
|
||||
},
|
||||
previousTimerSnapshot: {
|
||||
"w1:blood": true
|
||||
}
|
||||
})
|
||||
|
||||
const stoppedState = {
|
||||
timers: {
|
||||
w1: { blood: { running: false } },
|
||||
w2: {}
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedResult = nextTimerBannerState(stoppedState, { "w1:blood": true }, 5_000)
|
||||
expect(stoppedResult).toEqual({
|
||||
timerBannerState: {
|
||||
participantKey: "w1",
|
||||
timerKey: "blood",
|
||||
expiresAt: 15_000
|
||||
},
|
||||
previousTimerSnapshot: {
|
||||
"w1:blood": false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("builds board colors and timer banner render decisions", () => {
|
||||
expect(boardColors(true)).toEqual({
|
||||
red: "#000",
|
||||
center: "#000",
|
||||
green: "#000"
|
||||
})
|
||||
expect(boardColors(false)).toEqual({
|
||||
red: "#c91f1f",
|
||||
center: "#050505",
|
||||
green: "#1cab2d"
|
||||
})
|
||||
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.metadata = { w1Name: "Alpha", w2Name: "Bravo" }
|
||||
state.timers.w1.blood.running = true
|
||||
state.timers.w1.blood.startedAt = 1_000
|
||||
state.timers.w1.blood.remainingSeconds = 300
|
||||
|
||||
expect(timerBannerRenderState(
|
||||
config,
|
||||
state,
|
||||
{ participantKey: "w1", timerKey: "blood", expiresAt: null },
|
||||
(seconds) => `F-${seconds}`,
|
||||
11_000
|
||||
)).toEqual({
|
||||
timerBannerState: { participantKey: "w1", timerKey: "blood", expiresAt: null },
|
||||
visible: true,
|
||||
viewModel: {
|
||||
color: "green",
|
||||
label: "Green Alpha Blood Running",
|
||||
clockText: "F-10"
|
||||
}
|
||||
})
|
||||
|
||||
state.clocksByPhase.period_1.running = true
|
||||
state.clocksByPhase.period_1.startedAt = 1_000
|
||||
state.clocksByPhase.period_1.remainingSeconds = 120
|
||||
|
||||
expect(timerBannerRenderState(
|
||||
config,
|
||||
state,
|
||||
{ participantKey: "w1", timerKey: "blood", expiresAt: 20_000 },
|
||||
(seconds) => `F-${seconds}`,
|
||||
11_000
|
||||
)).toEqual({
|
||||
timerBannerState: null,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
})
|
||||
})
|
||||
})
|
||||
307
test/javascript/match_state/scoreboard_state.test.js
Normal file
307
test/javascript/match_state/scoreboard_state.test.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
buildScoreboardContext,
|
||||
connectionPlan,
|
||||
applyMatchPayloadContext,
|
||||
applyMatPayloadContext,
|
||||
applyStatePayloadContext,
|
||||
extractLiveMatchData,
|
||||
matchStorageKey,
|
||||
selectedBoutNumber,
|
||||
storageChangePlan,
|
||||
selectedBoutStorageKey
|
||||
} from "match-state-scoreboard-state"
|
||||
|
||||
describe("match state scoreboard state helpers", () => {
|
||||
it("builds the default scoreboard controller context", () => {
|
||||
expect(buildScoreboardContext({ initialBoutNumber: 1001, matchId: 55 })).toEqual({
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentBoutNumber: 1001,
|
||||
currentMatchId: 55,
|
||||
liveMatchData: {},
|
||||
lastMatchResult: "",
|
||||
state: null,
|
||||
finished: false,
|
||||
timerBannerState: null,
|
||||
previousTimerSnapshot: {}
|
||||
})
|
||||
})
|
||||
|
||||
it("builds tournament-scoped storage keys", () => {
|
||||
expect(selectedBoutStorageKey(4, 2)).toBe("mat-selected-bout:4:2")
|
||||
expect(matchStorageKey(4, 1007)).toBe("match-state:4:1007")
|
||||
expect(matchStorageKey(4, null)).toBe(null)
|
||||
})
|
||||
|
||||
it("builds connection plans by source mode", () => {
|
||||
expect(connectionPlan("localstorage", 11)).toEqual({
|
||||
useStorageListener: true,
|
||||
subscribeMat: true,
|
||||
subscribeMatch: false,
|
||||
matchId: null,
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true
|
||||
})
|
||||
|
||||
expect(connectionPlan("mat_websocket", 11)).toEqual({
|
||||
useStorageListener: false,
|
||||
subscribeMat: true,
|
||||
subscribeMatch: true,
|
||||
matchId: 11,
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false
|
||||
})
|
||||
|
||||
expect(connectionPlan("websocket", 11)).toEqual({
|
||||
useStorageListener: false,
|
||||
subscribeMat: false,
|
||||
subscribeMatch: true,
|
||||
matchId: 11,
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false
|
||||
})
|
||||
})
|
||||
|
||||
it("extracts live match fields from a websocket payload", () => {
|
||||
expect(extractLiveMatchData({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_name: "Alpha",
|
||||
finished: 1,
|
||||
ignored: "value"
|
||||
})).toEqual({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_name: "Alpha",
|
||||
finished: 1
|
||||
})
|
||||
})
|
||||
|
||||
it("applies scoreboard state payload context", () => {
|
||||
const context = applyStatePayloadContext(
|
||||
{ currentBoutNumber: 1001, finished: false, state: null },
|
||||
{
|
||||
metadata: { boutNumber: 1002 },
|
||||
matchResult: { finished: true }
|
||||
}
|
||||
)
|
||||
|
||||
expect(context.currentBoutNumber).toBe(1002)
|
||||
expect(context.finished).toBe(true)
|
||||
expect(context.state).toEqual({
|
||||
metadata: { boutNumber: 1002 },
|
||||
matchResult: { finished: true }
|
||||
})
|
||||
})
|
||||
|
||||
it("merges websocket match payload into current scoreboard context", () => {
|
||||
const currentContext = {
|
||||
currentBoutNumber: 1001,
|
||||
finished: false,
|
||||
liveMatchData: { w1_stat: "Old" },
|
||||
state: { metadata: { boutNumber: 1001 } }
|
||||
}
|
||||
|
||||
const nextContext = applyMatchPayloadContext(currentContext, {
|
||||
scoreboard_state: {
|
||||
metadata: { boutNumber: 1003 },
|
||||
matchResult: { finished: true }
|
||||
},
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
finished: 1
|
||||
})
|
||||
|
||||
expect(nextContext.currentBoutNumber).toBe(1003)
|
||||
expect(nextContext.finished).toBe(true)
|
||||
expect(nextContext.liveMatchData).toEqual({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
finished: 1
|
||||
})
|
||||
expect(nextContext.state).toEqual({
|
||||
metadata: { boutNumber: 1003 },
|
||||
matchResult: { finished: true }
|
||||
})
|
||||
})
|
||||
|
||||
it("updates localstorage scoreboard context from mat payload", () => {
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "localstorage",
|
||||
currentQueueBoutNumber: null,
|
||||
lastMatchResult: "",
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: null,
|
||||
state: null,
|
||||
liveMatchData: {}
|
||||
},
|
||||
{
|
||||
queue1_bout_number: 1001,
|
||||
last_match_result: "Result text"
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext).toMatchObject({
|
||||
currentQueueBoutNumber: 1001,
|
||||
lastMatchResult: "Result text",
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
})
|
||||
})
|
||||
|
||||
it("uses the selected mat bout as the localstorage scoreboard fallback", () => {
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "localstorage",
|
||||
currentQueueBoutNumber: null,
|
||||
lastMatchResult: "",
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: null,
|
||||
state: null,
|
||||
liveMatchData: {}
|
||||
},
|
||||
{
|
||||
queue1_bout_number: 1001,
|
||||
selected_bout_number: 1003,
|
||||
last_match_result: ""
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext.currentQueueBoutNumber).toBe(1003)
|
||||
expect(nextContext.loadSelectedBout).toBe(true)
|
||||
expect(nextContext.loadLocalState).toBe(true)
|
||||
})
|
||||
|
||||
it("derives storage change instructions for selected bout and match state keys", () => {
|
||||
const context = { currentBoutNumber: 1001 }
|
||||
|
||||
expect(storageChangePlan(context, "mat-selected-bout:4:2", 4, 2)).toEqual({
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
})
|
||||
|
||||
expect(storageChangePlan(context, "match-state:4:1001", 4, 2)).toEqual({
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
})
|
||||
|
||||
expect(storageChangePlan(context, "other-key", 4, 2)).toEqual({
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false,
|
||||
renderNow: false
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers selected bout numbers and falls back to queue bout", () => {
|
||||
expect(selectedBoutNumber({ boutNumber: 1004 }, 1001)).toBe(1004)
|
||||
expect(selectedBoutNumber(null, 1001)).toBe(1001)
|
||||
})
|
||||
|
||||
it("clears websocket scoreboard context when the mat has no active match", () => {
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "mat_websocket",
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentMatchId: 10,
|
||||
currentBoutNumber: 1001,
|
||||
liveMatchData: { w1_stat: "T3" },
|
||||
state: { metadata: { boutNumber: 1001 } },
|
||||
lastMatchResult: ""
|
||||
},
|
||||
{
|
||||
queue1_bout_number: null,
|
||||
queue1_match_id: null,
|
||||
selected_bout_number: null,
|
||||
selected_match_id: null,
|
||||
last_match_result: "Last result"
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext).toMatchObject({
|
||||
currentQueueBoutNumber: null,
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: null,
|
||||
state: null,
|
||||
liveMatchData: {},
|
||||
lastMatchResult: "Last result",
|
||||
resetTimerBanner: true,
|
||||
unsubscribeMatch: true,
|
||||
subscribeMatchId: null,
|
||||
renderNow: true
|
||||
})
|
||||
})
|
||||
|
||||
it("switches websocket scoreboard subscriptions when the selected match changes", () => {
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "mat_websocket",
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentMatchId: 10,
|
||||
currentBoutNumber: 1001,
|
||||
liveMatchData: { w1_stat: "T3" },
|
||||
state: { metadata: { boutNumber: 1001 } },
|
||||
lastMatchResult: ""
|
||||
},
|
||||
{
|
||||
queue1_bout_number: 1001,
|
||||
queue1_match_id: 10,
|
||||
selected_bout_number: 1002,
|
||||
selected_match_id: 11,
|
||||
last_match_result: ""
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext).toMatchObject({
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentMatchId: 11,
|
||||
currentBoutNumber: 1002,
|
||||
state: null,
|
||||
liveMatchData: {},
|
||||
resetTimerBanner: true,
|
||||
subscribeMatchId: 11,
|
||||
renderNow: true
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps current websocket subscription when the selected match is unchanged", () => {
|
||||
const state = { metadata: { boutNumber: 1002 } }
|
||||
const liveMatchData = { w1_stat: "T3" }
|
||||
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "mat_websocket",
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentMatchId: 11,
|
||||
currentBoutNumber: 1002,
|
||||
liveMatchData,
|
||||
state,
|
||||
lastMatchResult: ""
|
||||
},
|
||||
{
|
||||
queue1_bout_number: 1001,
|
||||
queue1_match_id: 10,
|
||||
selected_bout_number: 1002,
|
||||
selected_match_id: 11,
|
||||
last_match_result: "Result"
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext.currentMatchId).toBe(11)
|
||||
expect(nextContext.currentBoutNumber).toBe(1002)
|
||||
expect(nextContext.state).toBe(state)
|
||||
expect(nextContext.liveMatchData).toBe(liveMatchData)
|
||||
expect(nextContext.subscribeMatchId).toBe(null)
|
||||
expect(nextContext.renderNow).toBe(false)
|
||||
expect(nextContext.lastMatchResult).toBe("Result")
|
||||
})
|
||||
})
|
||||
73
test/javascript/match_state/serializers.test.js
Normal file
73
test/javascript/match_state/serializers.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
import {
|
||||
buildMatchMetadata,
|
||||
buildPersistedState,
|
||||
buildStorageKey,
|
||||
restorePersistedState
|
||||
} from "match-state-serializers"
|
||||
|
||||
describe("match state serializers", () => {
|
||||
it("builds a tournament and bout scoped storage key", () => {
|
||||
expect(buildStorageKey(12, 1007)).toBe("match-state:12:1007")
|
||||
})
|
||||
|
||||
it("builds match metadata for persistence and scoreboard payloads", () => {
|
||||
expect(buildMatchMetadata({
|
||||
tournamentId: 1,
|
||||
boutNumber: 1001,
|
||||
weightLabel: "106",
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
w1Name: "W1",
|
||||
w2Name: "W2",
|
||||
w1School: "School 1",
|
||||
w2School: "School 2"
|
||||
})).toEqual({
|
||||
tournamentId: 1,
|
||||
boutNumber: 1001,
|
||||
weightLabel: "106",
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
w1Name: "W1",
|
||||
w2Name: "W2",
|
||||
w1School: "School 1",
|
||||
w2School: "School 2"
|
||||
})
|
||||
})
|
||||
|
||||
it("builds persisted state with metadata", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.participantScores = { w1: 7, w2: 3 }
|
||||
|
||||
const persisted = buildPersistedState(state, { tournamentId: 1, boutNumber: 1001 })
|
||||
|
||||
expect(persisted.participantScores).toEqual({ w1: 7, w2: 3 })
|
||||
expect(persisted.metadata).toEqual({ tournamentId: 1, boutNumber: 1001 })
|
||||
})
|
||||
|
||||
it("restores persisted state over initial defaults", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const restored = restorePersistedState(config, {
|
||||
participantScores: { w1: 4, w2: 1 },
|
||||
assignment: { w1: "red", w2: "green" },
|
||||
clocksByPhase: {
|
||||
period_1: { remainingSeconds: 30 }
|
||||
},
|
||||
timers: {
|
||||
w1: {
|
||||
injury: { remainingSeconds: 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(restored.participantScores).toEqual({ w1: 4, w2: 1 })
|
||||
expect(restored.assignment).toEqual({ w1: "red", w2: "green" })
|
||||
expect(restored.clocksByPhase.period_1.remainingSeconds).toBe(30)
|
||||
expect(restored.clocksByPhase.period_1.durationSeconds).toBe(120)
|
||||
expect(restored.timers.w1.injury.remainingSeconds).toBe(50)
|
||||
expect(restored.timers.w2.injury.remainingSeconds).toBe(90)
|
||||
})
|
||||
})
|
||||
56
test/javascript/match_state/transport.test.js
Normal file
56
test/javascript/match_state/transport.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
loadJson,
|
||||
performIfChanged,
|
||||
removeKey,
|
||||
saveJson
|
||||
} from "match-state-transport"
|
||||
|
||||
describe("match state transport", () => {
|
||||
it("loads saved json from storage", () => {
|
||||
const storage = {
|
||||
getItem: vi.fn(() => '{"score":3}')
|
||||
}
|
||||
|
||||
expect(loadJson(storage, "match-state:1:1001")).toEqual({ score: 3 })
|
||||
expect(storage.getItem).toHaveBeenCalledWith("match-state:1:1001")
|
||||
})
|
||||
|
||||
it("returns null when stored json is invalid", () => {
|
||||
const storage = {
|
||||
getItem: vi.fn(() => "{not-json")
|
||||
}
|
||||
|
||||
expect(loadJson(storage, "bad")).toBe(null)
|
||||
})
|
||||
|
||||
it("saves and removes json values in storage", () => {
|
||||
const storage = {
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
|
||||
expect(saveJson(storage, "key", { score: 5 })).toBe(true)
|
||||
expect(storage.setItem).toHaveBeenCalledWith("key", '{"score":5}')
|
||||
|
||||
expect(removeKey(storage, "key")).toBe(true)
|
||||
expect(storage.removeItem).toHaveBeenCalledWith("key")
|
||||
})
|
||||
|
||||
it("only performs subscription actions when the payload changes", () => {
|
||||
const subscription = {
|
||||
perform: vi.fn()
|
||||
}
|
||||
|
||||
const firstSerialized = performIfChanged(subscription, "send_stat", { new_w1_stat: "T3" }, null)
|
||||
const secondSerialized = performIfChanged(subscription, "send_stat", { new_w1_stat: "T3" }, firstSerialized)
|
||||
const thirdSerialized = performIfChanged(subscription, "send_stat", { new_w1_stat: "T3 E1" }, secondSerialized)
|
||||
|
||||
expect(subscription.perform).toHaveBeenCalledTimes(2)
|
||||
expect(subscription.perform).toHaveBeenNthCalledWith(1, "send_stat", { new_w1_stat: "T3" })
|
||||
expect(subscription.perform).toHaveBeenNthCalledWith(2, "send_stat", { new_w1_stat: "T3 E1" })
|
||||
expect(firstSerialized).toBe('{"new_w1_stat":"T3"}')
|
||||
expect(secondSerialized).toBe(firstSerialized)
|
||||
expect(thirdSerialized).toBe('{"new_w1_stat":"T3 E1"}')
|
||||
})
|
||||
})
|
||||
1
test/javascript/support/stimulus_stub.js
Normal file
1
test/javascript/support/stimulus_stub.js
Normal file
@@ -0,0 +1 @@
|
||||
export class Controller {}
|
||||
51
test/models/mat_scoreboard_broadcast_test.rb
Normal file
51
test/models/mat_scoreboard_broadcast_test.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
require "test_helper"
|
||||
require "json"
|
||||
|
||||
class MatScoreboardBroadcastTest < ActiveSupport::TestCase
|
||||
test "set_selected_scoreboard_match broadcasts updated payload" do
|
||||
mat = mats(:one)
|
||||
queue1_match = matches(:tournament_1_bout_1000)
|
||||
selected_match = matches(:tournament_1_bout_1001)
|
||||
mat.update!(queue1: queue1_match.id, queue2: selected_match.id, queue3: nil, queue4: nil)
|
||||
|
||||
stream = MatScoreboardChannel.broadcasting_for(mat)
|
||||
clear_streams(stream)
|
||||
|
||||
mat.set_selected_scoreboard_match!(selected_match)
|
||||
|
||||
payload = JSON.parse(broadcasts_for(stream).last)
|
||||
assert_equal mat.id, payload["mat_id"]
|
||||
assert_equal queue1_match.id, payload["queue1_match_id"]
|
||||
assert_equal queue1_match.bout_number, payload["queue1_bout_number"]
|
||||
assert_equal selected_match.id, payload["selected_match_id"]
|
||||
assert_equal selected_match.bout_number, payload["selected_bout_number"]
|
||||
end
|
||||
|
||||
test "set_last_match_result broadcasts updated payload" do
|
||||
mat = mats(:one)
|
||||
queue1_match = matches(:tournament_1_bout_1000)
|
||||
mat.update!(queue1: queue1_match.id, queue2: nil, queue3: nil, queue4: nil)
|
||||
|
||||
stream = MatScoreboardChannel.broadcasting_for(mat)
|
||||
clear_streams(stream)
|
||||
|
||||
mat.set_last_match_result!("106 lbs - Winner Decision Loser 3-1")
|
||||
|
||||
payload = JSON.parse(broadcasts_for(stream).last)
|
||||
assert_equal "106 lbs - Winner Decision Loser 3-1", payload["last_match_result"]
|
||||
assert_equal queue1_match.id, payload["queue1_match_id"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcasts_for(stream)
|
||||
ActionCable.server.pubsub.broadcasts(stream)
|
||||
end
|
||||
|
||||
def clear_streams(*streams)
|
||||
ActionCable.server.pubsub.clear
|
||||
streams.each do |stream|
|
||||
broadcasts_for(stream).clear
|
||||
end
|
||||
end
|
||||
end
|
||||
85
test/models/mat_scoreboard_state_test.rb
Normal file
85
test/models/mat_scoreboard_state_test.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
require "test_helper"
|
||||
|
||||
class MatScoreboardStateTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@mat = mats(:one)
|
||||
@queue1_match = matches(:tournament_1_bout_1000)
|
||||
@queue2_match = matches(:tournament_1_bout_1001)
|
||||
Rails.cache.clear
|
||||
@mat.update!(queue1: @queue1_match.id, queue2: @queue2_match.id, queue3: nil, queue4: nil)
|
||||
end
|
||||
|
||||
test "scoreboard_payload falls back to queue1 when no selected match exists" do
|
||||
payload = @mat.scoreboard_payload
|
||||
|
||||
assert_equal @mat.id, payload[:mat_id]
|
||||
assert_equal @queue1_match.id, payload[:queue1_match_id]
|
||||
assert_equal @queue1_match.bout_number, payload[:queue1_bout_number]
|
||||
assert_nil payload[:selected_match_id]
|
||||
assert_nil payload[:selected_bout_number]
|
||||
assert_nil payload[:last_match_result]
|
||||
end
|
||||
|
||||
test "selected_scoreboard_match clears stale cached match ids" do
|
||||
stale_match = matches(:tournament_1_bout_2000)
|
||||
@mat.set_selected_scoreboard_match!(stale_match)
|
||||
|
||||
assert_nil @mat.selected_scoreboard_match
|
||||
assert_nil Rails.cache.read("tournament:#{@mat.tournament_id}:mat:#{@mat.id}:scoreboard_selection")
|
||||
end
|
||||
|
||||
test "scoreboard_payload returns selected match when selection is valid" do
|
||||
@mat.set_selected_scoreboard_match!(@queue2_match)
|
||||
|
||||
payload = @mat.scoreboard_payload
|
||||
|
||||
assert_equal @queue2_match.id, payload[:selected_match_id]
|
||||
assert_equal @queue2_match.bout_number, payload[:selected_bout_number]
|
||||
end
|
||||
|
||||
test "scoreboard_payload includes last match result when present" do
|
||||
@mat.set_last_match_result!("106 lbs - Winner Decision Loser 3-1")
|
||||
|
||||
payload = @mat.scoreboard_payload
|
||||
|
||||
assert_equal "106 lbs - Winner Decision Loser 3-1", payload[:last_match_result]
|
||||
end
|
||||
|
||||
test "scoreboard_payload handles empty queue" do
|
||||
@mat.update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
|
||||
|
||||
payload = @mat.scoreboard_payload
|
||||
|
||||
assert_nil payload[:queue1_match_id]
|
||||
assert_nil payload[:queue1_bout_number]
|
||||
assert_nil payload[:selected_match_id]
|
||||
assert_nil payload[:selected_bout_number]
|
||||
end
|
||||
|
||||
test "scoreboard_payload falls back to new queue1 after selected queue1 leaves the queue" do
|
||||
@mat.set_selected_scoreboard_match!(@queue1_match)
|
||||
@queue1_match.update!(winner_id: @queue1_match.w1, win_type: "Decision", score: "3-1", finished: 1)
|
||||
|
||||
@mat.advance_queue!(@queue1_match)
|
||||
payload = @mat.reload.scoreboard_payload
|
||||
|
||||
assert_equal @queue2_match.id, payload[:queue1_match_id]
|
||||
assert_equal @queue2_match.bout_number, payload[:queue1_bout_number]
|
||||
assert_nil payload[:selected_match_id]
|
||||
assert_nil payload[:selected_bout_number]
|
||||
assert_nil Rails.cache.read("tournament:#{@mat.tournament_id}:mat:#{@mat.id}:scoreboard_selection")
|
||||
end
|
||||
|
||||
test "scoreboard_payload keeps selected match when queue advances and selection remains queued" do
|
||||
@mat.set_selected_scoreboard_match!(@queue2_match)
|
||||
@queue1_match.update!(winner_id: @queue1_match.w1, win_type: "Decision", score: "3-1", finished: 1)
|
||||
|
||||
@mat.advance_queue!(@queue1_match)
|
||||
payload = @mat.reload.scoreboard_payload
|
||||
|
||||
assert_equal @queue2_match.id, payload[:queue1_match_id]
|
||||
assert_equal @queue2_match.bout_number, payload[:queue1_bout_number]
|
||||
assert_equal @queue2_match.id, payload[:selected_match_id]
|
||||
assert_equal @queue2_match.bout_number, payload[:selected_bout_number]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user