TUR-137 add timer reasons

This commit is contained in:
Daniel Schädler 2026-04-28 12:28:01 +02:00
parent 49d9a77c90
commit 3008b9fd50
No known key found for this signature in database
20 changed files with 344 additions and 57 deletions

View File

@ -171,14 +171,18 @@ class TournamentsController < ApplicationController
def timer_params def timer_params
{ {
timestamp: params[:timestamp], timestamp: params[:timestamp],
timer_mode: params[:timer_mode] timer_mode: params[:timer_mode],
timer_reason: params[:timer_reason],
timer_reason_text: params[:timer_reason_text]
} }
end end
def serialize_timer_state(tournament) def serialize_timer_state(tournament)
{ {
timestamp: tournament.timestamp&.iso8601, timestamp: tournament.timestamp&.iso8601,
timer_mode: tournament.timer_mode timer_mode: tournament.timer_mode,
timer_reason: tournament.timer_reason,
timer_reason_text: tournament.timer_reason_text
} }
end end
@ -333,40 +337,41 @@ class TournamentsController < ApplicationController
timestamp = params[:timestamp] timestamp = params[:timestamp]
timestamp_seconds = params[:timestamp_seconds] timestamp_seconds = params[:timestamp_seconds]
timer_mode = params[:timer_mode] timer_mode = params[:timer_mode]
normalize_timer_reason_params
timer_reason = params[:timer_reason]
timer_reason_text = params[:timer_reason_text]
clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer]) clear_timer = ActiveModel::Type::Boolean.new.cast(params[:clear_timer])
if clear_timer if clear_timer
if timestamp.present? || timestamp_seconds.present? || timer_mode.present? return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text)
return render json: { error: 'clear_timer cannot be combined with timestamp values or timer_mode' },
status: :unprocessable_entity
end end
params[:timestamp] = nil if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode)
params[:timer_mode] = nil return render_timer_validation_error('Invalid timer_mode')
return
end end
if timer_mode.present? && !%w[countdown countup].include?(timer_mode) timer_reason_error = validate_timer_reason_params(timer_reason, timer_reason_text)
return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity return render_timer_validation_error(timer_reason_error) if timer_reason_error
end
# throw error if both timestamp and timestamp_seconds are present # throw error if both timestamp and timestamp_seconds are present
if timestamp.present? && timestamp_seconds.present? if timestamp.present? && timestamp_seconds.present?
return render json: { error: 'Only one of timestamp or timestamp_seconds is allowed' }, status: :unprocessable_entity return render_timer_validation_error('Only one of timestamp or timestamp_seconds is allowed')
end end
if timer_mode == 'countup' && timestamp_seconds.present? if timer_mode == 'countup' && timestamp_seconds.present?
return render json: { error: 'timestamp_seconds is only allowed for countdown mode' }, status: :unprocessable_entity return render_timer_validation_error('timestamp_seconds is only allowed for countdown mode')
end end
if timestamp_seconds.present? if timestamp_seconds.present?
begin begin
timestamp_seconds = Integer(timestamp_seconds) timestamp_seconds = Integer(timestamp_seconds)
rescue ArgumentError rescue ArgumentError
return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity return render_timer_validation_error('Invalid seconds format')
end end
return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity if timestamp_seconds <= 0 if timestamp_seconds <= 0
return render_timer_validation_error('Timestamp must be in the future for countdown mode')
end
parsed_time = Time.zone.now + timestamp_seconds parsed_time = Time.zone.now + timestamp_seconds
params[:timestamp] = parsed_time params[:timestamp] = parsed_time
@ -375,24 +380,63 @@ class TournamentsController < ApplicationController
begin begin
parsed_time = Time.zone.parse(timestamp) parsed_time = Time.zone.parse(timestamp)
if parsed_time.nil? if parsed_time.nil?
return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity return render_timer_validation_error('Invalid timestamp format')
elsif timer_mode != 'countup' && !parsed_time.future? elsif timer_mode != 'countup' && !parsed_time.future?
return render json: { error: 'Timestamp must be in the future for countdown mode' }, status: :unprocessable_entity return render_timer_validation_error('Timestamp must be in the future for countdown mode')
end end
params[:timestamp] = parsed_time params[:timestamp] = parsed_time
params[:timer_mode] = timer_mode.presence || 'countdown' params[:timer_mode] = timer_mode.presence || 'countdown'
rescue ArgumentError rescue ArgumentError
return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity return render_timer_validation_error('Invalid timestamp format')
end end
elsif timer_mode == 'countup' elsif timer_mode == 'countup'
params[:timestamp] = Time.zone.now params[:timestamp] = Time.zone.now
elsif timer_mode.present? elsif timer_mode.present?
return render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' }, return render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer')
status: :unprocessable_entity
else else
return render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' }, return render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required')
status: :unprocessable_entity
end end
end end
def normalize_timer_reason_params
params[:timer_reason] = params[:timer_reason].presence
params[:timer_reason_text] = timer_reason_text_param
end
def timer_reason_text_param
return nil unless params.key?(:timer_reason_text)
params[:timer_reason_text].to_s.strip.presence
end
def validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text)
if timestamp.present? || timestamp_seconds.present? || timer_mode.present? ||
timer_reason.present? || timer_reason_text.present?
return render_timer_validation_error('clear_timer cannot be combined with timer values or timer reason')
end
params[:timestamp] = nil
params[:timer_mode] = nil
params[:timer_reason] = nil
params[:timer_reason_text] = nil
end
def validate_timer_reason_params(timer_reason, timer_reason_text)
return if timer_reason.blank? && timer_reason_text.blank?
return 'timer_reason_text is only allowed when timer_reason is custom' if timer_reason.blank?
return 'Invalid timer_reason' unless Tournament::TIMER_REASONS.include?(timer_reason)
if timer_reason == 'custom' && timer_reason_text.blank?
return 'timer_reason_text is required for custom timer_reason'
end
if timer_reason != 'custom' && timer_reason_text.present?
'timer_reason_text is only allowed when timer_reason is custom'
end
end
def render_timer_validation_error(message)
render json: { error: message }, status: :unprocessable_entity
end
end end

View File

@ -3,6 +3,9 @@
require 'securerandom' require 'securerandom'
class Tournament < ApplicationRecord class Tournament < ApplicationRecord
TIMER_MODES = %w[countdown countup].freeze
TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze
belongs_to :user belongs_to :user
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_many :stages, dependent: :destroy has_many :stages, dependent: :destroy
@ -11,16 +14,19 @@ class Tournament < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validates :code, presence: true, uniqueness: true validates :code, presence: true, uniqueness: true
validates :timer_mode, inclusion: { in: %w[countdown countup] }, allow_nil: true validates :timer_mode, inclusion: { in: TIMER_MODES }, allow_nil: true
validates :timer_reason, inclusion: { in: TIMER_REASONS }, allow_nil: true
validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true validates :sync_target_url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), allow_blank: true
validate :sync_configuration_is_complete validate :sync_configuration_is_complete
validate :playoff_teams_amount_is_positive_power_of_two validate :playoff_teams_amount_is_positive_power_of_two
validate :timer_reason_text_matches_reason
alias_attribute :owner, :user alias_attribute :owner, :user
after_initialize :generate_code after_initialize :generate_code
after_create_commit :ensure_default_beamer! after_create_commit :ensure_default_beamer!
before_validation :normalize_timer_reason
before_validation :clear_follower_sync_token_when_disabling_read_only_mode before_validation :clear_follower_sync_token_when_disabling_read_only_mode
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state? after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
@ -100,8 +106,24 @@ class Tournament < ApplicationRecord
self.sync_auth_token = nil self.sync_auth_token = nil
end end
def normalize_timer_reason
self.timer_reason = timer_reason.presence
self.timer_reason_text = timer_reason_text&.strip&.presence
end
def timer_reason_text_matches_reason
if timer_reason == 'custom'
errors.add(:timer_reason_text, "can't be blank") if timer_reason_text.blank?
elsif timer_reason_text.present?
errors.add(:timer_reason_text, 'is only allowed when timer_reason is custom')
end
end
def saved_change_to_timer_state? def saved_change_to_timer_state?
saved_change_to_timestamp? || saved_change_to_timer_mode? saved_change_to_timestamp? ||
saved_change_to_timer_mode? ||
saved_change_to_timer_reason? ||
saved_change_to_timer_reason_text?
end end
def broadcast_timer_state_change def broadcast_timer_state_change

View File

@ -16,6 +16,8 @@ class TournamentSerializer < SimpleTournamentSerializer
end end
attribute :timer_mode attribute :timer_mode
attribute :timer_reason
attribute :timer_reason_text
attribute :sync_last_pushed_at, if: :sync_metadata_visible? do attribute :sync_last_pushed_at, if: :sync_metadata_visible? do
object.sync_last_pushed_at&.iso8601 object.sync_last_pushed_at&.iso8601

View File

@ -24,6 +24,8 @@ class TournamentSnapshotBuilder
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES) payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
payload[:timestamp] = tournament.timestamp&.iso8601 payload[:timestamp] = tournament.timestamp&.iso8601
payload[:timer_mode] = tournament.timer_mode payload[:timer_mode] = tournament.timer_mode
payload[:timer_reason] = tournament.timer_reason
payload[:timer_reason_text] = tournament.timer_reason_text
payload[:sync_generated_at] = Time.current.iso8601(6) payload[:sync_generated_at] = Time.current.iso8601(6)
payload[:teams] = teams_payload payload[:teams] = teams_payload
payload[:beamers] = beamers_payload payload[:beamers] = beamers_payload

View File

@ -69,6 +69,8 @@ class TournamentSnapshotImporter
root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code]) root_attrs[:code] = tournament.code if code_taken_by_another_tournament?(root_attrs[:code])
root_attrs[:timestamp] = parse_time(snapshot[:timestamp]) root_attrs[:timestamp] = parse_time(snapshot[:timestamp])
root_attrs[:timer_mode] = snapshot[:timer_mode] root_attrs[:timer_mode] = snapshot[:timer_mode]
root_attrs[:timer_reason] = snapshot[:timer_reason]
root_attrs[:timer_reason_text] = snapshot[:timer_reason_text]
root_attrs[:sync_source_tournament_id] = snapshot[:id] root_attrs[:sync_source_tournament_id] = snapshot[:id]
root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at root_attrs[:sync_last_imported_snapshot_at] = snapshot_generated_at
tournament.update!(root_attrs) tournament.update!(root_attrs)

View File

@ -14,6 +14,8 @@ class TournamentSyncSchema
intermediate_round_participants_amount intermediate_round_participants_amount
timestamp timestamp
timer_mode timer_mode
timer_reason
timer_reason_text
].freeze, ].freeze,
ignored: %w[ ignored: %w[
user_id user_id

View File

@ -7,7 +7,9 @@ class TournamentTimerBroadcastPayload
type: 'timer.updated', type: 'timer.updated',
tournament_id: tournament.id, tournament_id: tournament.id,
timestamp: tournament.timestamp&.iso8601, timestamp: tournament.timestamp&.iso8601,
timer_mode: tournament.timer_mode timer_mode: tournament.timer_mode,
timer_reason: tournament.timer_reason,
timer_reason_text: tournament.timer_reason_text
} }
end end
end end

View File

@ -0,0 +1,6 @@
class AddTimerReasonToTournaments < ActiveRecord::Migration[7.0]
def change
add_column :tournaments, :timer_reason, :string
add_column :tournaments, :timer_reason_text, :text
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do ActiveRecord::Schema[7.0].define(version: 2026_04_28_120000) do
create_table "beamers", force: :cascade do |t| create_table "beamers", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "display_state", default: "during_game", null: false t.string "display_state", default: "during_game", null: false
@ -167,6 +167,8 @@ ActiveRecord::Schema[7.0].define(version: 2026_04_26_121000) do
t.string "sync_last_push_error" t.string "sync_last_push_error"
t.datetime "sync_last_imported_snapshot_at" t.datetime "sync_last_imported_snapshot_at"
t.string "timer_mode" t.string "timer_mode"
t.string "timer_reason"
t.text "timer_reason_text"
t.index ["code"], name: "index_tournaments_on_code", unique: true t.index ["code"], name: "index_tournaments_on_code", unique: true
t.index ["user_id"], name: "index_tournaments_on_user_id" t.index ["user_id"], name: "index_tournaments_on_user_id"
end end

View File

@ -15,6 +15,8 @@ Frontend should treat timer state as:
- no timer: `timestamp = null`, `timer_mode = null` - no timer: `timestamp = null`, `timer_mode = null`
- countdown: `timer_mode = "countdown"` - countdown: `timer_mode = "countdown"`
- countup: `timer_mode = "countup"` - countup: `timer_mode = "countup"`
- optional reason: `timer_reason` may be `tournament_start`, `playoff_start`, `match_end`, or `custom`
- custom reason: `timer_reason = "custom"` and `timer_reason_text` contains the display text
## Read Flow ## Read Flow
@ -31,7 +33,9 @@ Response shape:
```json ```json
{ {
"timestamp": "2026-04-24T10:15:00Z", "timestamp": "2026-04-24T10:15:00Z",
"timer_mode": "countdown" "timer_mode": "countdown",
"timer_reason": "tournament_start",
"timer_reason_text": null
} }
``` ```
@ -40,6 +44,13 @@ Possible values:
- `timer_mode = "countdown"` - `timer_mode = "countdown"`
- `timer_mode = "countup"` - `timer_mode = "countup"`
- `timer_mode = null` - `timer_mode = null`
- `timer_reason = "tournament_start"`
- `timer_reason = "playoff_start"`
- `timer_reason = "match_end"`
- `timer_reason = "custom"`
- `timer_reason = null`
`timer_reason_text` is only used when `timer_reason = "custom"`.
## Meaning Of `timestamp` ## Meaning Of `timestamp`
@ -103,7 +114,8 @@ Request:
```json ```json
{ {
"timestamp_seconds": 120 "timestamp_seconds": 120,
"timer_reason": "tournament_start"
} }
``` ```
@ -120,7 +132,8 @@ Request:
```json ```json
{ {
"timestamp": "2026-04-24T10:15:00Z" "timestamp": "2026-04-24T10:15:00Z",
"timer_reason": "playoff_start"
} }
``` ```
@ -129,7 +142,9 @@ Optional explicit mode:
```json ```json
{ {
"timestamp": "2026-04-24T10:15:00Z", "timestamp": "2026-04-24T10:15:00Z",
"timer_mode": "countdown" "timer_mode": "countdown",
"timer_reason": "custom",
"timer_reason_text": "Opening ceremony ran long"
} }
``` ```
@ -139,7 +154,8 @@ Request:
```json ```json
{ {
"timer_mode": "countup" "timer_mode": "countup",
"timer_reason": "match_end"
} }
``` ```
@ -157,7 +173,9 @@ Backend also accepts explicit timestamp with countup mode:
```json ```json
{ {
"timestamp": "2026-04-24T10:00:00Z", "timestamp": "2026-04-24T10:00:00Z",
"timer_mode": "countup" "timer_mode": "countup",
"timer_reason": "custom",
"timer_reason_text": "Manual overtime"
} }
``` ```
@ -179,10 +197,12 @@ Request:
Behavior: Behavior:
- backend clears both fields - backend clears all timer fields
- response returns: - response returns:
- `timestamp = null` - `timestamp = null`
- `timer_mode = null` - `timer_mode = null`
- `timer_reason = null`
- `timer_reason_text = null`
## Validation Rules ## Validation Rules
@ -194,6 +214,9 @@ Backend rejects:
- `timestamp_seconds` together with `timer_mode = "countup"` - `timestamp_seconds` together with `timer_mode = "countup"`
- `timer_mode` by itself unless it is `countup` - `timer_mode` by itself unless it is `countup`
- `clear_timer` mixed with timer values - `clear_timer` mixed with timer values
- invalid `timer_reason`
- `timer_reason = "custom"` without `timer_reason_text`
- `timer_reason_text` with any non-custom `timer_reason`
Countdown timestamps must be future timestamps. Countdown timestamps must be future timestamps.
Countup timestamps may be current or past timestamps. Countup timestamps may be current or past timestamps.
@ -223,7 +246,9 @@ Payload:
"type": "timer.updated", "type": "timer.updated",
"tournament_id": 123, "tournament_id": 123,
"timestamp": "2026-04-24T10:15:00Z", "timestamp": "2026-04-24T10:15:00Z",
"timer_mode": "countdown" "timer_mode": "countdown",
"timer_reason": "tournament_start",
"timer_reason_text": null
} }
``` ```
@ -243,7 +268,7 @@ Frontend rule:
Recommended flow: Recommended flow:
1. Load tournament via `GET /tournaments/:id` 1. Load tournament via `GET /tournaments/:id`
2. Read `timer_mode` and `timestamp` 2. Read `timer_mode`, `timestamp`, `timer_reason`, and `timer_reason_text`
3. Open ActionCable subscription for same tournament id 3. Open ActionCable subscription for same tournament id
4. Replace local timer state whenever `timer.updated` arrives 4. Replace local timer state whenever `timer.updated` arrives
5. Derive displayed time from `Date.now()` plus stored backend timestamp 5. Derive displayed time from `Date.now()` plus stored backend timestamp
@ -252,9 +277,11 @@ Recommended client state:
```ts ```ts
type TimerState = type TimerState =
| { mode: null; timestampIso: null } | { mode: null; timestampIso: null; reason: null; reasonText: null }
| { mode: "countdown"; timestampIso: string } | { mode: "countdown"; timestampIso: string; reason: TimerReason | null; reasonText: string | null }
| { mode: "countup"; timestampIso: string }; | { mode: "countup"; timestampIso: string; reason: TimerReason | null; reasonText: string | null };
type TimerReason = "tournament_start" | "playoff_start" | "match_end" | "custom";
``` ```
Suggested derivation logic: Suggested derivation logic:
@ -283,7 +310,7 @@ Recommended rendering:
Recommended local ticking: Recommended local ticking:
- keep backend state as timestamp + mode - keep backend state as timestamp + mode + reason
- update rendered value with local interval - update rendered value with local interval
- do not poll backend every second - do not poll backend every second
@ -300,7 +327,7 @@ Timer state is included in tournament sync snapshots.
That means: That means:
- leader timer changes propagate to follower - leader timer changes propagate to follower
- follower tournament exposes same `timestamp` / `timer_mode` - follower tournament exposes same `timestamp` / `timer_mode` / `timer_reason` / `timer_reason_text`
- follower websocket subscribers receive `timer.updated` - follower websocket subscribers receive `timer.updated`
- follower still rejects timer writes while read only - follower still rejects timer writes while read only
@ -321,5 +348,5 @@ Implemented coverage:
Current note: Current note:
- single-backend HTTP E2E timer specs pass - single-backend and local dual-server HTTP E2E timer specs pass
- follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet - follower blackbox task still has existing Docker/network issue in follow setup, so production-style follow stack verification is not green yet

View File

@ -70,6 +70,8 @@ Payload:
"intermediate_round_participants_amount": 0, "intermediate_round_participants_amount": 0,
"timestamp": null, "timestamp": null,
"timer_mode": null, "timer_mode": null,
"timer_reason": null,
"timer_reason_text": null,
"owner_username": "owner", "owner_username": "owner",
"stages": [], "stages": [],
"teams": [], "teams": [],

View File

@ -30,9 +30,12 @@ module TurniereE2E
timer_response = patch!( timer_response = patch!(
"/tournaments/#{tournament.fetch(:id)}/timer", "/tournaments/#{tournament.fetch(:id)}/timer",
body: { timestamp_seconds: 600 } body: { timestamp_seconds: 600, timer_reason: 'tournament_start' }
) )
checkpoints[:timer_set] = { timestamp: timer_response.fetch(:timestamp) } checkpoints[:timer_set] = {
timestamp: timer_response.fetch(:timestamp),
timer_reason: timer_response.fetch(:timer_reason)
}
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :timer_set return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :timer_set
upcoming_matches = get!( upcoming_matches = get!(

View File

@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do
expected_keys = %i[ expected_keys = %i[
id name code public description playoff_teams_amount id name code public description playoff_teams_amount
instant_finalists_amount intermediate_round_participants_amount instant_finalists_amount intermediate_round_participants_amount
timestamp timer_mode owner_username stages teams team_action_lists timestamp timer_mode timer_reason timer_reason_text owner_username stages teams team_action_lists
] ]
expect(json.keys).to match_array(expected_keys) expect(json.keys).to match_array(expected_keys)
expect(json[:name]).to eq(@tournament.name) expect(json[:name]).to eq(@tournament.name)
@ -224,13 +224,20 @@ RSpec.describe TournamentsController, type: :controller do
describe 'GET #timer' do describe 'GET #timer' do
it 'returns timer state in ISO 8601 format' do it 'returns timer state in ISO 8601 format' do
timestamp = Time.utc(2026, 4, 13, 18, 0, 0) timestamp = Time.utc(2026, 4, 13, 18, 0, 0)
@tournament.update!(timestamp: timestamp, timer_mode: 'countdown') @tournament.update!(
timestamp: timestamp,
timer_mode: 'countdown',
timer_reason: 'custom',
timer_reason_text: 'Opening ceremony'
)
get :timer, params: { id: @tournament.to_param } get :timer, params: { id: @tournament.to_param }
expect(response).to be_successful expect(response).to be_successful
expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601) expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601)
expect(deserialize_response(response)[:timer_mode]).to eq('countdown') expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
expect(deserialize_response(response)[:timer_reason]).to eq('custom')
expect(deserialize_response(response)[:timer_reason_text]).to eq('Opening ceremony')
end end
end end
@ -242,35 +249,60 @@ RSpec.describe TournamentsController, type: :controller do
it 'stores timezone-aware timestamp values without shifting them' do it 'stores timezone-aware timestamp values without shifting them' do
timestamp = '2030-04-13T18:00:00+02:00' timestamp = '2030-04-13T18:00:00+02:00'
patch :set_timer, params: { id: @tournament.to_param, timestamp: timestamp } patch :set_timer, params: {
id: @tournament.to_param,
timestamp: timestamp,
timer_reason: 'tournament_start'
}
expect(response).to be_successful expect(response).to be_successful
expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601) expect(@tournament.reload.timestamp.iso8601).to eq(Time.iso8601(timestamp).utc.iso8601)
expect(@tournament.timer_reason).to eq('tournament_start')
expect(@tournament.timer_reason_text).to be_nil
expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.iso8601) expect(deserialize_response(response)[:timestamp]).to eq(@tournament.timestamp.iso8601)
expect(deserialize_response(response)[:timer_mode]).to eq('countdown') expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
expect(deserialize_response(response)[:timer_reason]).to eq('tournament_start')
end end
it 'starts countup timer at current time when timer_mode=countup' do it 'starts countup timer at current time when timer_mode=countup' do
before_request = Time.zone.now before_request = Time.zone.now
patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup' } patch :set_timer, params: {
id: @tournament.to_param,
timer_mode: 'countup',
timer_reason: 'custom',
timer_reason_text: 'Tie-break delay'
}
expect(response).to be_successful expect(response).to be_successful
expect(@tournament.reload.timer_mode).to eq('countup') expect(@tournament.reload.timer_mode).to eq('countup')
expect(@tournament.timer_reason).to eq('custom')
expect(@tournament.timer_reason_text).to eq('Tie-break delay')
expect(@tournament.timestamp).to be_between(before_request - 1.second, Time.zone.now + 1.second) expect(@tournament.timestamp).to be_between(before_request - 1.second, Time.zone.now + 1.second)
expect(deserialize_response(response)[:timer_mode]).to eq('countup') expect(deserialize_response(response)[:timer_mode]).to eq('countup')
expect(deserialize_response(response)[:timer_reason]).to eq('custom')
expect(deserialize_response(response)[:timer_reason_text]).to eq('Tie-break delay')
end end
it 'clears timer state when clear_timer=true' do it 'clears timer state when clear_timer=true' do
@tournament.update!(timestamp: Time.utc(2030, 4, 13, 18, 0, 0), timer_mode: 'countdown') @tournament.update!(
timestamp: Time.utc(2030, 4, 13, 18, 0, 0),
timer_mode: 'countdown',
timer_reason: 'custom',
timer_reason_text: 'Warm-up pause'
)
patch :set_timer, params: { id: @tournament.to_param, clear_timer: true } patch :set_timer, params: { id: @tournament.to_param, clear_timer: true }
expect(response).to be_successful expect(response).to be_successful
expect(@tournament.reload.timestamp).to be_nil expect(@tournament.reload.timestamp).to be_nil
expect(@tournament.timer_mode).to be_nil expect(@tournament.timer_mode).to be_nil
expect(@tournament.timer_reason).to be_nil
expect(@tournament.timer_reason_text).to be_nil
expect(deserialize_response(response)[:timestamp]).to be_nil expect(deserialize_response(response)[:timestamp]).to be_nil
expect(deserialize_response(response)[:timer_mode]).to be_nil expect(deserialize_response(response)[:timer_mode]).to be_nil
expect(deserialize_response(response)[:timer_reason]).to be_nil
expect(deserialize_response(response)[:timer_reason_text]).to be_nil
end end
it 'rejects timestamp_seconds for countup mode' do it 'rejects timestamp_seconds for countup mode' do
@ -279,6 +311,34 @@ RSpec.describe TournamentsController, type: :controller do
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode') expect(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode')
end end
it 'rejects invalid timer reason values' do
patch :set_timer, params: { id: @tournament.to_param, timestamp_seconds: 120, timer_reason: 'ceremony' }
expect(response).to have_http_status(:unprocessable_entity)
expect(deserialize_response(response)[:error]).to eq('Invalid timer_reason')
end
it 'requires timer_reason_text for custom timer reason' do
patch :set_timer, params: { id: @tournament.to_param, timestamp_seconds: 120, timer_reason: 'custom' }
expect(response).to have_http_status(:unprocessable_entity)
expect(deserialize_response(response)[:error]).to eq('timer_reason_text is required for custom timer_reason')
end
it 'rejects timer_reason_text for predefined timer reasons' do
patch :set_timer, params: {
id: @tournament.to_param,
timestamp_seconds: 120,
timer_reason: 'match_end',
timer_reason_text: 'Extra info'
}
expect(response).to have_http_status(:unprocessable_entity)
expect(deserialize_response(response)[:error]).to eq(
'timer_reason_text is only allowed when timer_reason is custom'
)
end
end end
describe 'POST #create' do describe 'POST #create' do

View File

@ -134,15 +134,32 @@ RSpec.describe 'Backend API surface HTTP E2E' do
forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'Not Allowed' }) forbidden_update = other_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'Not Allowed' })
unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' }) unauthenticated_update = anonymous_client.patch("/tournaments/#{tournament.fetch(:id)}", body: { name: 'No Auth' })
timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) timer_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120,
timer_reason: 'tournament_start'
})
timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer") timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer")
timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' }) timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_mode: 'countup',
timer_reason: 'custom',
timer_reason_text: 'Pause ran long'
})
timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true }) timer_clear = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true })
timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120, timestamp_seconds: 120,
timestamp: (Time.now.utc + 240).iso8601 timestamp: (Time.now.utc + 240).iso8601
}) })
timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 'nope' }) timer_invalid_reason = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120,
timer_reason: 'ceremony'
})
timer_custom_without_text = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120,
timer_reason: 'custom'
})
timer_invalid_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 'nope'
})
timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 }) timer_past = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 0 })
timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_mode: 'countup', timer_mode: 'countup',
@ -171,18 +188,31 @@ RSpec.describe 'Backend API surface HTTP E2E' do
expect(timer_seconds[:status]).to eq(200) expect(timer_seconds[:status]).to eq(200)
expect(timer_seconds.dig(:json, :timestamp)).to be_a(String) expect(timer_seconds.dig(:json, :timestamp)).to be_a(String)
expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown') expect(timer_seconds.dig(:json, :timer_mode)).to eq('countdown')
expect(timer_seconds.dig(:json, :timer_reason)).to eq('tournament_start')
expect(timer_seconds.dig(:json, :timer_reason_text)).to be_nil
expect(timer_show[:status]).to eq(200) expect(timer_show[:status]).to eq(200)
expect(timer_show.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp)) expect(timer_show.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp))
expect(timer_show.dig(:json, :timer_mode)).to eq('countdown') expect(timer_show.dig(:json, :timer_mode)).to eq('countdown')
expect(timer_show.dig(:json, :timer_reason)).to eq('tournament_start')
expect(timer_countup[:status]).to eq(200) expect(timer_countup[:status]).to eq(200)
expect(timer_countup.dig(:json, :timestamp)).to be_a(String) expect(timer_countup.dig(:json, :timestamp)).to be_a(String)
expect(timer_countup.dig(:json, :timer_mode)).to eq('countup') expect(timer_countup.dig(:json, :timer_mode)).to eq('countup')
expect(timer_countup.dig(:json, :timer_reason)).to eq('custom')
expect(timer_countup.dig(:json, :timer_reason_text)).to eq('Pause ran long')
expect(timer_clear[:status]).to eq(200) expect(timer_clear[:status]).to eq(200)
expect(timer_clear.dig(:json, :timestamp)).to be_nil expect(timer_clear.dig(:json, :timestamp)).to be_nil
expect(timer_clear.dig(:json, :timer_mode)).to be_nil expect(timer_clear.dig(:json, :timer_mode)).to be_nil
expect(timer_clear.dig(:json, :timer_reason)).to be_nil
expect(timer_clear.dig(:json, :timer_reason_text)).to be_nil
expect(timer_double[:status]).to eq(422) expect(timer_double[:status]).to eq(422)
expect(timer_double.dig(:json, :error)).to eq('Only one of timestamp or timestamp_seconds is allowed') expect(timer_double.dig(:json, :error)).to eq('Only one of timestamp or timestamp_seconds is allowed')
expect(timer_invalid_reason[:status]).to eq(422)
expect(timer_invalid_reason.dig(:json, :error)).to eq('Invalid timer_reason')
expect(timer_custom_without_text[:status]).to eq(422)
expect(timer_custom_without_text.dig(:json, :error)).to eq(
'timer_reason_text is required for custom timer_reason'
)
expect(timer_invalid_seconds[:status]).to eq(422) expect(timer_invalid_seconds[:status]).to eq(422)
expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format') expect(timer_invalid_seconds.dig(:json, :error)).to eq('Invalid seconds format')
expect(timer_past[:status]).to eq(422) expect(timer_past[:status]).to eq(422)

View File

@ -504,6 +504,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
instant_finalists_amount: tournament.fetch(:instant_finalists_amount), instant_finalists_amount: tournament.fetch(:instant_finalists_amount),
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount), intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
timestamp: tournament[:timestamp], timestamp: tournament[:timestamp],
timer_mode: tournament[:timer_mode],
timer_reason: tournament[:timer_reason],
timer_reason_text: tournament[:timer_reason_text],
teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) }, teams: tournament.fetch(:teams).map { |team| team.slice(:name, :advancing_from_group_stage) }.sort_by { |team| team.fetch(:name) },
team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list| team_action_lists: tournament.fetch(:team_action_lists, []).sort_by { |list| list.fetch(:key) }.map do |list|
{ {

View File

@ -40,24 +40,42 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do
initial_payload = cable_client.wait_for_timer_payload! initial_payload = cable_client.wait_for_timer_payload!
expect(initial_payload.fetch('timestamp')).to be_nil expect(initial_payload.fetch('timestamp')).to be_nil
expect(initial_payload.fetch('timer_mode')).to be_nil expect(initial_payload.fetch('timer_mode')).to be_nil
expect(initial_payload.fetch('timer_reason')).to be_nil
expect(initial_payload.fetch('timer_reason_text')).to be_nil
countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) countdown = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: {
timestamp_seconds: 120,
timer_reason: 'match_end'
})
expect(countdown[:status]).to eq(200) expect(countdown[:status]).to eq(200)
sync_now_if_requested!(source.fetch(:id))
countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown')
expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
expect(countdown_payload.fetch('timer_reason')).to eq('match_end')
expect(countdown_payload.fetch('timer_reason_text')).to be_nil
countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { timer_mode: 'countup' }) countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: {
timer_mode: 'countup',
timer_reason: 'custom',
timer_reason_text: 'Follower sync custom reason'
})
expect(countup[:status]).to eq(200) expect(countup[:status]).to eq(200)
sync_now_if_requested!(source.fetch(:id))
countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup')
expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp)) expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp))
expect(countup_payload.fetch('timer_reason')).to eq('custom')
expect(countup_payload.fetch('timer_reason_text')).to eq('Follower sync custom reason')
clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { clear_timer: true }) clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: { clear_timer: true })
expect(clear[:status]).to eq(200) expect(clear[:status]).to eq(200)
sync_now_if_requested!(source.fetch(:id))
cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil)
expect(cleared_payload.fetch('timestamp')).to be_nil expect(cleared_payload.fetch('timestamp')).to be_nil
expect(cleared_payload.fetch('timer_reason')).to be_nil
expect(cleared_payload.fetch('timer_reason_text')).to be_nil
ensure ensure
cable_client&.close cable_client&.close
end end
@ -95,6 +113,13 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do
response.fetch(:json) response.fetch(:json)
end end
def sync_now_if_requested!(source_tournament_id)
return unless ENV.fetch('TURNIERE_E2E_SYNC_MODE', nil) == 'manual'
response = source_client.post("/tournaments/#{source_tournament_id}/test_sync")
expect(response[:status]).to eq(200)
end
def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20) def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20)
deadline = Time.now + timeout deadline = Time.now + timeout
loop do loop do

View File

@ -31,24 +31,39 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do
initial_payload = cable_client.wait_for_timer_payload! initial_payload = cable_client.wait_for_timer_payload!
expect(initial_payload.fetch('timestamp')).to be_nil expect(initial_payload.fetch('timestamp')).to be_nil
expect(initial_payload.fetch('timer_mode')).to be_nil expect(initial_payload.fetch('timer_mode')).to be_nil
expect(initial_payload.fetch('timer_reason')).to be_nil
expect(initial_payload.fetch('timer_reason_text')).to be_nil
countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timestamp_seconds: 120 }) countdown = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120,
timer_reason: 'playoff_start'
})
expect(countdown[:status]).to eq(200) expect(countdown[:status]).to eq(200)
countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown') countdown_payload = wait_for_timer_state!(cable_client, timer_mode: 'countdown')
expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp)) expect(countdown_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
expect(countdown_payload.fetch('timer_reason')).to eq('playoff_start')
expect(countdown_payload.fetch('timer_reason_text')).to be_nil
countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { timer_mode: 'countup' }) countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_mode: 'countup',
timer_reason: 'custom',
timer_reason_text: 'Manual overtime'
})
expect(countup[:status]).to eq(200) expect(countup[:status]).to eq(200)
countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup') countup_payload = wait_for_timer_state!(cable_client, timer_mode: 'countup')
expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp)) expect(countup_payload.fetch('timestamp')).to eq(countup.dig(:json, :timestamp))
expect(countup_payload.fetch('timer_reason')).to eq('custom')
expect(countup_payload.fetch('timer_reason_text')).to eq('Manual overtime')
clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true }) clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: { clear_timer: true })
expect(clear[:status]).to eq(200) expect(clear[:status]).to eq(200)
cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil) cleared_payload = wait_for_timer_state!(cable_client, timer_mode: nil)
expect(cleared_payload.fetch('timestamp')).to be_nil expect(cleared_payload.fetch('timestamp')).to be_nil
expect(cleared_payload.fetch('timer_reason')).to be_nil
expect(cleared_payload.fetch('timer_reason_text')).to be_nil
ensure ensure
cable_client&.close cable_client&.close
end end

View File

@ -83,4 +83,27 @@ RSpec.describe 'Tournament live payloads' do
expect(payload[:matches]).to eq(TournamentMatchesPayload.build(tournament, state: 'not_started')) expect(payload[:matches]).to eq(TournamentMatchesPayload.build(tournament, state: 'not_started'))
end end
end end
describe TournamentTimerBroadcastPayload do
it 'serializes timer reason fields for websocket subscribers' do
tournament = create(
:tournament,
timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
timer_mode: 'countdown',
timer_reason: 'custom',
timer_reason_text: 'Opening ceremony'
)
payload = described_class.build(tournament)
expect(payload).to include(
type: 'timer.updated',
tournament_id: tournament.id,
timestamp: '2026-04-24T10:15:00Z',
timer_mode: 'countdown',
timer_reason: 'custom',
timer_reason_text: 'Opening ceremony'
)
end
end
end end

View File

@ -6,6 +6,12 @@ RSpec.describe TournamentSnapshotImporter do
describe '.import!' do describe '.import!' do
it 'replaces follower tournament graph using source ids as stable mapping keys' do it 'replaces follower tournament graph using source ids as stable mapping keys' do
source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) source = create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match)
source.update!(
timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
timer_mode: 'countdown',
timer_reason: 'custom',
timer_reason_text: 'Opening ceremony'
)
source.beamers.create!( source.beamers.create!(
name: 'stream-a', name: 'stream-a',
display_state: 'during_game', display_state: 'during_game',
@ -23,6 +29,8 @@ RSpec.describe TournamentSnapshotImporter do
expect(follower.sync_source_tournament_id).to eq(source.id) expect(follower.sync_source_tournament_id).to eq(source.id)
expect(follower.name).to eq(source.name) expect(follower.name).to eq(source.name)
expect(follower.timer_mode).to eq(source.timer_mode) expect(follower.timer_mode).to eq(source.timer_mode)
expect(follower.timer_reason).to eq(source.timer_reason)
expect(follower.timer_reason_text).to eq(source.timer_reason_text)
expect(follower.teams.count).to eq(snapshot.fetch(:teams).count) expect(follower.teams.count).to eq(snapshot.fetch(:teams).count)
expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count) expect(follower.beamers.count).to eq(snapshot.fetch(:beamers).count)
expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count) expect(follower.team_action_lists.count).to eq(snapshot.fetch(:team_action_lists).count)
@ -127,6 +135,11 @@ RSpec.describe TournamentSnapshotImporter do
tournament.reload tournament.reload
play_some_playoff_matches!(tournament) play_some_playoff_matches!(tournament)
tournament.update!(
timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
timer_mode: 'countdown',
timer_reason: 'playoff_start'
)
tournament.reload tournament.reload
end end
@ -186,6 +199,8 @@ RSpec.describe TournamentSnapshotImporter do
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount, intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
timestamp: tournament.timestamp&.iso8601, timestamp: tournament.timestamp&.iso8601,
timer_mode: tournament.timer_mode, timer_mode: tournament.timer_mode,
timer_reason: tournament.timer_reason,
timer_reason_text: tournament.timer_reason_text,
teams: tournament.teams.order(:name).map { |team| team.name }, teams: tournament.teams.order(:name).map { |team| team.name },
team_action_lists: tournament.team_action_lists.order(:key).map do |list| team_action_lists: tournament.team_action_lists.order(:key).map do |list|
{ {

View File

@ -525,7 +525,7 @@ def _run_blackbox_rspec(base_url, email, password, username, alt_email=E2E_ALT_E
def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"): def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"):
log_handle = open(log_path, "w") log_handle = open(log_path, "w")
process = subprocess.Popen( process = subprocess.Popen(
["/bin/sh", "-lc", command], ["/bin/sh", "-c", command],
env=env, env=env,
stdout=log_handle, stdout=log_handle,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,