diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb index 3625ef7..bc29529 100644 --- a/app/controllers/tournaments_controller.rb +++ b/app/controllers/tournaments_controller.rb @@ -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 - end - - params[:timestamp] = nil - params[:timer_mode] = nil - return + return validate_clear_timer_params(timestamp, timestamp_seconds, timer_mode, timer_reason, timer_reason_text) end - if timer_mode.present? && !%w[countdown countup].include?(timer_mode) - return render json: { error: 'Invalid timer_mode' }, status: :unprocessable_entity + if timer_mode.present? && !Tournament::TIMER_MODES.include?(timer_mode) + return render_timer_validation_error('Invalid timer_mode') 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 diff --git a/app/models/tournament.rb b/app/models/tournament.rb index 3debf27..9decf6c 100644 --- a/app/models/tournament.rb +++ b/app/models/tournament.rb @@ -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 diff --git a/app/serializers/tournament_serializer.rb b/app/serializers/tournament_serializer.rb index 46009a1..2691690 100644 --- a/app/serializers/tournament_serializer.rb +++ b/app/serializers/tournament_serializer.rb @@ -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 diff --git a/app/services/tournament_snapshot_builder.rb b/app/services/tournament_snapshot_builder.rb index 8ca4a26..0975a78 100644 --- a/app/services/tournament_snapshot_builder.rb +++ b/app/services/tournament_snapshot_builder.rb @@ -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 diff --git a/app/services/tournament_snapshot_importer.rb b/app/services/tournament_snapshot_importer.rb index af298c2..c2868da 100644 --- a/app/services/tournament_snapshot_importer.rb +++ b/app/services/tournament_snapshot_importer.rb @@ -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) diff --git a/app/services/tournament_sync_schema.rb b/app/services/tournament_sync_schema.rb index 64b4d33..d698bf5 100644 --- a/app/services/tournament_sync_schema.rb +++ b/app/services/tournament_sync_schema.rb @@ -14,6 +14,8 @@ class TournamentSyncSchema intermediate_round_participants_amount timestamp timer_mode + timer_reason + timer_reason_text ].freeze, ignored: %w[ user_id diff --git a/app/services/tournament_timer_broadcast_payload.rb b/app/services/tournament_timer_broadcast_payload.rb index 9b790de..80bdf5c 100644 --- a/app/services/tournament_timer_broadcast_payload.rb +++ b/app/services/tournament_timer_broadcast_payload.rb @@ -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 diff --git a/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb b/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb new file mode 100644 index 0000000..54cb911 --- /dev/null +++ b/db/migrate/20260428120000_add_timer_reason_to_tournaments.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 002741a..9ebcac7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[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 diff --git a/doc/timer_api.md b/doc/timer_api.md index dcb1169..f1d0622 100644 --- a/doc/timer_api.md +++ b/doc/timer_api.md @@ -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 diff --git a/doc/tournament_live_updates.md b/doc/tournament_live_updates.md index 9d8f9f4..fb87d47 100644 --- a/doc/tournament_live_updates.md +++ b/doc/tournament_live_updates.md @@ -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": [], diff --git a/e2e/lib/scenario_runner.rb b/e2e/lib/scenario_runner.rb index eb67787..664db82 100644 --- a/e2e/lib/scenario_runner.rb +++ b/e2e/lib/scenario_runner.rb @@ -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!( diff --git a/spec/controllers/tournaments_controller_spec.rb b/spec/controllers/tournaments_controller_spec.rb index 9a9cb9a..2f95deb 100644 --- a/spec/controllers/tournaments_controller_spec.rb +++ b/spec/controllers/tournaments_controller_spec.rb @@ -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 diff --git a/spec/e2e/http/api_surface_spec.rb b/spec/e2e/http/api_surface_spec.rb index 2c1b5ed..8575991 100644 --- a/spec/e2e/http/api_surface_spec.rb +++ b/spec/e2e/http/api_surface_spec.rb @@ -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) diff --git a/spec/e2e/http/tournament_follow_sync_spec.rb b/spec/e2e/http/tournament_follow_sync_spec.rb index 1800ad6..aa3f87c 100644 --- a/spec/e2e/http/tournament_follow_sync_spec.rb +++ b/spec/e2e/http/tournament_follow_sync_spec.rb @@ -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| { diff --git a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb index 3b8c364..4112807 100644 --- a/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_follow_sync_websocket_spec.rb @@ -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 diff --git a/spec/e2e/http/tournament_timer_websocket_spec.rb b/spec/e2e/http/tournament_timer_websocket_spec.rb index 1431476..73913bb 100644 --- a/spec/e2e/http/tournament_timer_websocket_spec.rb +++ b/spec/e2e/http/tournament_timer_websocket_spec.rb @@ -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 diff --git a/spec/services/tournament_live_payload_spec.rb b/spec/services/tournament_live_payload_spec.rb index cd91ab4..21cb34b 100644 --- a/spec/services/tournament_live_payload_spec.rb +++ b/spec/services/tournament_live_payload_spec.rb @@ -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 diff --git a/spec/services/tournament_snapshot_importer_spec.rb b/spec/services/tournament_snapshot_importer_spec.rb index 03f9b1a..14f3795 100644 --- a/spec/services/tournament_snapshot_importer_spec.rb +++ b/spec/services/tournament_snapshot_importer_spec.rb @@ -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| { diff --git a/tasks.py b/tasks.py index 3bb5306..69986f7 100644 --- a/tasks.py +++ b/tasks.py @@ -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,