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
{
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
def serialize_timer_state(tournament)
{
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
@ -333,40 +337,41 @@ class TournamentsController < ApplicationController
timestamp = params[:timestamp]
timestamp_seconds = params[:timestamp_seconds]
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])
if clear_timer
if timestamp.present? || timestamp_seconds.present? || timer_mode.present?
return render json: { error: 'clear_timer cannot be combined with timestamp values or timer_mode' },
status: :unprocessable_entity
return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text)
end
params[:timestamp] = nil
params[:timer_mode] = nil
return
if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode)
return render_timer_validation_error('Invalid timer_mode')
end
if timer_mode.present? && !%w[countdown countup].include?(timer_mode)
return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity
end
timer_reason_error = validate_timer_reason_params(timer_reason, timer_reason_text)
return render_timer_validation_error(timer_reason_error) if timer_reason_error
# throw error if both timestamp and timestamp_seconds are 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
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
if timestamp_seconds.present?
begin
timestamp_seconds = Integer(timestamp_seconds)
rescue ArgumentError
return render json: { error: 'Invalid seconds format' }, status: :unprocessable_entity
return render_timer_validation_error('Invalid seconds format')
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
params[:timestamp] = parsed_time
@ -375,24 +380,63 @@ class TournamentsController < ApplicationController
begin
parsed_time = Time.zone.parse(timestamp)
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?
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
params[:timestamp] = parsed_time
params[:timer_mode] = timer_mode.presence || 'countdown'
rescue ArgumentError
return render json: { error: 'Invalid timestamp format' }, status: :unprocessable_entity
return render_timer_validation_error('Invalid timestamp format')
end
elsif timer_mode == 'countup'
params[:timestamp] = Time.zone.now
elsif timer_mode.present?
return render json: { error: 'timer_mode requires timestamp, timestamp_seconds, or clear_timer' },
status: :unprocessable_entity
return render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer')
else
return render json: { error: 'timestamp, timestamp_seconds, or clear_timer is required' },
status: :unprocessable_entity
return render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required')
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

View File

@ -3,6 +3,9 @@
require 'securerandom'
class Tournament < ApplicationRecord
TIMER_MODES = %w[countdown countup].freeze
TIMER_REASONS = %w[tournament_start playoff_start match_end custom].freeze
belongs_to :user
has_many :teams, dependent: :destroy
has_many :stages, dependent: :destroy
@ -11,16 +14,19 @@ class Tournament < ApplicationRecord
validates :name, presence: 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
validate :sync_configuration_is_complete
validate :playoff_teams_amount_is_positive_power_of_two
validate :timer_reason_text_matches_reason
alias_attribute :owner, :user
after_initialize :generate_code
after_create_commit :ensure_default_beamer!
before_validation :normalize_timer_reason
before_validation :clear_follower_sync_token_when_disabling_read_only_mode
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
@ -100,8 +106,24 @@ class Tournament < ApplicationRecord
self.sync_auth_token = nil
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?
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
def broadcast_timer_state_change

View File

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

View File

@ -24,6 +24,8 @@ class TournamentSnapshotBuilder
payload = tournament.attributes.symbolize_keys.slice(*ROOT_ATTRIBUTES)
payload[:timestamp] = tournament.timestamp&.iso8601
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[:teams] = teams_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[:timestamp] = parse_time(snapshot[:timestamp])
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_last_imported_snapshot_at] = snapshot_generated_at
tournament.update!(root_attrs)

View File

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

View File

@ -7,7 +7,9 @@ class TournamentTimerBroadcastPayload
type: 'timer.updated',
tournament_id: tournament.id,
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

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.
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|
t.string "name", 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.datetime "sync_last_imported_snapshot_at"
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 ["user_id"], name: "index_tournaments_on_user_id"
end

View File

@ -15,6 +15,8 @@ Frontend should treat timer state as:
- no timer: `timestamp = null`, `timer_mode = null`
- countdown: `timer_mode = "countdown"`
- 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
@ -31,7 +33,9 @@ Response shape:
```json
{
"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 = "countup"`
- `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`
@ -103,7 +114,8 @@ Request:
```json
{
"timestamp_seconds": 120
"timestamp_seconds": 120,
"timer_reason": "tournament_start"
}
```
@ -120,7 +132,8 @@ Request:
```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
{
"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
{
"timer_mode": "countup"
"timer_mode": "countup",
"timer_reason": "match_end"
}
```
@ -157,7 +173,9 @@ Backend also accepts explicit timestamp with countup mode:
```json
{
"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:
- backend clears both fields
- backend clears all timer fields
- response returns:
- `timestamp = null`
- `timer_mode = null`
- `timer_reason = null`
- `timer_reason_text = null`
## Validation Rules
@ -194,6 +214,9 @@ Backend rejects:
- `timestamp_seconds` together with `timer_mode = "countup"`
- `timer_mode` by itself unless it is `countup`
- `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.
Countup timestamps may be current or past timestamps.
@ -223,7 +246,9 @@ Payload:
"type": "timer.updated",
"tournament_id": 123,
"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:
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
4. Replace local timer state whenever `timer.updated` arrives
5. Derive displayed time from `Date.now()` plus stored backend timestamp
@ -252,9 +277,11 @@ Recommended client state:
```ts
type TimerState =
| { mode: null; timestampIso: null }
| { mode: "countdown"; timestampIso: string }
| { mode: "countup"; timestampIso: string };
| { mode: null; timestampIso: null; reason: null; reasonText: null }
| { mode: "countdown"; timestampIso: string; reason: TimerReason | null; reasonText: string | null }
| { mode: "countup"; timestampIso: string; reason: TimerReason | null; reasonText: string | null };
type TimerReason = "tournament_start" | "playoff_start" | "match_end" | "custom";
```
Suggested derivation logic:
@ -283,7 +310,7 @@ Recommended rendering:
Recommended local ticking:
- keep backend state as timestamp + mode
- keep backend state as timestamp + mode + reason
- update rendered value with local interval
- do not poll backend every second
@ -300,7 +327,7 @@ Timer state is included in tournament sync snapshots.
That means:
- 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 still rejects timer writes while read only
@ -321,5 +348,5 @@ Implemented coverage:
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

View File

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

View File

@ -30,9 +30,12 @@ module TurniereE2E
timer_response = patch!(
"/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
upcoming_matches = get!(

View File

@ -105,7 +105,7 @@ RSpec.describe TournamentsController, type: :controller do
expected_keys = %i[
id name code public description playoff_teams_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[:name]).to eq(@tournament.name)
@ -224,13 +224,20 @@ RSpec.describe TournamentsController, type: :controller do
describe 'GET #timer' do
it 'returns timer state in ISO 8601 format' do
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 }
expect(response).to be_successful
expect(deserialize_response(response)[:timestamp]).to eq(timestamp.iso8601)
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
@ -242,35 +249,60 @@ RSpec.describe TournamentsController, type: :controller do
it 'stores timezone-aware timestamp values without shifting them' do
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(@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)[:timer_mode]).to eq('countdown')
expect(deserialize_response(response)[:timer_reason]).to eq('tournament_start')
end
it 'starts countup timer at current time when timer_mode=countup' do
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(@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(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
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 }
expect(response).to be_successful
expect(@tournament.reload.timestamp).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)[: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
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(deserialize_response(response)[:error]).to eq('timestamp_seconds is only allowed for countdown mode')
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
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' })
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_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_double = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timestamp_seconds: 120,
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_countup_seconds = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
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.dig(:json, :timestamp)).to be_a(String)
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.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_reason)).to eq('tournament_start')
expect(timer_countup[:status]).to eq(200)
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_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.dig(:json, :timestamp)).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.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.dig(:json, :error)).to eq('Invalid seconds format')
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),
intermediate_round_participants_amount: tournament.fetch(:intermediate_round_participants_amount),
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) },
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!
expect(initial_payload.fetch('timestamp')).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)
sync_now_if_requested!(source.fetch(:id))
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('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)
sync_now_if_requested!(source.fetch(:id))
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('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 })
expect(clear[:status]).to eq(200)
sync_now_if_requested!(source.fetch(:id))
cleared_payload = wait_for_timer_state!(cable_client, timer_mode: 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
cable_client&.close
end
@ -95,6 +113,13 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do
response.fetch(:json)
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)
deadline = Time.now + timeout
loop do

View File

@ -31,24 +31,39 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do
initial_payload = cable_client.wait_for_timer_payload!
expect(initial_payload.fetch('timestamp')).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)
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('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)
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('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 })
expect(clear[:status]).to eq(200)
cleared_payload = wait_for_timer_state!(cable_client, timer_mode: 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
cable_client&.close
end

View File

@ -83,4 +83,27 @@ RSpec.describe 'Tournament live payloads' do
expect(payload[:matches]).to eq(TournamentMatchesPayload.build(tournament, state: 'not_started'))
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

View File

@ -6,6 +6,12 @@ RSpec.describe TournamentSnapshotImporter do
describe '.import!' 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.update!(
timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
timer_mode: 'countdown',
timer_reason: 'custom',
timer_reason_text: 'Opening ceremony'
)
source.beamers.create!(
name: 'stream-a',
display_state: 'during_game',
@ -23,6 +29,8 @@ RSpec.describe TournamentSnapshotImporter do
expect(follower.sync_source_tournament_id).to eq(source.id)
expect(follower.name).to eq(source.name)
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.beamers.count).to eq(snapshot.fetch(:beamers).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
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
end
@ -186,6 +199,8 @@ RSpec.describe TournamentSnapshotImporter do
intermediate_round_participants_amount: tournament.intermediate_round_participants_amount,
timestamp: tournament.timestamp&.iso8601,
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 },
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"):
log_handle = open(log_path, "w")
process = subprocess.Popen(
["/bin/sh", "-lc", command],
["/bin/sh", "-c", command],
env=env,
stdout=log_handle,
stderr=subprocess.STDOUT,