Merge remote-tracking branch 'origin/master' into codex/tur-223-match-metadata

This commit is contained in:
Daniel Schädler 2026-05-04 15:10:23 +02:00
commit 61856aab1f
7 changed files with 208 additions and 9 deletions

View File

@ -205,12 +205,13 @@ class TournamentsController < ApplicationController
private
def timer_params
{
timestamp: params[:timestamp],
timer_mode: params[:timer_mode],
timer_params = {
timer_reason: params[:timer_reason],
timer_reason_text: params[:timer_reason_text]
}
timer_params[:timestamp] = params[:timestamp] if params.key?(:timestamp)
timer_params[:timer_mode] = params[:timer_mode] if params.key?(:timer_mode)
timer_params
end
def serialize_timer_state(tournament)
@ -371,6 +372,9 @@ class TournamentsController < ApplicationController
timestamp = params[:timestamp]
timestamp_seconds = params[:timestamp_seconds]
timer_mode = params[:timer_mode]
timer_reason_param_present = params.key?(:timer_reason)
timer_reason_text_param_present = params.key?(:timer_reason_text)
timer_reason_update_requested = timer_reason_param_present || timer_reason_text_param_present
normalize_timer_reason_params
timer_reason = params[:timer_reason]
timer_reason_text = params[:timer_reason_text]
@ -384,7 +388,12 @@ class TournamentsController < ApplicationController
return render_timer_validation_error('Invalid timer_mode')
end
timer_reason_error = validate_timer_reason_params(timer_reason, timer_reason_text)
timer_reason_error = validate_timer_reason_params(
timer_reason,
timer_reason_text,
timer_reason_param_present,
timer_reason_text_param_present
)
return render_timer_validation_error(timer_reason_error) if timer_reason_error
# throw error if both timestamp and timestamp_seconds are present
@ -428,6 +437,8 @@ class TournamentsController < ApplicationController
params[:timestamp] = Time.zone.now
elsif timer_mode.present?
return render_timer_validation_error('timer_mode requires timestamp, timestamp_seconds, or clear_timer')
elsif timer_reason_update_requested
return render_timer_validation_error('timer_reason requires active timer') unless timer_active?
else
return render_timer_validation_error('timestamp, timestamp_seconds, or clear_timer is required')
end
@ -456,8 +467,17 @@ class TournamentsController < ApplicationController
params[:timer_reason_text] = nil
end
def validate_timer_reason_params(timer_reason, timer_reason_text)
def validate_timer_reason_params(
timer_reason,
timer_reason_text,
timer_reason_param_present,
timer_reason_text_param_present
)
if timer_reason_text_param_present && !timer_reason_param_present
return 'timer_reason is required when timer_reason_text is provided'
end
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)
@ -470,6 +490,10 @@ class TournamentsController < ApplicationController
'timer_reason_text is only allowed when timer_reason is custom'
end
def timer_active?
@tournament.timestamp.present? && @tournament.timer_mode.present?
end
def render_timer_validation_error(message)
render json: { error: message }, status: :unprocessable_content
end

View File

@ -185,6 +185,39 @@ Useful for:
- rehydrating synced follower state
- advanced admin tooling
### Change timer reason without restarting
After a timer is active, owner may change only the reason fields.
Backend keeps current `timestamp` and `timer_mode` unchanged.
Set a predefined reason:
```json
{
"timer_reason": "playoff_start"
}
```
Set a custom reason:
```json
{
"timer_reason": "custom",
"timer_reason_text": "Opening ceremony ran long"
}
```
Remove reason:
```json
{
"timer_reason": null
}
```
Response shape is the normal timer state with the same active `timestamp` and
`timer_mode`, plus updated reason fields.
### Clear timer
Request:
@ -215,6 +248,8 @@ Backend rejects:
- `timer_mode` by itself unless it is `countup`
- `clear_timer` mixed with timer values
- invalid `timer_reason`
- reason-only updates when no timer is active
- `timer_reason_text` without `timer_reason`
- `timer_reason = "custom"` without `timer_reason_text`
- `timer_reason_text` with any non-custom `timer_reason`
@ -338,6 +373,7 @@ Implemented coverage:
- API surface timer lifecycle:
- countdown
- countup
- reason-only set / change / remove
- clear
- validation
- [spec/e2e/http/api_surface_spec.rb](../spec/e2e/http/api_surface_spec.rb)

View File

@ -307,6 +307,53 @@ RSpec.describe TournamentsController, type: :controller do
expect(deserialize_response(response)[:timer_reason_text]).to be_nil
end
it 'updates timer reason without changing active timer clock' do
original_timestamp = Time.utc(2030, 4, 13, 18, 0, 0)
@tournament.update!(
timestamp: original_timestamp,
timer_mode: 'countdown',
timer_reason: 'tournament_start'
)
patch :set_timer, params: {
id: @tournament.to_param,
timer_reason: 'custom',
timer_reason_text: 'Schedule pause'
}
expect(response).to be_successful
expect(@tournament.reload.timestamp).to eq(original_timestamp)
expect(@tournament.timer_mode).to eq('countdown')
expect(@tournament.timer_reason).to eq('custom')
expect(@tournament.timer_reason_text).to eq('Schedule pause')
expect(deserialize_response(response)[:timestamp]).to eq(original_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('Schedule pause')
patch :set_timer, params: {
id: @tournament.to_param,
timer_reason: ''
}
expect(response).to be_successful
expect(@tournament.reload.timestamp).to eq(original_timestamp)
expect(@tournament.timer_mode).to eq('countdown')
expect(@tournament.timer_reason).to be_nil
expect(@tournament.timer_reason_text).to be_nil
expect(deserialize_response(response)[:timestamp]).to eq(original_timestamp.iso8601)
expect(deserialize_response(response)[:timer_mode]).to eq('countdown')
expect(deserialize_response(response)[:timer_reason]).to be_nil
expect(deserialize_response(response)[:timer_reason_text]).to be_nil
end
it 'rejects timer reason updates when no timer is active' do
patch :set_timer, params: { id: @tournament.to_param, timer_reason: 'match_end' }
expect(response).to have_http_status(:unprocessable_content)
expect(deserialize_response(response)[:error]).to eq('timer_reason requires active timer')
end
it 'rejects timestamp_seconds for countup mode' do
patch :set_timer, params: { id: @tournament.to_param, timer_mode: 'countup', timestamp_seconds: 120 }

View File

@ -139,6 +139,13 @@ RSpec.describe 'Backend API surface HTTP E2E' do
timer_reason: 'tournament_start'
})
timer_show = anonymous_client.get("/tournaments/#{tournament.fetch(:id)}/timer")
timer_reason_custom = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_reason: 'custom',
timer_reason_text: 'Pause ran long'
})
timer_reason_remove = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_reason: nil
})
timer_countup = owner_client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_mode: 'countup',
timer_reason: 'custom',
@ -194,6 +201,16 @@ RSpec.describe 'Backend API surface HTTP E2E' do
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_reason_custom[:status]).to eq(200)
expect(timer_reason_custom.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp))
expect(timer_reason_custom.dig(:json, :timer_mode)).to eq('countdown')
expect(timer_reason_custom.dig(:json, :timer_reason)).to eq('custom')
expect(timer_reason_custom.dig(:json, :timer_reason_text)).to eq('Pause ran long')
expect(timer_reason_remove[:status]).to eq(200)
expect(timer_reason_remove.dig(:json, :timestamp)).to eq(timer_seconds.dig(:json, :timestamp))
expect(timer_reason_remove.dig(:json, :timer_mode)).to eq('countdown')
expect(timer_reason_remove.dig(:json, :timer_reason)).to be_nil
expect(timer_reason_remove.dig(:json, :timer_reason_text)).to be_nil
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')

View File

@ -55,6 +55,35 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do
expect(countdown_payload.fetch('timer_reason')).to eq('match_end')
expect(countdown_payload.fetch('timer_reason_text')).to be_nil
reason_update = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: {
timer_reason: 'custom',
timer_reason_text: 'Follower reason after start'
})
expect(reason_update[:status]).to eq(200)
sync_now_if_requested!(source.fetch(:id))
reason_payload = wait_for_timer_payload_matching!(cable_client) do |payload|
payload.fetch('timestamp') == countdown.dig(:json, :timestamp) &&
payload.fetch('timer_mode') == 'countdown' &&
payload.fetch('timer_reason') == 'custom' &&
payload.fetch('timer_reason_text') == 'Follower reason after start'
end
expect(reason_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
reason_clear = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: {
timer_reason: nil
})
expect(reason_clear[:status]).to eq(200)
sync_now_if_requested!(source.fetch(:id))
reason_cleared_payload = wait_for_timer_payload_matching!(cable_client) do |payload|
payload.fetch('timestamp') == countdown.dig(:json, :timestamp) &&
payload.fetch('timer_mode') == 'countdown' &&
payload.fetch('timer_reason').nil? &&
payload.fetch('timer_reason_text').nil?
end
expect(reason_cleared_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
countup = source_client.patch("/tournaments/#{source.fetch(:id)}/timer", body: {
timer_mode: 'countup',
timer_reason: 'custom',
@ -121,10 +150,16 @@ RSpec.describe 'Tournament timer follower sync websocket HTTP E2E' do
end
def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20)
wait_for_timer_payload_matching!(cable_client, timeout: timeout) do |payload|
payload.fetch('timer_mode') == timer_mode
end
end
def wait_for_timer_payload_matching!(cable_client, timeout: 20)
deadline = Time.now + timeout
loop do
payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if payload.fetch('timer_mode') == timer_mode
return payload if yield(payload)
raise "follower timer websocket payload not received within #{timeout}s" if Time.now >= deadline
end

View File

@ -45,6 +45,33 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do
expect(countdown_payload.fetch('timer_reason')).to eq('playoff_start')
expect(countdown_payload.fetch('timer_reason_text')).to be_nil
reason_update = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_reason: 'custom',
timer_reason_text: 'Reason changed after start'
})
expect(reason_update[:status]).to eq(200)
reason_payload = wait_for_timer_payload_matching!(cable_client) do |payload|
payload.fetch('timestamp') == countdown.dig(:json, :timestamp) &&
payload.fetch('timer_mode') == 'countdown' &&
payload.fetch('timer_reason') == 'custom' &&
payload.fetch('timer_reason_text') == 'Reason changed after start'
end
expect(reason_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
reason_clear = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_reason: nil
})
expect(reason_clear[:status]).to eq(200)
reason_cleared_payload = wait_for_timer_payload_matching!(cable_client) do |payload|
payload.fetch('timestamp') == countdown.dig(:json, :timestamp) &&
payload.fetch('timer_mode') == 'countdown' &&
payload.fetch('timer_reason').nil? &&
payload.fetch('timer_reason_text').nil?
end
expect(reason_cleared_payload.fetch('timestamp')).to eq(countdown.dig(:json, :timestamp))
countup = client.patch("/tournaments/#{tournament.fetch(:id)}/timer", body: {
timer_mode: 'countup',
timer_reason: 'custom',
@ -90,10 +117,16 @@ RSpec.describe 'Tournament timer websocket HTTP E2E' do
end
def wait_for_timer_state!(cable_client, timer_mode:, timeout: 20)
wait_for_timer_payload_matching!(cable_client, timeout: timeout) do |payload|
payload.fetch('timer_mode') == timer_mode
end
end
def wait_for_timer_payload_matching!(cable_client, timeout: 20)
deadline = Time.now + timeout
loop do
payload = cable_client.wait_for_timer_payload!(timeout: [deadline - Time.now, 0.1].max)
return payload if payload.fetch('timer_mode') == timer_mode
return payload if yield(payload)
raise "timer websocket payload not received within #{timeout}s" if Time.now >= deadline
end

View File

@ -633,10 +633,17 @@ def _start_server(command, env, log_path="/tmp/turniere-e2e-server.log"):
return process, log_handle
def _server_env(env=None):
server_env = _env(GIT_COMMIT_SHA=_git_commit_sha())
if _rails_env(env) == "test":
server_env["WEB_CONCURRENCY"] = os.environ.get("WEB_CONCURRENCY", "1")
return server_env
def _start_test_server(host=SERVER_HOST, port=SERVER_PORT):
return _start_server(
f"{_bundle_exec('rails', 's')} -e test -b {host} -p {port}",
_env(GIT_COMMIT_SHA=_git_commit_sha()),
_server_env("test"),
)
@ -704,7 +711,7 @@ def server(c, host=SERVER_HOST, port=SERVER_PORT, env=None):
"""Start the backend locally in development."""
c.run(
f"RAILS_ENV={_rails_env(env)} {_bundle_exec('rails', 's')} -b {host} -p {port}",
env=_env(GIT_COMMIT_SHA=_git_commit_sha()),
env=_server_env(env),
pty=True,
)