Fix avanzamento quarto basket e regia senza cronometro.
Normalizza il periodo nel backend quando l'app invia "Q1" via cable, così advance_period aggiorna regia e overlay; rimuove i controlli cronometro dalla regia e rende più tollerante il decode iOS al resume sessione. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,6 +34,7 @@ module Public
|
||||
def score
|
||||
cmd = params[:cmd].presence || params[:score_action].presence
|
||||
result = Scoring::ApplyAction.new(session: @session, action: cmd).call
|
||||
@session.reload
|
||||
render json: status_payload.merge(
|
||||
set_won: result.set_won,
|
||||
match_won: result.match_won,
|
||||
|
||||
@@ -55,7 +55,7 @@ class ScoreState < ApplicationRecord
|
||||
end
|
||||
|
||||
def current_period
|
||||
(data || {})["period"].to_i.positive? ? (data || {})["period"].to_i : 1
|
||||
Scoring::PeriodData.number_from((data || {})["period"]) || 1
|
||||
end
|
||||
|
||||
def current_period_label
|
||||
|
||||
@@ -28,6 +28,7 @@ module Scoring
|
||||
def ensure_period_data(score)
|
||||
data = period_data(score)
|
||||
default_data.each { |k, v| data[k] = v unless data.key?(k) }
|
||||
PeriodData.normalize_hash!(data)
|
||||
data
|
||||
end
|
||||
|
||||
@@ -43,7 +44,7 @@ module Scoring
|
||||
end
|
||||
|
||||
def period_label(data)
|
||||
period = data["period"].to_i
|
||||
period = PeriodData.number_from(data["period"]) || 1
|
||||
return "OT" if data["overtime"]
|
||||
|
||||
board = @match.effective_board_type
|
||||
@@ -63,15 +64,17 @@ module Scoring
|
||||
rules = period_rules
|
||||
max_periods = rules.periods
|
||||
|
||||
current = PeriodData.number_from(data["period"]) || 1
|
||||
|
||||
if data["overtime"]
|
||||
data["period"] = data["period"].to_i + 1
|
||||
data["period"] = current + 1
|
||||
data["clock_secs"] = rules.overtime_duration_secs
|
||||
elsif data["period"].to_i >= max_periods
|
||||
elsif current >= max_periods
|
||||
data["overtime"] = true
|
||||
data["period"] = max_periods + 1
|
||||
data["clock_secs"] = rules.overtime_duration_secs
|
||||
else
|
||||
data["period"] = data["period"].to_i + 1
|
||||
data["period"] = current + 1
|
||||
data["clock_secs"] = rules.period_duration_secs
|
||||
end
|
||||
data["clock_running"] = false
|
||||
@@ -109,7 +112,14 @@ module Scoring
|
||||
score = ensure_score(session)
|
||||
data = ensure_period_data(score)
|
||||
%w[period clock_secs clock_running home_score away_score overtime].each do |key|
|
||||
data[key] = payload[key] if payload.key?(key)
|
||||
next unless payload.key?(key)
|
||||
|
||||
if key == "period"
|
||||
period = PeriodData.number_from(payload[key])
|
||||
data["period"] = period if period&.positive?
|
||||
else
|
||||
data[key] = payload[key]
|
||||
end
|
||||
end
|
||||
save_period_score!(score, data)
|
||||
score
|
||||
|
||||
32
backend/app/services/scoring/period_data.rb
Normal file
32
backend/app/services/scoring/period_data.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module Scoring
|
||||
# Normalizza `score_states.data["period"]` (intero o etichetta "Q2" / "2° tempo").
|
||||
module PeriodData
|
||||
module_function
|
||||
|
||||
def number_from(raw)
|
||||
case raw
|
||||
when Integer, Float
|
||||
value = raw.to_i
|
||||
value.positive? ? value : nil
|
||||
when String
|
||||
text = raw.strip
|
||||
return nil if text.empty?
|
||||
|
||||
if (match = text.match(/\AQ(\d+)\z/i))
|
||||
match[1].to_i
|
||||
elsif (match = text.match(/\A(\d+)\s*°/))
|
||||
match[1].to_i
|
||||
elsif (value = text.to_i).positive?
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_hash!(data)
|
||||
number = number_from(data["period"])
|
||||
data["period"] = number if number&.positive?
|
||||
data["period"] = 1 unless data["period"].to_i.positive?
|
||||
data
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,6 @@
|
||||
<p class="regia-sets" id="sets-line"><%= live_score_period_label(@match, @score) %></p>
|
||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||
<p class="regia-partials" id="partials-line" hidden></p>
|
||||
<p class="regia-clock-line" id="clock-line">Cronometro: <%= format_clock_secs(data["clock_secs"]) %></p>
|
||||
|
||||
<div class="regia-team-row">
|
||||
<div>
|
||||
@@ -13,7 +12,7 @@
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point_3" style="width:100%;margin-top:4px">+3</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:4px">−</button>
|
||||
</div>
|
||||
<div class="regia-center-hint" id="center-hint"><%= format_clock_secs(data["clock_secs"]) %></div>
|
||||
<div class="regia-center-hint" id="center-hint"><%= live_score_period_label(@match, @score) %></div>
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||
<div class="regia-points" id="away-points"><%= data["away_score"] || @score.away_points %></div>
|
||||
@@ -23,8 +22,4 @@
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:4px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="regia-clock-actions">
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_toggle"><%= data["clock_running"] ? "Pausa" : "Avvia" %> cronometro</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_reset">Reset</button>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="advance_period">Prossimo quarto</button>
|
||||
</div>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="advance_period" style="margin-top:12px">Prossimo quarto</button>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<p class="regia-sets" id="sets-line"><%= live_score_period_label(@match, @score) %></p>
|
||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||
<p class="regia-partials" id="partials-line" hidden></p>
|
||||
<p class="regia-clock-line" id="clock-line">Cronometro: <%= format_clock_secs(data["clock_secs"]) %></p>
|
||||
|
||||
<div class="regia-team-row">
|
||||
<div>
|
||||
@@ -11,7 +10,7 @@
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
<div class="regia-center-hint" id="center-hint"><%= format_clock_secs(data["clock_secs"]) %></div>
|
||||
<div class="regia-center-hint" id="center-hint"><%= live_score_period_label(@match, @score) %></div>
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||
<div class="regia-points" id="away-points"><%= data["away_score"] || @score.away_points %></div>
|
||||
@@ -19,8 +18,4 @@
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="regia-clock-actions">
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_toggle"><%= data["clock_running"] ? "Pausa" : "Avvia" %> cronometro</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" data-action="clock_reset">Reset</button>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="advance_period">Prossimo tempo</button>
|
||||
</div>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="advance_period" style="margin-top:12px">Prossimo tempo</button>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<header class="regia-header">
|
||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p>Regia · <%= regia_header_subtitle(@match, @score) %></p>
|
||||
<p id="regia-subtitle">Regia · <%= regia_header_subtitle(@match, @score) %></p>
|
||||
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
|
||||
<%= @stream_closed ? "Terminata" : (@session.paused? ? "In pausa" : (@on_air ? "In onda" : "In attesa")) %>
|
||||
</span>
|
||||
@@ -93,4 +93,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/regia.js?v=7"></script>
|
||||
<script src="/regia.js?v=8"></script>
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
modalCancel: document.getElementById("modal-cancel"),
|
||||
preview: document.getElementById("regia-preview"),
|
||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||
btnPause: document.getElementById("btn-pause")
|
||||
btnPause: document.getElementById("btn-pause"),
|
||||
subtitle: document.getElementById("regia-subtitle")
|
||||
};
|
||||
|
||||
let pendingAction = null;
|
||||
@@ -99,6 +100,31 @@
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function resolvePeriodLabel(score, board) {
|
||||
const raw = score.period;
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
if (typeof raw === "number" && raw > 0) {
|
||||
return board === "basket" ? `Q${raw}` : `${raw}° tempo`;
|
||||
}
|
||||
const nested = score.data || {};
|
||||
const dataPeriod = nested.period ?? nested["period"];
|
||||
const periodNum = parseInt(dataPeriod, 10);
|
||||
if (periodNum > 0) {
|
||||
if (nested.overtime || nested["overtime"]) {
|
||||
return board === "basket" ? `OT${Math.max(periodNum - 4, 1)}` : "Suppl.";
|
||||
}
|
||||
return board === "basket" ? `Q${periodNum}` : `${periodNum}° tempo`;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
function updateRegiaSubtitle(periodLabel, board) {
|
||||
if (!els.subtitle) return;
|
||||
if (board === "basket" || board === "timed") {
|
||||
els.subtitle.textContent = `Regia · ${periodLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyScorePayload(data) {
|
||||
const s = data.score;
|
||||
if (!s) return;
|
||||
@@ -109,8 +135,7 @@
|
||||
const homeSets = s.home_sets ?? s.homeSets;
|
||||
const awaySets = s.away_sets ?? s.awaySets;
|
||||
const currentSet = s.current_set ?? s.currentSet;
|
||||
const period = s.period;
|
||||
const clockSecs = s.clock_secs ?? s.clockSecs ?? s.data?.clock_secs;
|
||||
const periodLabel = resolvePeriodLabel(s, board);
|
||||
const awayEl = document.getElementById("away-points");
|
||||
const centerHint = document.getElementById("center-hint");
|
||||
const clockLine = document.getElementById("clock-line");
|
||||
@@ -119,11 +144,12 @@
|
||||
if (awayEl) awayEl.textContent = awayPts;
|
||||
|
||||
if (board === "basket" || board === "timed") {
|
||||
if (els.setsLine) els.setsLine.textContent = period || "—";
|
||||
if (els.setsLine) els.setsLine.textContent = periodLabel;
|
||||
if (els.scoreLine) els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`;
|
||||
if (centerHint) centerHint.textContent = formatClock(clockSecs);
|
||||
if (clockLine) clockLine.textContent = `Cronometro: ${formatClock(clockSecs)}`;
|
||||
if (centerHint) centerHint.textContent = periodLabel;
|
||||
updateRegiaSubtitle(periodLabel, board);
|
||||
} else if (board === "timer") {
|
||||
const clockSecs = s.clock_secs ?? s.clockSecs ?? s.data?.clock_secs;
|
||||
if (els.setsLine) els.setsLine.textContent = "Cronometro";
|
||||
if (clockLine) clockLine.textContent = formatClock(clockSecs, true);
|
||||
} else if (board === "generic") {
|
||||
@@ -184,11 +210,18 @@
|
||||
async function handleAction(action) {
|
||||
const prevHome = els.homePoints?.textContent;
|
||||
const prevAway = document.getElementById("away-points")?.textContent;
|
||||
const trigger = root.querySelector(`[data-action="${action}"]`);
|
||||
if (trigger) trigger.disabled = true;
|
||||
try {
|
||||
const data = await postScore(action);
|
||||
applyScorePayload(data);
|
||||
setBadge(data);
|
||||
|
||||
if (action === "advance_period") {
|
||||
const periodLabel = resolvePeriodLabel(data.score || {}, cfg.board);
|
||||
toast(periodLabel === "—" ? "Periodo aggiornato" : `Ora in ${periodLabel}`);
|
||||
}
|
||||
|
||||
if (cfg.board === "volley" || cfg.board === "racket") {
|
||||
if (data.set_won && action !== "close_set") {
|
||||
const winner = data.winner === "home" ? cfg.homeName : cfg.awayName;
|
||||
@@ -204,6 +237,8 @@
|
||||
const awayEl = document.getElementById("away-points");
|
||||
if (prevAway != null && awayEl) awayEl.textContent = prevAway;
|
||||
toast(e.message || "Errore");
|
||||
} finally {
|
||||
if (trigger) trigger.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,72 @@ RSpec.describe "Public regia", type: :request do
|
||||
expect(response.parsed_body.dig("score", "board_type")).to eq("basket")
|
||||
end
|
||||
|
||||
it "avanza il quarto basket via API" do
|
||||
basket_team = club.teams.create!(name: "U13 Basket", sport_key: "basket")
|
||||
basket_match = basket_team.matches.create!(opponent_name: "Avversario", sport: "basket")
|
||||
basket_session = StreamSession.create!(match: basket_match, user: user, platform: "matchlivetv", status: "live")
|
||||
basket_session.create_score_state!(
|
||||
board_type: "basket",
|
||||
home_sets: 0,
|
||||
away_sets: 0,
|
||||
home_points: 0,
|
||||
away_points: 0,
|
||||
current_set: 1,
|
||||
data: {
|
||||
"period" => 1,
|
||||
"clock_secs" => 600,
|
||||
"clock_running" => false,
|
||||
"home_score" => 0,
|
||||
"away_score" => 4,
|
||||
"overtime" => false
|
||||
}
|
||||
)
|
||||
token = Sessions::RegiaAccess.new(basket_session).issue_token!
|
||||
|
||||
patch public_regia_score_path(token), params: { cmd: "advance_period" }, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
expect(body.dig("score", "period")).to eq("Q2")
|
||||
expect(body.dig("score", "data", "period")).to eq(2)
|
||||
expect(basket_session.score_state.reload.data["period"]).to eq(2)
|
||||
end
|
||||
|
||||
it "avanza il quarto basket anche con periodo Q1 corrotto in data" do
|
||||
basket_team = club.teams.create!(name: "U13 Basket", sport_key: "basket")
|
||||
basket_match = basket_team.matches.create!(opponent_name: "Avversario", sport: "basket")
|
||||
basket_session = StreamSession.create!(match: basket_match, user: user, platform: "matchlivetv", status: "live")
|
||||
basket_session.create_score_state!(
|
||||
board_type: "basket",
|
||||
data: {
|
||||
"period" => "Q1",
|
||||
"home_score" => 0,
|
||||
"away_score" => 4,
|
||||
"clock_secs" => 600,
|
||||
"clock_running" => false,
|
||||
"overtime" => false
|
||||
}
|
||||
)
|
||||
token = Sessions::RegiaAccess.new(basket_session).issue_token!
|
||||
|
||||
patch public_regia_score_path(token), params: { cmd: "advance_period" }, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body.dig("score", "period")).to eq("Q2")
|
||||
end
|
||||
|
||||
it "non mostra controlli cronometro sulla regia basket" do
|
||||
basket_team = club.teams.create!(name: "U13 Basket", sport_key: "basket")
|
||||
basket_match = basket_team.matches.create!(opponent_name: "Avversario", sport: "basket")
|
||||
basket_session = StreamSession.create!(match: basket_match, user: user, platform: "matchlivetv", status: "live")
|
||||
basket_session.create_score_state!(board_type: "basket", data: { "period" => 1, "home_score" => 0, "away_score" => 0 })
|
||||
token = Sessions::RegiaAccess.new(basket_session).issue_token!
|
||||
|
||||
get public_regia_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Prossimo quarto")
|
||||
expect(response.body).not_to include("cronometro")
|
||||
expect(response.body).not_to include("Cronometro:")
|
||||
end
|
||||
|
||||
it "mette in pausa e riprende la diretta" do
|
||||
mtx = instance_double(
|
||||
Mediamtx::Client,
|
||||
|
||||
66
backend/spec/services/scoring/period_data_spec.rb
Normal file
66
backend/spec/services/scoring/period_data_spec.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Scoring::PeriodData do
|
||||
describe ".number_from" do
|
||||
it "legge interi e etichette basket/timed" do
|
||||
expect(described_class.number_from(2)).to eq(2)
|
||||
expect(described_class.number_from("Q3")).to eq(3)
|
||||
expect(described_class.number_from("2° tempo")).to eq(2)
|
||||
expect(described_class.number_from("Q1")).to eq(1)
|
||||
expect(described_class.number_from("")).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe "Basket advance_period con periodo corrotto" do
|
||||
let!(:user) { User.create!(email: "period@test.it", name: "U", password: "password123", role: "coach") }
|
||||
let!(:club) { Club.create!(name: "C", sport: "basket", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let!(:team) { club.teams.create!(name: "U13", sport_key: "basket") }
|
||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "basket") }
|
||||
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") }
|
||||
|
||||
it "avanza il quarto anche se data.period è la stringa Q1 (sync app mobile)" do
|
||||
session.create_score_state!(
|
||||
board_type: "basket",
|
||||
home_points: 0,
|
||||
away_points: 4,
|
||||
data: {
|
||||
"period" => "Q1",
|
||||
"home_score" => 0,
|
||||
"away_score" => 4,
|
||||
"clock_secs" => 600,
|
||||
"clock_running" => false,
|
||||
"overtime" => false
|
||||
}
|
||||
)
|
||||
|
||||
Scoring::ApplyAction.new(session: session, action: "advance_period").call
|
||||
score = session.score_state.reload
|
||||
|
||||
expect(score.data["period"]).to eq(2)
|
||||
expect(score.current_period_label).to eq("Q2")
|
||||
expect(score.as_cable_payload[:period]).to eq("Q2")
|
||||
end
|
||||
|
||||
it "normalizza period in sync Action Cable" do
|
||||
session.create_score_state!(
|
||||
board_type: "basket",
|
||||
data: { "period" => 1, "home_score" => 0, "away_score" => 0 }
|
||||
)
|
||||
|
||||
Scoring::SyncState.new(
|
||||
session: session,
|
||||
payload: {
|
||||
"type" => "score_update",
|
||||
"board_type" => "basket",
|
||||
"period" => "Q1",
|
||||
"home_score" => 2,
|
||||
"away_score" => 1
|
||||
}
|
||||
).call
|
||||
|
||||
score = session.score_state.reload
|
||||
expect(score.data["period"]).to eq(1)
|
||||
expect(score.data["home_score"]).to eq(2)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user