mirror of
https://github.com/jcwimer/wrestlingApp
synced 2026-04-11 16:01:56 +00:00
New stats page, scoreboard, and live scores pages.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -21,6 +21,7 @@ tmp
|
||||
.rvmrc
|
||||
deploy/prod.env
|
||||
frontend/node_modules
|
||||
node_modules
|
||||
.aider*
|
||||
|
||||
# Ignore cypress test results
|
||||
@@ -33,4 +34,7 @@ cypress-tests/cypress/videos
|
||||
# repomix-output.xml
|
||||
|
||||
# generated by cine mcp settings
|
||||
~/
|
||||
~/
|
||||
|
||||
/.ruby-lsp
|
||||
.codex
|
||||
|
||||
11
AGENTS.md
Normal file
11
AGENTS.md
Normal file
@@ -0,0 +1,11 @@
|
||||
- I have two ways to run rails commands in the repo. Either use rvm with `rvm use 4.0.1; rvm gemset use wrestlingdev;` or use docker with `docker run -it -v $(pwd):/rails wrestlingdev-dev <rails command>`
|
||||
- If the docker image doesn't exist, use the build command: `docker build -t wrestlingdev-dev -f deploy/rails-dev-Dockerfile .`
|
||||
- If the Gemfile changes, you need to rebuild the docker image: `docker build -t wrestlingdev-dev -f deploy/rails-dev-Dockerfile .`
|
||||
- Do not add unnecessary comments to the code where you remove things.
|
||||
- Write as little code as possible. I do not want crazy non standard rails implementations.
|
||||
- This project is using propshaft and importmap.
|
||||
- Stimulus is used for javascript.
|
||||
- Cypress tests are created for js tests. They can be found in cypress-tests/cypress
|
||||
- Cypress tests can be run with docker: bash cypress-tests/run-cypress-tests.sh
|
||||
- javascript tests are through vitest. See `vitest.config.js`. Run `npm run test:js`
|
||||
- importmap pins in `importmap.rb` and aliases in `vitest.config.js` need to match.
|
||||
@@ -18,6 +18,9 @@ window.Stimulus = application;
|
||||
import WrestlerColorController from "controllers/wrestler_color_controller";
|
||||
import MatchScoreController from "controllers/match_score_controller";
|
||||
import MatchDataController from "controllers/match_data_controller";
|
||||
import MatchStateController from "controllers/match_state_controller";
|
||||
import MatchScoreboardController from "controllers/match_scoreboard_controller";
|
||||
import MatStateController from "controllers/mat_state_controller";
|
||||
import MatchSpectateController from "controllers/match_spectate_controller";
|
||||
import UpMatchesConnectionController from "controllers/up_matches_connection_controller";
|
||||
|
||||
@@ -25,6 +28,9 @@ import UpMatchesConnectionController from "controllers/up_matches_connection_con
|
||||
application.register("wrestler-color", WrestlerColorController);
|
||||
application.register("match-score", MatchScoreController);
|
||||
application.register("match-data", MatchDataController);
|
||||
application.register("match-state", MatchStateController);
|
||||
application.register("match-scoreboard", MatchScoreboardController);
|
||||
application.register("mat-state", MatStateController);
|
||||
application.register("match-spectate", MatchSpectateController);
|
||||
application.register("up-matches-connection", UpMatchesConnectionController);
|
||||
|
||||
|
||||
163
app/assets/javascripts/controllers/mat_state_controller.js
Normal file
163
app/assets/javascripts/controllers/mat_state_controller.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
tournamentId: Number,
|
||||
matId: Number,
|
||||
boutNumber: Number,
|
||||
matchId: Number,
|
||||
selectMatchUrl: String,
|
||||
weightLabel: String,
|
||||
w1Id: Number,
|
||||
w2Id: Number,
|
||||
w1Name: String,
|
||||
w2Name: String,
|
||||
w1School: String,
|
||||
w2School: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.boundHandleSubmit = this.handleSubmit.bind(this)
|
||||
this.saveSelectedBout()
|
||||
this.broadcastSelectedBout()
|
||||
this.element.addEventListener("submit", this.boundHandleSubmit)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("submit", this.boundHandleSubmit)
|
||||
}
|
||||
|
||||
storageKey() {
|
||||
return `mat-selected-bout:${this.tournamentIdValue}:${this.matIdValue}`
|
||||
}
|
||||
|
||||
saveSelectedBout() {
|
||||
if (!this.matIdValue || this.matIdValue <= 0) return
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(this.storageKey(), JSON.stringify({
|
||||
boutNumber: this.boutNumberValue,
|
||||
matchId: this.matchIdValue,
|
||||
updatedAt: Date.now()
|
||||
}))
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
broadcastSelectedBout() {
|
||||
if (!this.hasSelectMatchUrlValue || !this.selectMatchUrlValue) return
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
const body = new URLSearchParams()
|
||||
if (this.matchIdValue) body.set("match_id", this.matchIdValue.toString())
|
||||
if (this.boutNumberValue) body.set("bout_number", this.boutNumberValue.toString())
|
||||
|
||||
const lastMatchResult = this.loadLastMatchResult()
|
||||
if (lastMatchResult) body.set("last_match_result", lastMatchResult)
|
||||
|
||||
fetch(this.selectMatchUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-Token": csrfToken || "",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
|
||||
},
|
||||
body,
|
||||
credentials: "same-origin"
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
lastMatchResultStorageKey() {
|
||||
return `mat-last-match-result:${this.tournamentIdValue}:${this.matIdValue}`
|
||||
}
|
||||
|
||||
saveLastMatchResult(text) {
|
||||
if (!this.matIdValue || this.matIdValue <= 0) return
|
||||
|
||||
try {
|
||||
if (text) {
|
||||
window.localStorage.setItem(this.lastMatchResultStorageKey(), text)
|
||||
} else {
|
||||
window.localStorage.removeItem(this.lastMatchResultStorageKey())
|
||||
}
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
loadLastMatchResult() {
|
||||
try {
|
||||
return window.localStorage.getItem(this.lastMatchResultStorageKey()) || ""
|
||||
} catch (_error) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
const form = event.target
|
||||
if (!(form instanceof HTMLFormElement)) return
|
||||
|
||||
const resultText = this.buildLastMatchResult(form)
|
||||
if (!resultText) return
|
||||
|
||||
this.saveLastMatchResult(resultText)
|
||||
this.broadcastCurrentState(resultText)
|
||||
}
|
||||
|
||||
broadcastCurrentState(lastMatchResult) {
|
||||
if (!this.hasSelectMatchUrlValue || !this.selectMatchUrlValue) return
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
const body = new URLSearchParams()
|
||||
if (this.matchIdValue) body.set("match_id", this.matchIdValue.toString())
|
||||
if (this.boutNumberValue) body.set("bout_number", this.boutNumberValue.toString())
|
||||
if (lastMatchResult) body.set("last_match_result", lastMatchResult)
|
||||
|
||||
fetch(this.selectMatchUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-Token": csrfToken || "",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
|
||||
},
|
||||
body,
|
||||
credentials: "same-origin",
|
||||
keepalive: true
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
buildLastMatchResult(form) {
|
||||
const winnerId = form.querySelector("#match_winner_id")?.value
|
||||
const winType = form.querySelector("#match_win_type")?.value
|
||||
const score = form.querySelector("#final-score-field")?.value
|
||||
if (!winnerId || !winType) return ""
|
||||
|
||||
const winner = this.participantDataForId(winnerId)
|
||||
const loser = this.loserParticipantData(winnerId)
|
||||
if (!winner || !loser) return ""
|
||||
|
||||
const weightLabel = this.hasWeightLabelValue ? this.weightLabelValue : ""
|
||||
return `${weightLabel} lbs - ${winner.name} (${winner.school}) ${winType} ${loser.name} (${loser.school}) ${score || ""}`.trim()
|
||||
}
|
||||
|
||||
participantDataForId(participantId) {
|
||||
const normalizedId = String(participantId)
|
||||
if (normalizedId === String(this.w1IdValue)) {
|
||||
return { name: this.w1NameValue, school: this.w1SchoolValue }
|
||||
}
|
||||
if (normalizedId === String(this.w2IdValue)) {
|
||||
return { name: this.w2NameValue, school: this.w2SchoolValue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
loserParticipantData(winnerId) {
|
||||
const normalizedId = String(winnerId)
|
||||
if (normalizedId === String(this.w1IdValue)) {
|
||||
return { name: this.w2NameValue, school: this.w2SchoolValue }
|
||||
}
|
||||
if (normalizedId === String(this.w2IdValue)) {
|
||||
return { name: this.w1NameValue, school: this.w1SchoolValue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,44 @@ import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"winType", "winnerSelect", "submitButton", "dynamicScoreInput",
|
||||
"winType", "overtimeSelect", "winnerSelect", "submitButton", "dynamicScoreInput",
|
||||
"finalScoreField", "validationAlerts", "pinTimeTip"
|
||||
]
|
||||
|
||||
static values = {
|
||||
winnerScore: { type: String, default: "0" },
|
||||
loserScore: { type: String, default: "0" }
|
||||
loserScore: { type: String, default: "0" },
|
||||
pinMinutes: { type: String, default: "0" },
|
||||
pinSeconds: { type: String, default: "00" },
|
||||
manualOverride: { type: Boolean, default: false },
|
||||
finished: { type: Boolean, default: false }
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log("Match score controller connected")
|
||||
// Use setTimeout to ensure the DOM is fully loaded
|
||||
this.boundMarkManualOverride = this.markManualOverride.bind(this)
|
||||
this.element.addEventListener("input", this.boundMarkManualOverride)
|
||||
this.element.addEventListener("change", this.boundMarkManualOverride)
|
||||
if (this.finishedValue) {
|
||||
this.validateForm()
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.updateScoreInput()
|
||||
this.validateForm()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("input", this.boundMarkManualOverride)
|
||||
this.element.removeEventListener("change", this.boundMarkManualOverride)
|
||||
}
|
||||
|
||||
winTypeChanged() {
|
||||
if (this.finishedValue) {
|
||||
this.validateForm()
|
||||
return
|
||||
}
|
||||
this.updateScoreInput()
|
||||
this.validateForm()
|
||||
}
|
||||
@@ -30,6 +49,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
updateScoreInput() {
|
||||
if (this.finishedValue) return
|
||||
const winType = this.winTypeTarget.value
|
||||
this.dynamicScoreInputTarget.innerHTML = ""
|
||||
|
||||
@@ -47,6 +67,9 @@ export default class extends Controller {
|
||||
|
||||
this.dynamicScoreInputTarget.appendChild(minuteInput)
|
||||
this.dynamicScoreInputTarget.appendChild(secondInput)
|
||||
|
||||
minuteInput.querySelector("input").value = this.pinMinutesValue || "0"
|
||||
secondInput.querySelector("input").value = this.pinSecondsValue || "00"
|
||||
|
||||
// Add event listeners to the new inputs
|
||||
const inputs = this.dynamicScoreInputTarget.querySelectorAll("input")
|
||||
@@ -111,6 +134,43 @@ export default class extends Controller {
|
||||
this.validateForm()
|
||||
}
|
||||
|
||||
applyDefaultResults(defaults = {}) {
|
||||
if (this.manualOverrideValue || this.finishedValue) return
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "winnerId") && this.hasWinnerSelectTarget) {
|
||||
this.winnerSelectTarget.value = defaults.winnerId ? String(defaults.winnerId) : ""
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "overtimeType") && this.hasOvertimeSelectTarget) {
|
||||
const allowedValues = Array.from(this.overtimeSelectTarget.options).map((option) => option.value)
|
||||
this.overtimeSelectTarget.value = allowedValues.includes(defaults.overtimeType) ? defaults.overtimeType : ""
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "winnerScore")) {
|
||||
this.winnerScoreValue = String(defaults.winnerScore)
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "loserScore")) {
|
||||
this.loserScoreValue = String(defaults.loserScore)
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "pinMinutes")) {
|
||||
this.pinMinutesValue = String(defaults.pinMinutes)
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "pinSeconds")) {
|
||||
this.pinSecondsValue = String(defaults.pinSeconds).padStart(2, "0")
|
||||
}
|
||||
|
||||
this.updateScoreInput()
|
||||
this.validateForm()
|
||||
}
|
||||
|
||||
markManualOverride(event) {
|
||||
if (!event.isTrusted) return
|
||||
this.manualOverrideValue = true
|
||||
}
|
||||
|
||||
updatePinTimeScore() {
|
||||
const minuteInput = this.dynamicScoreInputTarget.querySelector("#minutes")
|
||||
const secondInput = this.dynamicScoreInputTarget.querySelector("#seconds")
|
||||
@@ -234,4 +294,4 @@ export default class extends Controller {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { loadJson } from "match-state-transport"
|
||||
import {
|
||||
buildScoreboardContext,
|
||||
connectionPlan,
|
||||
applyMatchPayloadContext,
|
||||
applyMatPayloadContext,
|
||||
applyStatePayloadContext,
|
||||
matchStorageKey,
|
||||
selectedBoutNumber,
|
||||
selectedBoutStorageKey as selectedBoutStorageKeyFromState,
|
||||
storageChangePlan
|
||||
} from "match-state-scoreboard-state"
|
||||
import {
|
||||
boardColors,
|
||||
emptyBoardViewModel,
|
||||
mainClockRunning as mainClockRunningFromPresenters,
|
||||
nextTimerBannerState,
|
||||
populatedBoardViewModel,
|
||||
timerBannerRenderState
|
||||
} from "match-state-scoreboard-presenters"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"redSection",
|
||||
"centerSection",
|
||||
"greenSection",
|
||||
"emptyState",
|
||||
"redName",
|
||||
"redSchool",
|
||||
"redScore",
|
||||
"redTimerIndicator",
|
||||
"greenName",
|
||||
"greenSchool",
|
||||
"greenScore",
|
||||
"greenTimerIndicator",
|
||||
"clock",
|
||||
"periodLabel",
|
||||
"weightLabel",
|
||||
"boutLabel",
|
||||
"timerBanner",
|
||||
"timerBannerLabel",
|
||||
"timerBannerClock",
|
||||
"redStats",
|
||||
"greenStats",
|
||||
"lastMatchResult"
|
||||
]
|
||||
|
||||
static values = {
|
||||
sourceMode: { type: String, default: "localstorage" },
|
||||
displayMode: { type: String, default: "fullscreen" },
|
||||
matchId: Number,
|
||||
matId: Number,
|
||||
tournamentId: Number,
|
||||
initialBoutNumber: Number
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.applyControllerContext(buildScoreboardContext({
|
||||
initialBoutNumber: this.initialBoutNumberValue,
|
||||
matchId: this.matchIdValue
|
||||
}))
|
||||
|
||||
const plan = connectionPlan(this.sourceModeValue, this.currentMatchId)
|
||||
if (plan.useStorageListener) {
|
||||
this.storageListener = this.handleStorageChange.bind(this)
|
||||
window.addEventListener("storage", this.storageListener)
|
||||
}
|
||||
if (plan.loadSelectedBout) {
|
||||
this.loadSelectedBoutNumber()
|
||||
}
|
||||
if (plan.subscribeMat) {
|
||||
this.setupMatSubscription()
|
||||
}
|
||||
if (plan.loadLocalState) {
|
||||
this.loadStateFromLocalStorage()
|
||||
}
|
||||
if (plan.subscribeMatch) {
|
||||
this.setupMatchSubscription(plan.matchId)
|
||||
}
|
||||
|
||||
this.startTicking()
|
||||
this.render()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.storageListener) {
|
||||
window.removeEventListener("storage", this.storageListener)
|
||||
this.storageListener = null
|
||||
}
|
||||
this.unsubscribeMatSubscription()
|
||||
this.unsubscribeMatchSubscription()
|
||||
if (this.tickInterval) {
|
||||
window.clearInterval(this.tickInterval)
|
||||
this.tickInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
setupMatSubscription() {
|
||||
if (!window.App || !window.App.cable || !this.matIdValue) return
|
||||
if (this.matSubscription) return
|
||||
|
||||
this.matSubscription = App.cable.subscriptions.create(
|
||||
{
|
||||
channel: "MatScoreboardChannel",
|
||||
mat_id: this.matIdValue
|
||||
},
|
||||
{
|
||||
received: (data) => this.handleMatPayload(data)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
unsubscribeMatSubscription() {
|
||||
if (this.matSubscription) {
|
||||
this.matSubscription.unsubscribe()
|
||||
this.matSubscription = null
|
||||
}
|
||||
}
|
||||
|
||||
setupMatchSubscription(matchId) {
|
||||
this.unsubscribeMatchSubscription()
|
||||
if (!window.App || !window.App.cable || !matchId) return
|
||||
|
||||
this.matchSubscription = App.cable.subscriptions.create(
|
||||
{
|
||||
channel: "MatchChannel",
|
||||
match_id: matchId
|
||||
},
|
||||
{
|
||||
connected: () => {
|
||||
this.matchSubscription.perform("request_sync")
|
||||
},
|
||||
received: (data) => {
|
||||
this.handleMatchPayload(data)
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
unsubscribeMatchSubscription() {
|
||||
if (this.matchSubscription) {
|
||||
this.matchSubscription.unsubscribe()
|
||||
this.matchSubscription = null
|
||||
}
|
||||
}
|
||||
|
||||
handleMatPayload(data) {
|
||||
const nextContext = applyMatPayloadContext(this.currentContext(), data)
|
||||
this.applyControllerContext(nextContext)
|
||||
|
||||
if (nextContext.loadSelectedBout) {
|
||||
this.loadSelectedBoutNumber()
|
||||
}
|
||||
if (nextContext.loadLocalState) {
|
||||
this.loadStateFromLocalStorage()
|
||||
}
|
||||
if (nextContext.resetTimerBanner) {
|
||||
this.resetTimerBannerState()
|
||||
}
|
||||
if (nextContext.unsubscribeMatch) {
|
||||
this.unsubscribeMatchSubscription()
|
||||
}
|
||||
if (nextContext.subscribeMatchId) {
|
||||
this.setupMatchSubscription(nextContext.subscribeMatchId)
|
||||
}
|
||||
if (nextContext.renderNow) {
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
handleMatchPayload(data) {
|
||||
this.applyControllerContext(applyMatchPayloadContext(this.currentContext(), data))
|
||||
}
|
||||
|
||||
storageKey() {
|
||||
return matchStorageKey(this.tournamentIdValue, this.currentBoutNumber)
|
||||
}
|
||||
|
||||
selectedBoutStorageKey() {
|
||||
return selectedBoutStorageKeyFromState(this.tournamentIdValue, this.matIdValue)
|
||||
}
|
||||
|
||||
handleStorageChange(event) {
|
||||
const plan = storageChangePlan(this.currentContext(), event.key, this.tournamentIdValue, this.matIdValue)
|
||||
if (plan.loadSelectedBout) this.loadSelectedBoutNumber()
|
||||
if (plan.loadLocalState) this.loadStateFromLocalStorage()
|
||||
if (plan.renderNow) this.render()
|
||||
}
|
||||
|
||||
loadSelectedBoutNumber() {
|
||||
const parsedSelection = loadJson(window.localStorage, this.selectedBoutStorageKey())
|
||||
this.currentBoutNumber = selectedBoutNumber(parsedSelection, this.currentQueueBoutNumber)
|
||||
}
|
||||
|
||||
loadStateFromLocalStorage() {
|
||||
const storageKey = this.storageKey()
|
||||
if (!storageKey) {
|
||||
this.state = null
|
||||
this.resetTimerBannerState()
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = loadJson(window.localStorage, storageKey)
|
||||
this.applyStatePayload(parsed)
|
||||
}
|
||||
|
||||
applyStatePayload(payload) {
|
||||
this.applyControllerContext(applyStatePayloadContext(this.currentContext(), payload))
|
||||
this.updateTimerBannerState()
|
||||
}
|
||||
|
||||
applyControllerContext(context) {
|
||||
this.currentQueueBoutNumber = context.currentQueueBoutNumber
|
||||
this.currentBoutNumber = context.currentBoutNumber
|
||||
this.currentMatchId = context.currentMatchId
|
||||
this.liveMatchData = context.liveMatchData
|
||||
this.lastMatchResult = context.lastMatchResult
|
||||
this.state = context.state
|
||||
this.finished = context.finished
|
||||
this.timerBannerState = context.timerBannerState || null
|
||||
this.previousTimerSnapshot = context.previousTimerSnapshot || {}
|
||||
}
|
||||
|
||||
currentContext() {
|
||||
return {
|
||||
sourceMode: this.sourceModeValue,
|
||||
currentQueueBoutNumber: this.currentQueueBoutNumber,
|
||||
currentBoutNumber: this.currentBoutNumber,
|
||||
currentMatchId: this.currentMatchId,
|
||||
liveMatchData: this.liveMatchData,
|
||||
lastMatchResult: this.lastMatchResult,
|
||||
state: this.state,
|
||||
finished: this.finished,
|
||||
timerBannerState: this.timerBannerState,
|
||||
previousTimerSnapshot: this.previousTimerSnapshot || {}
|
||||
}
|
||||
}
|
||||
|
||||
startTicking() {
|
||||
if (this.tickInterval) return
|
||||
this.tickInterval = window.setInterval(() => this.render(), 250)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state || !this.state.metadata) {
|
||||
this.renderEmptyState()
|
||||
return
|
||||
}
|
||||
|
||||
this.config = getMatchStateConfig(this.state.metadata.ruleset, this.state.metadata.bracketPosition)
|
||||
const viewModel = populatedBoardViewModel(
|
||||
this.config,
|
||||
this.state,
|
||||
this.liveMatchData,
|
||||
this.currentBoutNumber,
|
||||
(seconds) => this.formatClock(seconds)
|
||||
)
|
||||
|
||||
this.applyLiveBoardColors()
|
||||
if (this.hasEmptyStateTarget) this.emptyStateTarget.style.display = "none"
|
||||
this.applyBoardViewModel(viewModel)
|
||||
this.renderTimerBanner()
|
||||
this.renderLastMatchResult()
|
||||
}
|
||||
|
||||
renderEmptyState() {
|
||||
const viewModel = emptyBoardViewModel(this.currentBoutNumber, this.lastMatchResult)
|
||||
this.applyEmptyBoardColors()
|
||||
if (this.hasEmptyStateTarget) this.emptyStateTarget.style.display = "block"
|
||||
this.applyBoardViewModel(viewModel)
|
||||
this.hideTimerBanner()
|
||||
this.renderLastMatchResult()
|
||||
}
|
||||
|
||||
applyBoardViewModel(viewModel) {
|
||||
if (this.hasRedNameTarget) this.redNameTarget.textContent = viewModel.redName
|
||||
if (this.hasRedSchoolTarget) this.redSchoolTarget.textContent = viewModel.redSchool
|
||||
if (this.hasRedScoreTarget) this.redScoreTarget.textContent = viewModel.redScore
|
||||
if (this.hasRedTimerIndicatorTarget) this.redTimerIndicatorTarget.innerHTML = this.renderTimerIndicator(viewModel.redTimerIndicator)
|
||||
if (this.hasGreenNameTarget) this.greenNameTarget.textContent = viewModel.greenName
|
||||
if (this.hasGreenSchoolTarget) this.greenSchoolTarget.textContent = viewModel.greenSchool
|
||||
if (this.hasGreenScoreTarget) this.greenScoreTarget.textContent = viewModel.greenScore
|
||||
if (this.hasGreenTimerIndicatorTarget) this.greenTimerIndicatorTarget.innerHTML = this.renderTimerIndicator(viewModel.greenTimerIndicator)
|
||||
if (this.hasClockTarget) this.clockTarget.textContent = viewModel.clockText
|
||||
if (this.hasPeriodLabelTarget) this.periodLabelTarget.textContent = viewModel.phaseLabel
|
||||
if (this.hasWeightLabelTarget) this.weightLabelTarget.textContent = viewModel.weightLabel
|
||||
if (this.hasBoutLabelTarget) this.boutLabelTarget.textContent = viewModel.boutLabel
|
||||
if (this.hasRedStatsTarget) this.redStatsTarget.textContent = viewModel.redStats
|
||||
if (this.hasGreenStatsTarget) this.greenStatsTarget.textContent = viewModel.greenStats
|
||||
}
|
||||
|
||||
renderLastMatchResult() {
|
||||
if (this.hasLastMatchResultTarget) this.lastMatchResultTarget.textContent = this.lastMatchResult || "-"
|
||||
}
|
||||
|
||||
renderTimerIndicator(label) {
|
||||
if (!label) return ""
|
||||
return `<span class="label label-default">${label}</span>`
|
||||
}
|
||||
|
||||
applyLiveBoardColors() {
|
||||
this.applyBoardColors(boardColors(false))
|
||||
}
|
||||
|
||||
applyEmptyBoardColors() {
|
||||
this.applyBoardColors(boardColors(true))
|
||||
}
|
||||
|
||||
applyBoardColors(colors) {
|
||||
if (this.hasRedSectionTarget) this.redSectionTarget.style.background = colors.red
|
||||
if (this.hasCenterSectionTarget) this.centerSectionTarget.style.background = colors.center
|
||||
if (this.hasGreenSectionTarget) this.greenSectionTarget.style.background = colors.green
|
||||
}
|
||||
|
||||
updateTimerBannerState() {
|
||||
const nextState = nextTimerBannerState(this.state, this.previousTimerSnapshot)
|
||||
this.timerBannerState = nextState.timerBannerState
|
||||
this.previousTimerSnapshot = nextState.previousTimerSnapshot
|
||||
}
|
||||
|
||||
renderTimerBanner() {
|
||||
if (!this.hasTimerBannerTarget) return
|
||||
const renderState = timerBannerRenderState(
|
||||
this.config,
|
||||
this.state,
|
||||
this.timerBannerState,
|
||||
(seconds) => this.formatClock(seconds)
|
||||
)
|
||||
this.timerBannerState = renderState.timerBannerState
|
||||
|
||||
if (!renderState.visible) {
|
||||
this.hideTimerBanner()
|
||||
return
|
||||
}
|
||||
|
||||
const viewModel = renderState.viewModel
|
||||
this.timerBannerTarget.style.display = "block"
|
||||
this.timerBannerTarget.style.borderColor = viewModel.color === "green" ? "#1cab2d" : "#c91f1f"
|
||||
if (this.hasTimerBannerLabelTarget) this.timerBannerLabelTarget.textContent = viewModel.label
|
||||
if (this.hasTimerBannerClockTarget) this.timerBannerClockTarget.textContent = viewModel.clockText
|
||||
}
|
||||
|
||||
hideTimerBanner() {
|
||||
if (this.hasTimerBannerTarget) this.timerBannerTarget.style.display = "none"
|
||||
}
|
||||
|
||||
resetTimerBannerState() {
|
||||
this.timerBannerState = null
|
||||
this.previousTimerSnapshot = {}
|
||||
}
|
||||
|
||||
mainClockRunning() {
|
||||
return mainClockRunningFromPresenters(this.config, this.state)
|
||||
}
|
||||
|
||||
formatClock(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus"
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"w1Stats", "w2Stats", "winner", "winType",
|
||||
"score", "finished", "statusIndicator"
|
||||
"score", "finished", "statusIndicator", "scoreboardContainer"
|
||||
]
|
||||
|
||||
static values = {
|
||||
@@ -134,6 +134,9 @@ export default class extends Controller {
|
||||
|
||||
if (data.finished !== undefined && this.hasFinishedTarget) {
|
||||
this.finishedTarget.textContent = data.finished ? 'Yes' : 'No'
|
||||
if (this.hasScoreboardContainerTarget) {
|
||||
this.scoreboardContainerTarget.style.display = data.finished ? 'none' : 'block'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
803
app/assets/javascripts/controllers/match_state_controller.js
Normal file
803
app/assets/javascripts/controllers/match_state_controller.js
Normal file
@@ -0,0 +1,803 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import {
|
||||
accumulatedMatchSeconds as accumulatedMatchSecondsFromEngine,
|
||||
activeClockForPhase,
|
||||
adjustClockState,
|
||||
applyChoiceAction,
|
||||
applyMatchAction,
|
||||
baseControlForPhase,
|
||||
buildEvent as buildEventFromEngine,
|
||||
buildClockState,
|
||||
buildInitialState,
|
||||
buildTimerState,
|
||||
controlForSelectedPhase,
|
||||
controlFromChoice,
|
||||
currentAuxiliaryTimerSeconds as currentAuxiliaryTimerSecondsFromEngine,
|
||||
currentClockSeconds as currentClockSecondsFromEngine,
|
||||
deleteEventFromState,
|
||||
derivedStats as derivedStatsFromEngine,
|
||||
hasRunningClockOrTimer as hasRunningClockOrTimerFromEngine,
|
||||
matchResultDefaults as matchResultDefaultsFromEngine,
|
||||
moveToNextPhase,
|
||||
moveToPreviousPhase,
|
||||
orderedEvents as orderedEventsFromEngine,
|
||||
opponentParticipant as opponentParticipantFromEngine,
|
||||
phaseIndexForKey as phaseIndexForKeyFromEngine,
|
||||
recomputeDerivedState as recomputeDerivedStateFromEngine,
|
||||
scoreboardStatePayload as scoreboardStatePayloadFromEngine,
|
||||
startAuxiliaryTimerState,
|
||||
startClockState,
|
||||
stopAuxiliaryTimerState,
|
||||
stopClockState,
|
||||
stopAllAuxiliaryTimers as stopAllAuxiliaryTimersFromEngine,
|
||||
swapEventParticipants,
|
||||
swapPhaseParticipants,
|
||||
syncClockSnapshot
|
||||
} from "match-state-engine"
|
||||
import {
|
||||
buildMatchMetadata,
|
||||
buildPersistedState,
|
||||
buildStorageKey,
|
||||
restorePersistedState
|
||||
} from "match-state-serializers"
|
||||
import {
|
||||
loadJson,
|
||||
performIfChanged,
|
||||
removeKey,
|
||||
saveJson
|
||||
} from "match-state-transport"
|
||||
import {
|
||||
choiceViewModel,
|
||||
eventLogSections
|
||||
} from "match-state-presenters"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"greenLabel",
|
||||
"redLabel",
|
||||
"greenPanel",
|
||||
"redPanel",
|
||||
"greenName",
|
||||
"redName",
|
||||
"greenSchool",
|
||||
"redSchool",
|
||||
"greenScore",
|
||||
"redScore",
|
||||
"periodLabel",
|
||||
"clock",
|
||||
"clockStatus",
|
||||
"accumulationClock",
|
||||
"matchPosition",
|
||||
"formatName",
|
||||
"choiceActions",
|
||||
"eventLog",
|
||||
"greenControls",
|
||||
"redControls",
|
||||
"matchResultsPanel",
|
||||
"w1StatField",
|
||||
"w2StatField"
|
||||
]
|
||||
|
||||
static values = {
|
||||
matchId: Number,
|
||||
tournamentId: Number,
|
||||
boutNumber: Number,
|
||||
weightLabel: String,
|
||||
bracketPosition: String,
|
||||
ruleset: String,
|
||||
w1Id: Number,
|
||||
w2Id: Number,
|
||||
w1Name: String,
|
||||
w2Name: String,
|
||||
w1School: String,
|
||||
w2School: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.config = getMatchStateConfig(this.rulesetValue, this.bracketPositionValue)
|
||||
this.boundHandleClick = this.handleDelegatedClick.bind(this)
|
||||
this.element.addEventListener("click", this.boundHandleClick)
|
||||
this.initializeState()
|
||||
this.loadPersistedState()
|
||||
this.syncClockFromActivePhase()
|
||||
if (this.hasRunningClockOrTimer()) {
|
||||
this.startTicking()
|
||||
}
|
||||
this.render({ rebuildControls: true })
|
||||
this.setupSubscription()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("click", this.boundHandleClick)
|
||||
window.clearTimeout(this.matchResultsDefaultsTimeout)
|
||||
this.cleanupSubscription()
|
||||
this.saveState()
|
||||
this.stopTicking()
|
||||
this.stopAllAuxiliaryTimers()
|
||||
}
|
||||
|
||||
initializeState() {
|
||||
this.state = this.buildInitialState()
|
||||
}
|
||||
|
||||
buildInitialState() {
|
||||
return buildInitialState(this.config)
|
||||
}
|
||||
|
||||
render(options = {}) {
|
||||
const rebuildControls = options.rebuildControls === true
|
||||
if (this.hasGreenLabelTarget) this.greenLabelTarget.textContent = this.displayLabelForParticipant("w1")
|
||||
if (this.hasRedLabelTarget) this.redLabelTarget.textContent = this.displayLabelForParticipant("w2")
|
||||
if (this.hasGreenPanelTarget) this.applyPanelColor(this.greenPanelTarget, this.colorForParticipant("w1"))
|
||||
if (this.hasRedPanelTarget) this.applyPanelColor(this.redPanelTarget, this.colorForParticipant("w2"))
|
||||
if (this.hasGreenNameTarget) this.greenNameTarget.textContent = this.w1NameValue
|
||||
if (this.hasRedNameTarget) this.redNameTarget.textContent = this.w2NameValue
|
||||
if (this.hasGreenSchoolTarget) this.greenSchoolTarget.textContent = this.w1SchoolValue
|
||||
if (this.hasRedSchoolTarget) this.redSchoolTarget.textContent = this.w2SchoolValue
|
||||
if (this.hasGreenScoreTarget) this.greenScoreTarget.textContent = this.state.participantScores.w1.toString()
|
||||
if (this.hasRedScoreTarget) this.redScoreTarget.textContent = this.state.participantScores.w2.toString()
|
||||
if (this.hasPeriodLabelTarget) this.periodLabelTarget.textContent = this.currentPhase().label
|
||||
this.updateLiveDisplays()
|
||||
if (this.hasMatchPositionTarget) this.matchPositionTarget.textContent = this.humanizePosition(this.state.displayControl)
|
||||
if (this.hasFormatNameTarget) this.formatNameTarget.textContent = this.config.matchFormat.label
|
||||
if (rebuildControls) {
|
||||
if (this.hasGreenControlsTarget) this.greenControlsTarget.innerHTML = this.renderWrestlerControls("w1")
|
||||
if (this.hasRedControlsTarget) this.redControlsTarget.innerHTML = this.renderWrestlerControls("w2")
|
||||
}
|
||||
if (this.hasChoiceActionsTarget) this.choiceActionsTarget.innerHTML = this.renderChoiceActions()
|
||||
if (this.hasEventLogTarget) this.eventLogTarget.innerHTML = this.renderEventLog()
|
||||
this.updateTimerDisplays()
|
||||
this.updateStatFieldsAndBroadcast()
|
||||
this.scheduleApplyMatchResultDefaults()
|
||||
this.saveState()
|
||||
}
|
||||
|
||||
renderWrestlerControls(participantKey) {
|
||||
return Object.values(this.config.wrestler_actions).map((section) => {
|
||||
const content = this.renderWrestlerSection(participantKey, section)
|
||||
if (!content) return ""
|
||||
|
||||
return `
|
||||
<div style="margin-top: 12px;">
|
||||
<strong>${section.title}</strong>
|
||||
<div class="text-muted" style="margin: 4px 0 8px;">${section.description}</div>
|
||||
<div>${content}</div>
|
||||
</div>
|
||||
`
|
||||
}).join('<hr>')
|
||||
}
|
||||
|
||||
renderWrestlerSection(participantKey, section) {
|
||||
if (!section) return ""
|
||||
|
||||
if (section === this.config.wrestler_actions.timers) {
|
||||
return this.renderTimerSection(participantKey, section)
|
||||
}
|
||||
|
||||
const actionKeys = this.actionKeysForSection(participantKey, section)
|
||||
return this.renderActionButtons(participantKey, actionKeys)
|
||||
}
|
||||
|
||||
renderActionButtons(participantKey, actionKeys) {
|
||||
return actionKeys.map((actionKey) => {
|
||||
const action = this.config.actionsByKey[actionKey]
|
||||
if (!action) return ""
|
||||
|
||||
const buttonClass = this.colorForParticipant(participantKey) === "green" ? "btn-success" : "btn-danger"
|
||||
return `<button type="button" class="btn ${buttonClass} btn-sm" data-match-state-button="score-action" data-participant-key="${participantKey}" data-action-key="${actionKey}">${action.label}</button>`
|
||||
}).join(" ")
|
||||
}
|
||||
|
||||
actionKeysForSection(participantKey, section) {
|
||||
if (!section?.items) return []
|
||||
|
||||
return section.items.flatMap((itemKey) => {
|
||||
if (itemKey === "global") {
|
||||
return this.availableActionKeysForAvailability(participantKey, "global")
|
||||
}
|
||||
|
||||
if (itemKey === "position") {
|
||||
const position = this.positionForParticipant(participantKey)
|
||||
return this.availableActionKeysForAvailability(participantKey, position)
|
||||
}
|
||||
|
||||
return itemKey
|
||||
})
|
||||
}
|
||||
|
||||
availableActionKeysForAvailability(participantKey, availability) {
|
||||
if (this.currentPhase().type !== "period") return []
|
||||
|
||||
return Object.entries(this.config.actionsByKey)
|
||||
.filter(([, action]) => action.availability === availability)
|
||||
.map(([actionKey]) => actionKey)
|
||||
}
|
||||
|
||||
renderTimerSection(participantKey, section) {
|
||||
return (section.items || []).map((timerKey) => {
|
||||
const timerConfig = this.config.timers[timerKey]
|
||||
if (!timerConfig) return ""
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 12px;">
|
||||
<strong>${timerConfig.label}</strong>: <span data-match-state-timer-display="${participantKey}-${timerKey}">${this.formatClock(this.currentAuxiliaryTimerSeconds(participantKey, timerKey))}</span>
|
||||
<div class="btn-group btn-group-xs" style="margin-left: 8px;">
|
||||
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="start">Start</button>
|
||||
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="stop">Stop</button>
|
||||
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="reset">Reset</button>
|
||||
</div>
|
||||
<div class="text-muted" data-match-state-timer-status="${participantKey}-${timerKey}">Max ${this.formatClock(timerConfig.maxSeconds)}</div>
|
||||
</div>
|
||||
`
|
||||
}).join("")
|
||||
}
|
||||
|
||||
handleDelegatedClick(event) {
|
||||
const button = event.target.closest("button")
|
||||
if (!button) return
|
||||
|
||||
// Buttons with direct Stimulus actions are handled separately.
|
||||
if (button.dataset.action && button.dataset.action.includes("match-state#")) return
|
||||
|
||||
const buttonType = button.dataset.matchStateButton
|
||||
if (buttonType === "score-action") {
|
||||
this.applyAction(button)
|
||||
} else if (buttonType === "choice-action") {
|
||||
this.applyChoice(button)
|
||||
} else if (buttonType === "timer-action") {
|
||||
this.handleTimerCommand(button)
|
||||
} else if (buttonType === "swap-phase") {
|
||||
this.swapPhase(button)
|
||||
} else if (buttonType === "swap-event") {
|
||||
this.swapEvent(button)
|
||||
} else if (buttonType === "delete-event") {
|
||||
this.deleteEvent(button)
|
||||
}
|
||||
}
|
||||
|
||||
applyAction(button) {
|
||||
const participantKey = button.dataset.participantKey
|
||||
const actionKey = button.dataset.actionKey
|
||||
if (!applyMatchAction(this.config, this.state, this.currentPhase(), this.currentClockSeconds(), participantKey, actionKey)) return
|
||||
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
applyChoice(button) {
|
||||
const phase = this.currentPhase()
|
||||
if (phase.type !== "choice") return
|
||||
|
||||
const participantKey = button.dataset.participantKey
|
||||
const choiceKey = button.dataset.choiceKey
|
||||
|
||||
const result = applyChoiceAction(this.state, phase, this.currentClockSeconds(), participantKey, choiceKey)
|
||||
if (!result.applied) return
|
||||
|
||||
if (result.deferred) {
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
return
|
||||
}
|
||||
|
||||
this.advancePhase()
|
||||
}
|
||||
|
||||
swapColors() {
|
||||
this.state.assignment.w1 = this.state.assignment.w1 === "green" ? "red" : "green"
|
||||
this.state.assignment.w2 = this.state.assignment.w2 === "green" ? "red" : "green"
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
buildEvent(participantKey, actionKey, options = {}) {
|
||||
return buildEventFromEngine(this.state, this.currentPhase(), this.currentClockSeconds(), participantKey, actionKey, options)
|
||||
}
|
||||
|
||||
startClock() {
|
||||
if (this.currentPhase().type !== "period") return
|
||||
const activeClock = this.activeClock()
|
||||
if (!startClockState(activeClock)) return
|
||||
this.syncClockFromActivePhase()
|
||||
this.startTicking()
|
||||
this.render()
|
||||
}
|
||||
|
||||
stopClock() {
|
||||
const activeClock = this.activeClock()
|
||||
if (!stopClockState(activeClock)) return
|
||||
this.syncClockFromActivePhase()
|
||||
this.stopTicking()
|
||||
this.render()
|
||||
}
|
||||
|
||||
resetClock() {
|
||||
this.stopClock()
|
||||
const activeClock = this.activeClock()
|
||||
if (!activeClock) return
|
||||
activeClock.remainingSeconds = activeClock.durationSeconds
|
||||
this.syncClockFromActivePhase()
|
||||
this.render()
|
||||
}
|
||||
|
||||
addMinute() {
|
||||
this.adjustClock(60)
|
||||
}
|
||||
|
||||
subtractMinute() {
|
||||
this.adjustClock(-60)
|
||||
}
|
||||
|
||||
addSecond() {
|
||||
this.adjustClock(1)
|
||||
}
|
||||
|
||||
subtractSecond() {
|
||||
this.adjustClock(-1)
|
||||
}
|
||||
|
||||
previousPhase() {
|
||||
this.stopClock()
|
||||
if (!moveToPreviousPhase(this.config, this.state)) return
|
||||
this.applyPhaseDefaults()
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
nextPhase() {
|
||||
this.advancePhase()
|
||||
}
|
||||
|
||||
resetMatch() {
|
||||
const confirmed = window.confirm("Are you sure you want to reset the match? This will wipe the score, reset all timers, and wipe all stats")
|
||||
if (!confirmed) return
|
||||
|
||||
this.stopTicking()
|
||||
this.initializeState()
|
||||
this.syncClockFromActivePhase()
|
||||
this.clearPersistedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
advancePhase() {
|
||||
this.stopClock()
|
||||
if (!moveToNextPhase(this.config, this.state)) return
|
||||
this.applyPhaseDefaults()
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
deleteEvent(button) {
|
||||
const eventId = Number(button.dataset.eventId)
|
||||
if (!deleteEventFromState(this.config, this.state, eventId)) return
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
swapEvent(button) {
|
||||
const eventId = Number(button.dataset.eventId)
|
||||
if (!swapEventParticipants(this.config, this.state, eventId)) return
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
swapPhase(button) {
|
||||
const phaseKey = button.dataset.phaseKey
|
||||
if (!swapPhaseParticipants(this.config, this.state, phaseKey)) return
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
handleTimerCommand(button) {
|
||||
const participantKey = button.dataset.participantKey
|
||||
const timerKey = button.dataset.timerKey
|
||||
const command = button.dataset.timerCommand
|
||||
|
||||
if (command === "start") this.startAuxiliaryTimer(participantKey, timerKey)
|
||||
if (command === "stop") this.stopAuxiliaryTimer(participantKey, timerKey)
|
||||
if (command === "reset") this.resetAuxiliaryTimer(participantKey, timerKey)
|
||||
}
|
||||
|
||||
startAuxiliaryTimer(participantKey, timerKey) {
|
||||
const timer = this.state.timers[participantKey][timerKey]
|
||||
if (!startAuxiliaryTimerState(timer)) return
|
||||
this.startTicking()
|
||||
this.render()
|
||||
}
|
||||
|
||||
stopAuxiliaryTimer(participantKey, timerKey) {
|
||||
const timer = this.state.timers[participantKey][timerKey]
|
||||
const { stopped, elapsedSeconds } = stopAuxiliaryTimerState(timer)
|
||||
if (!stopped) return
|
||||
|
||||
if (elapsedSeconds > 0) {
|
||||
this.state.events.push({
|
||||
...this.buildEvent(participantKey, `timer_used_${timerKey}`),
|
||||
elapsedSeconds: elapsedSeconds
|
||||
})
|
||||
}
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
resetAuxiliaryTimer(participantKey, timerKey) {
|
||||
this.stopAuxiliaryTimer(participantKey, timerKey)
|
||||
const timer = this.state.timers[participantKey][timerKey]
|
||||
timer.remainingSeconds = this.config.timers[timerKey].maxSeconds
|
||||
this.render()
|
||||
}
|
||||
|
||||
buildTimerState() {
|
||||
return buildTimerState(this.config)
|
||||
}
|
||||
|
||||
buildClockState() {
|
||||
return buildClockState(this.config)
|
||||
}
|
||||
|
||||
currentClockSeconds() {
|
||||
return currentClockSecondsFromEngine(this.activeClock())
|
||||
}
|
||||
|
||||
currentAuxiliaryTimerSeconds(participantKey, timerKey) {
|
||||
return currentAuxiliaryTimerSecondsFromEngine(this.state.timers[participantKey][timerKey])
|
||||
}
|
||||
|
||||
hasRunningClockOrTimer() {
|
||||
return hasRunningClockOrTimerFromEngine(this.state)
|
||||
}
|
||||
|
||||
startTicking() {
|
||||
if (this.tickInterval) return
|
||||
this.tickInterval = window.setInterval(() => {
|
||||
if (this.activeClock()?.running && this.currentClockSeconds() === 0) {
|
||||
this.stopClock()
|
||||
return
|
||||
}
|
||||
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(this.state.timers[participantKey])) {
|
||||
if (this.state.timers[participantKey][timerKey].running && this.currentAuxiliaryTimerSeconds(participantKey, timerKey) === 0) {
|
||||
this.stopAuxiliaryTimer(participantKey, timerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.updateLiveDisplays()
|
||||
this.updateTimerDisplays()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
stopTicking() {
|
||||
if (!this.tickInterval) return
|
||||
window.clearInterval(this.tickInterval)
|
||||
this.tickInterval = null
|
||||
}
|
||||
|
||||
stopAllAuxiliaryTimers() {
|
||||
stopAllAuxiliaryTimersFromEngine(this.state)
|
||||
}
|
||||
|
||||
positionForParticipant(participantKey) {
|
||||
if (this.state.displayControl === "neutral") return "neutral"
|
||||
if (this.state.displayControl === `${participantKey}_control`) return "top"
|
||||
return "bottom"
|
||||
}
|
||||
|
||||
opponentParticipant(participantKey) {
|
||||
return opponentParticipantFromEngine(participantKey)
|
||||
}
|
||||
|
||||
humanizePosition(position) {
|
||||
if (position === "neutral") return "Neutral"
|
||||
if (position === "green_control") return "Green In Control"
|
||||
if (position === "red_control") return "Red In Control"
|
||||
return position
|
||||
}
|
||||
|
||||
recomputeDerivedState() {
|
||||
recomputeDerivedStateFromEngine(this.config, this.state)
|
||||
}
|
||||
|
||||
renderEventLog() {
|
||||
if (this.state.events.length === 0) {
|
||||
return '<p class="text-muted">No events yet.</p>'
|
||||
}
|
||||
|
||||
return eventLogSections(this.config, this.state, (seconds) => this.formatClock(seconds)).map((section) => {
|
||||
const items = section.items.map((eventRecord) => {
|
||||
return `
|
||||
<div class="well well-sm" style="margin-bottom: 8px;">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px;">
|
||||
<div style="flex: 1 1 260px; min-width: 0; overflow-wrap: anywhere;">
|
||||
<strong>${eventRecord.colorLabel}</strong> ${eventRecord.actionLabel}
|
||||
<span class="text-muted">(${eventRecord.clockLabel})</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto;">
|
||||
<button type="button" class="btn btn-xs btn-link" data-match-state-button="swap-event" data-event-id="${eventRecord.id}">Swap</button>
|
||||
<button type="button" class="btn btn-xs btn-link" data-match-state-button="delete-event" data-event-id="${eventRecord.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join("")
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px;">
|
||||
<h5 style="margin: 0;">${section.label}</h5>
|
||||
<button type="button" class="btn btn-xs btn-link" data-match-state-button="swap-phase" data-phase-key="${section.key}">Swap Entire Period</button>
|
||||
</div>
|
||||
${items}
|
||||
</div>
|
||||
`
|
||||
}).join("")
|
||||
}
|
||||
|
||||
updateLiveDisplays() {
|
||||
if (this.hasClockTarget) {
|
||||
this.clockTarget.textContent = this.currentPhase().type === "period" ? this.formatClock(this.currentClockSeconds()) : "-"
|
||||
}
|
||||
if (this.hasClockStatusTarget) {
|
||||
this.clockStatusTarget.textContent = this.currentPhase().type === "period"
|
||||
? (this.activeClock()?.running ? "Running" : "Stopped")
|
||||
: "Choice"
|
||||
}
|
||||
if (this.hasAccumulationClockTarget) {
|
||||
this.accumulationClockTarget.textContent = this.formatClock(this.accumulatedMatchSeconds())
|
||||
}
|
||||
}
|
||||
|
||||
updateTimerDisplays() {
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const [timerKey, timerConfig] of Object.entries(this.config.timers)) {
|
||||
const display = this.element.querySelector(`[data-match-state-timer-display="${participantKey}-${timerKey}"]`)
|
||||
const status = this.element.querySelector(`[data-match-state-timer-status="${participantKey}-${timerKey}"]`)
|
||||
if (display) {
|
||||
display.textContent = this.formatClock(this.currentAuxiliaryTimerSeconds(participantKey, timerKey))
|
||||
}
|
||||
if (status) {
|
||||
const running = this.state.timers[participantKey][timerKey].running
|
||||
status.textContent = `Max ${this.formatClock(timerConfig.maxSeconds)}${running ? " • running" : ""}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderChoiceActions() {
|
||||
const phase = this.currentPhase()
|
||||
const viewModel = choiceViewModel(this.config, this.state, phase, {
|
||||
w1: { name: this.w1NameValue },
|
||||
w2: { name: this.w2NameValue }
|
||||
})
|
||||
if (!viewModel) return ""
|
||||
|
||||
return `
|
||||
<div class="well well-sm">
|
||||
<div><strong>${viewModel.label}</strong></div>
|
||||
<div class="text-muted" style="margin: 6px 0;">${viewModel.selectionText}</div>
|
||||
<div>${viewModel.buttons.map((button) => `
|
||||
<button
|
||||
type="button"
|
||||
class="btn ${button.buttonClass} btn-sm"
|
||||
data-match-state-button="choice-action"
|
||||
data-participant-key="${button.participantKey}"
|
||||
data-choice-key="${button.choiceKey}">
|
||||
${button.text}
|
||||
</button>
|
||||
`).join(" ")}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
currentPhase() {
|
||||
return this.config.phaseSequence[this.state.phaseIndex]
|
||||
}
|
||||
|
||||
applyPhaseDefaults() {
|
||||
this.syncClockFromActivePhase()
|
||||
this.state.control = this.baseControlForCurrentPhase()
|
||||
}
|
||||
|
||||
baseControlForCurrentPhase() {
|
||||
return baseControlForPhase(this.currentPhase(), this.state.selections, this.state.control)
|
||||
}
|
||||
|
||||
controlFromChoice(selection) {
|
||||
return controlFromChoice(selection)
|
||||
}
|
||||
|
||||
colorForParticipant(participantKey) {
|
||||
return this.state.assignment[participantKey]
|
||||
}
|
||||
|
||||
displayLabelForParticipant(participantKey) {
|
||||
return this.colorForParticipant(participantKey) === "green" ? "Green" : "Red"
|
||||
}
|
||||
|
||||
applyPanelColor(panelElement, color) {
|
||||
panelElement.classList.remove("panel-success", "panel-danger")
|
||||
panelElement.classList.add(color === "green" ? "panel-success" : "panel-danger")
|
||||
}
|
||||
|
||||
controlForSelectedPhase() {
|
||||
return controlForSelectedPhase(this.config, this.state)
|
||||
}
|
||||
|
||||
baseControlForPhase(phase) {
|
||||
return baseControlForPhase(phase, this.state.selections, this.state.control)
|
||||
}
|
||||
|
||||
orderedEvents() {
|
||||
return orderedEventsFromEngine(this.config, this.state.events)
|
||||
}
|
||||
|
||||
phaseIndexForKey(phaseKey) {
|
||||
return phaseIndexForKeyFromEngine(this.config, phaseKey)
|
||||
}
|
||||
|
||||
activeClock() {
|
||||
return activeClockForPhase(this.state, this.currentPhase())
|
||||
}
|
||||
|
||||
setupSubscription() {
|
||||
this.cleanupSubscription()
|
||||
if (!this.matchIdValue || !window.App || !window.App.cable) return
|
||||
|
||||
this.matchSubscription = App.cable.subscriptions.create(
|
||||
{
|
||||
channel: "MatchChannel",
|
||||
match_id: this.matchIdValue
|
||||
},
|
||||
{
|
||||
connected: () => {
|
||||
this.isConnected = true
|
||||
this.pushDerivedStatsToChannel()
|
||||
this.pushScoreboardStateToChannel()
|
||||
},
|
||||
disconnected: () => {
|
||||
this.isConnected = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
cleanupSubscription() {
|
||||
if (!this.matchSubscription) return
|
||||
try {
|
||||
this.matchSubscription.unsubscribe()
|
||||
} catch (_error) {
|
||||
}
|
||||
this.matchSubscription = null
|
||||
this.isConnected = false
|
||||
}
|
||||
|
||||
updateStatFieldsAndBroadcast() {
|
||||
const derivedStats = this.derivedStats()
|
||||
|
||||
if (this.hasW1StatFieldTarget) this.w1StatFieldTarget.value = derivedStats.w1
|
||||
if (this.hasW2StatFieldTarget) this.w2StatFieldTarget.value = derivedStats.w2
|
||||
|
||||
this.lastDerivedStats = derivedStats
|
||||
this.pushDerivedStatsToChannel()
|
||||
this.pushScoreboardStateToChannel()
|
||||
}
|
||||
|
||||
pushDerivedStatsToChannel() {
|
||||
if (!this.matchSubscription || !this.lastDerivedStats) return
|
||||
this.lastBroadcastStats = performIfChanged(this.matchSubscription, "send_stat", {
|
||||
new_w1_stat: this.lastDerivedStats.w1,
|
||||
new_w2_stat: this.lastDerivedStats.w2
|
||||
}, this.lastBroadcastStats)
|
||||
}
|
||||
|
||||
pushScoreboardStateToChannel() {
|
||||
if (!this.matchSubscription) return
|
||||
|
||||
this.lastBroadcastScoreboardState = performIfChanged(this.matchSubscription, "send_scoreboard", {
|
||||
scoreboard_state: this.scoreboardStatePayload()
|
||||
}, this.lastBroadcastScoreboardState)
|
||||
}
|
||||
|
||||
applyMatchResultDefaults() {
|
||||
const controllerElement = this.matchResultsPanelTarget?.querySelector('[data-controller~="match-score"]')
|
||||
if (!controllerElement) return
|
||||
|
||||
const scoreController = this.application.getControllerForElementAndIdentifier(controllerElement, "match-score")
|
||||
if (!scoreController || typeof scoreController.applyDefaultResults !== "function") return
|
||||
|
||||
scoreController.applyDefaultResults(
|
||||
matchResultDefaultsFromEngine(this.state, {
|
||||
w1Id: this.w1IdValue,
|
||||
w2Id: this.w2IdValue,
|
||||
currentPhase: this.currentPhase(),
|
||||
accumulationSeconds: this.accumulatedMatchSeconds()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
scheduleApplyMatchResultDefaults() {
|
||||
if (!this.hasMatchResultsPanelTarget) return
|
||||
|
||||
window.clearTimeout(this.matchResultsDefaultsTimeout)
|
||||
this.matchResultsDefaultsTimeout = window.setTimeout(() => {
|
||||
this.applyMatchResultDefaults()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
storageKey() {
|
||||
return buildStorageKey(this.tournamentIdValue, this.boutNumberValue)
|
||||
}
|
||||
|
||||
loadPersistedState() {
|
||||
const parsedState = loadJson(window.localStorage, this.storageKey())
|
||||
if (!parsedState) {
|
||||
if (window.localStorage.getItem(this.storageKey())) {
|
||||
this.clearPersistedState()
|
||||
this.state = this.buildInitialState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.state = restorePersistedState(this.config, parsedState)
|
||||
} catch (_error) {
|
||||
this.clearPersistedState()
|
||||
this.state = this.buildInitialState()
|
||||
}
|
||||
}
|
||||
|
||||
saveState() {
|
||||
const persistedState = buildPersistedState(this.state, this.matchMetadata())
|
||||
saveJson(window.localStorage, this.storageKey(), persistedState)
|
||||
}
|
||||
|
||||
clearPersistedState() {
|
||||
removeKey(window.localStorage, this.storageKey())
|
||||
}
|
||||
|
||||
accumulatedMatchSeconds() {
|
||||
return accumulatedMatchSecondsFromEngine(this.config, this.state, this.currentPhase().key)
|
||||
}
|
||||
|
||||
syncClockFromActivePhase() {
|
||||
this.state.clock = syncClockSnapshot(this.activeClock())
|
||||
}
|
||||
|
||||
adjustClock(deltaSeconds) {
|
||||
if (this.currentPhase().type !== "period") return
|
||||
|
||||
const activeClock = this.activeClock()
|
||||
if (!adjustClockState(activeClock, deltaSeconds)) return
|
||||
this.syncClockFromActivePhase()
|
||||
this.render()
|
||||
}
|
||||
|
||||
formatClock(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
derivedStats() {
|
||||
return derivedStatsFromEngine(this.config, this.state.events)
|
||||
}
|
||||
|
||||
scoreboardStatePayload() {
|
||||
return scoreboardStatePayloadFromEngine(this.config, this.state, this.matchMetadata())
|
||||
}
|
||||
|
||||
matchMetadata() {
|
||||
return buildMatchMetadata({
|
||||
tournamentId: this.tournamentIdValue,
|
||||
boutNumber: this.boutNumberValue,
|
||||
weightLabel: this.weightLabelValue,
|
||||
ruleset: this.rulesetValue,
|
||||
bracketPosition: this.bracketPositionValue,
|
||||
w1Name: this.w1NameValue,
|
||||
w2Name: this.w2NameValue,
|
||||
w1School: this.w1SchoolValue,
|
||||
w2School: this.w2SchoolValue
|
||||
})
|
||||
}
|
||||
}
|
||||
344
app/assets/javascripts/lib/match_state/config.js
Normal file
344
app/assets/javascripts/lib/match_state/config.js
Normal file
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
State page config contract
|
||||
==========================
|
||||
|
||||
The state page responds to these top-level config objects:
|
||||
|
||||
1. `wrestler_actions`
|
||||
Drives the wrestler-side UI from top to bottom inside each wrestler panel.
|
||||
The controller renders these sections in order, so the order in this object
|
||||
controls the visual order underneath each wrestler's name, school, and score.
|
||||
Supported sections:
|
||||
- `match_actions`
|
||||
- `timers`
|
||||
- `extra_actions`
|
||||
|
||||
Each section may define:
|
||||
- `title`
|
||||
- `description`
|
||||
- `items`
|
||||
|
||||
How the state page uses it:
|
||||
- `match_actions.items`
|
||||
Each item is either:
|
||||
- a literal action key, or
|
||||
- a special alias such as `global` or `position`
|
||||
The state page expands those aliases into the currently legal actions for
|
||||
that wrestler and renders them as buttons.
|
||||
- `timers.items`
|
||||
Each item is a timer key. The state page renders the timer display plus
|
||||
start/stop/reset buttons for each listed timer.
|
||||
- `extra_actions.items`
|
||||
Each item is a literal action key rendered as an always-visible button
|
||||
underneath the timer section.
|
||||
|
||||
2. `actionsByKey`
|
||||
Canonical definitions for match actions and history actions.
|
||||
This is the source of truth for how a button behaves and how an action
|
||||
should appear in the event log.
|
||||
Each action may define:
|
||||
- `label`
|
||||
- `availability`
|
||||
- `statCode`
|
||||
- `effect`
|
||||
- `progression`
|
||||
|
||||
How the state page uses it:
|
||||
- `label`
|
||||
Used for button text and event log text.
|
||||
- `availability`
|
||||
Used when `wrestler_actions.match_actions.items` includes aliases like
|
||||
`global` or `position`.
|
||||
- `effect`
|
||||
Used by the rules engine to update score and match position when replaying
|
||||
the event list.
|
||||
- `statCode`
|
||||
Used when rewriting the hidden `w1_stat` / `w2_stat` fields from the
|
||||
structured event log for websocket sync and match submission.
|
||||
- `progression`
|
||||
Used for progressive actions like stalling, caution, and penalty to decide
|
||||
if the opponent should automatically receive a linked point-scoring event.
|
||||
|
||||
Supported `availability` values used by the wrestler-side UI:
|
||||
- `global`
|
||||
- `neutral`
|
||||
- `top`
|
||||
- `bottom`
|
||||
- `extra`
|
||||
|
||||
3. `timers`
|
||||
Canonical timer definitions keyed by timer name.
|
||||
This controls both the timer controls in the wrestler panel and how timer
|
||||
usage is labeled in the event log.
|
||||
|
||||
How the state page uses it:
|
||||
- `label`
|
||||
Displayed next to the running timer value in the wrestler panel.
|
||||
- `maxSeconds`
|
||||
Used to initialize, reset, clamp, and render the timer.
|
||||
- `historyLabel`
|
||||
Used when a timer stop event is recorded in history.
|
||||
- `statCode`
|
||||
Used when rewriting the hidden `w1_stat` / `w2_stat` fields for timer-used
|
||||
events.
|
||||
|
||||
4. `phases`
|
||||
Defines the period / choice sequence for this wrestling style.
|
||||
The active phase drives:
|
||||
- the main match clock
|
||||
- phase labels
|
||||
- start-of-period position behavior
|
||||
- choice button behavior
|
||||
- event grouping in the history list
|
||||
|
||||
How the state page uses it:
|
||||
- chooses which phase sequence to use from bracket position
|
||||
- builds the main match clock state for timed phases
|
||||
- determines whether the current phase is a period or a choice phase
|
||||
- determines how a period starts (`neutral` or from a prior choice)
|
||||
*/
|
||||
|
||||
const RULESETS = {
|
||||
folkstyle_usa: {
|
||||
id: "folkstyle_usa",
|
||||
|
||||
wrestler_actions: {
|
||||
match_actions: {
|
||||
title: "Match Actions",
|
||||
description: "Scoring and match-state actions available based on current position.",
|
||||
items: ["global", "position"]
|
||||
},
|
||||
timers: {
|
||||
title: "Wrestler Timers",
|
||||
description: "Track blood, injury, recovery, and head/neck time for this wrestler.",
|
||||
items: ["blood", "injury", "recovery", "head_neck"]
|
||||
},
|
||||
extra_actions: {
|
||||
title: "Extra Actions",
|
||||
description: "Force the match into a specific position and record it in history.",
|
||||
items: ["position_neutral", "position_top", "position_bottom"]
|
||||
}
|
||||
},
|
||||
|
||||
actionsByKey: {
|
||||
stalling: {
|
||||
label: "Stalling",
|
||||
availability: "global",
|
||||
statCode: "S",
|
||||
effect: { points: 0 },
|
||||
progression: [0, 1, 1, 2]
|
||||
},
|
||||
caution: {
|
||||
label: "Caution",
|
||||
availability: "global",
|
||||
statCode: "C",
|
||||
effect: { points: 0 },
|
||||
progression: [0, 0, 1]
|
||||
},
|
||||
penalty: {
|
||||
label: "Penalty",
|
||||
availability: "global",
|
||||
statCode: "P",
|
||||
effect: { points: 0 },
|
||||
progression: [1, 1, 2]
|
||||
},
|
||||
minus_1: {
|
||||
label: "-1 Point",
|
||||
availability: "global",
|
||||
statCode: "-1",
|
||||
effect: { points: -1 }
|
||||
},
|
||||
plus_1: {
|
||||
label: "+1 Point",
|
||||
availability: "global",
|
||||
statCode: "+1",
|
||||
effect: { points: 1 }
|
||||
},
|
||||
plus_2: {
|
||||
label: "+2 Points",
|
||||
statCode: "+2",
|
||||
effect: { points: 2 }
|
||||
},
|
||||
takedown_3: {
|
||||
label: "Takedown +3",
|
||||
availability: "neutral",
|
||||
statCode: "T3",
|
||||
effect: { points: 3, nextPosition: "top" }
|
||||
},
|
||||
nearfall_2: {
|
||||
label: "Nearfall +2",
|
||||
availability: "top",
|
||||
statCode: "N2",
|
||||
effect: { points: 2 }
|
||||
},
|
||||
nearfall_3: {
|
||||
label: "Nearfall +3",
|
||||
availability: "top",
|
||||
statCode: "N3",
|
||||
effect: { points: 3 }
|
||||
},
|
||||
nearfall_4: {
|
||||
label: "Nearfall +4",
|
||||
availability: "top",
|
||||
statCode: "N4",
|
||||
effect: { points: 4 }
|
||||
},
|
||||
nearfall_5: {
|
||||
label: "Nearfall +5",
|
||||
availability: "top",
|
||||
statCode: "N5",
|
||||
effect: { points: 5 }
|
||||
},
|
||||
escape_1: {
|
||||
label: "Escape +1",
|
||||
availability: "bottom",
|
||||
statCode: "E1",
|
||||
effect: { points: 1, nextPosition: "neutral" }
|
||||
},
|
||||
reversal_2: {
|
||||
label: "Reversal +2",
|
||||
availability: "bottom",
|
||||
statCode: "R2",
|
||||
effect: { points: 2, nextPosition: "top" }
|
||||
},
|
||||
position_neutral: {
|
||||
label: "Neutral",
|
||||
availability: "extra",
|
||||
statCode: "|Neutral|",
|
||||
effect: { points: 0, nextPosition: "neutral" }
|
||||
},
|
||||
position_top: {
|
||||
label: "Top",
|
||||
availability: "extra",
|
||||
statCode: "|Top|",
|
||||
effect: { points: 0, nextPosition: "top" }
|
||||
},
|
||||
position_bottom: {
|
||||
label: "Bottom",
|
||||
availability: "extra",
|
||||
statCode: "|Bottom|",
|
||||
effect: { points: 0, nextPosition: "bottom" }
|
||||
},
|
||||
choice_top: {
|
||||
label: "Choice: Top",
|
||||
statCode: "|Chose Top|"
|
||||
},
|
||||
choice_bottom: {
|
||||
label: "Choice: Bottom",
|
||||
statCode: "|Chose Bottom|"
|
||||
},
|
||||
choice_neutral: {
|
||||
label: "Choice: Neutral",
|
||||
statCode: "|Chose Neutral|"
|
||||
},
|
||||
choice_defer: {
|
||||
label: "Choice: Defer",
|
||||
statCode: "|Deferred|"
|
||||
}
|
||||
},
|
||||
|
||||
timers: {
|
||||
blood: { maxSeconds: 300, label: "Blood", historyLabel: "Blood Time Used", statCode: "Blood Time" },
|
||||
injury: { maxSeconds: 90, label: "Injury", historyLabel: "Injury Time Used", statCode: "Injury Time" },
|
||||
recovery: { maxSeconds: 120, label: "Recovery", historyLabel: "Recovery Time Used", statCode: "Recovery Time" },
|
||||
head_neck: { maxSeconds: 300, label: "Head/Neck", historyLabel: "Head/Neck Time Used", statCode: "Head/Neck Time" }
|
||||
},
|
||||
|
||||
phases: {
|
||||
championship: {
|
||||
label: "Championship Format",
|
||||
sequence: [
|
||||
{ key: "period_1", label: "Period 1", type: "period", startsIn: "neutral", clockSeconds: 120 },
|
||||
{ key: "choice_1", label: "Choice 1", type: "choice", chooser: "either", options: ["top", "bottom", "neutral", "defer"] },
|
||||
{ key: "period_2", label: "Period 2", type: "period", startsFromChoice: "choice_1", clockSeconds: 120 },
|
||||
{ key: "choice_2", label: "Choice 2", type: "choice", chooser: "other", options: ["top", "bottom", "neutral"] },
|
||||
{ key: "period_3", label: "Period 3", type: "period", startsFromChoice: "choice_2", clockSeconds: 120 },
|
||||
{ key: "sv_1", label: "SV-1", type: "period", startsIn: "neutral", clockSeconds: 60, overtimeType: "SV-1" },
|
||||
{ key: "choice_3", label: "Choice 3", type: "choice", chooser: "either", options: ["top", "bottom", "defer"] },
|
||||
{ key: "tb_1a", label: "TB-1A", type: "period", startsFromChoice: "choice_3", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_4", label: "Choice 4", type: "choice", chooser: "other", options: ["top", "bottom"] },
|
||||
{ key: "tb_1b", label: "TB-1B", type: "period", startsFromChoice: "choice_4", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_5", label: "Choice 5", type: "choice", chooser: "either", options: ["top", "bottom"] },
|
||||
{ key: "utb", label: "UTB", type: "period", startsFromChoice: "choice_5", clockSeconds: 30, overtimeType: "UTB" }
|
||||
]
|
||||
},
|
||||
consolation: {
|
||||
label: "Consolation Format",
|
||||
sequence: [
|
||||
{ key: "period_1", label: "Period 1", type: "period", startsIn: "neutral", clockSeconds: 60 },
|
||||
{ key: "choice_1", label: "Choice 1", type: "choice", chooser: "either", options: ["top", "bottom", "neutral", "defer"] },
|
||||
{ key: "period_2", label: "Period 2", type: "period", startsFromChoice: "choice_1", clockSeconds: 120 },
|
||||
{ key: "choice_2", label: "Choice 2", type: "choice", chooser: "other", options: ["top", "bottom", "neutral"] },
|
||||
{ key: "period_3", label: "Period 3", type: "period", startsFromChoice: "choice_2", clockSeconds: 120 },
|
||||
{ key: "sv_1", label: "SV-1", type: "period", startsIn: "neutral", clockSeconds: 60, overtimeType: "SV-1" },
|
||||
{ key: "choice_3", label: "Choice 3", type: "choice", chooser: "either", options: ["top", "bottom", "defer"] },
|
||||
{ key: "tb_1a", label: "TB-1A", type: "period", startsFromChoice: "choice_3", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_4", label: "Choice 4", type: "choice", chooser: "other", options: ["top", "bottom"] },
|
||||
{ key: "tb_1b", label: "TB-1B", type: "period", startsFromChoice: "choice_4", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_5", label: "Choice 5", type: "choice", chooser: "either", options: ["top", "bottom"] },
|
||||
{ key: "utb", label: "UTB", type: "period", startsFromChoice: "choice_5", clockSeconds: 30, overtimeType: "UTB" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function phaseStyleKeyForBracketPosition(bracketPosition) {
|
||||
if (!bracketPosition) return "championship"
|
||||
|
||||
if (
|
||||
bracketPosition.includes("Conso") ||
|
||||
["3/4", "5/6", "7/8"].includes(bracketPosition)
|
||||
) {
|
||||
return "consolation"
|
||||
}
|
||||
|
||||
return "championship"
|
||||
}
|
||||
|
||||
function buildActionEffects(actionsByKey) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => action.effect)
|
||||
.map(([key, action]) => [key, action.effect])
|
||||
)
|
||||
}
|
||||
|
||||
function buildActionLabels(actionsByKey, timers) {
|
||||
const actionLabels = Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => action.label)
|
||||
.map(([key, action]) => [key, action.label])
|
||||
)
|
||||
|
||||
Object.entries(timers || {}).forEach(([timerKey, timer]) => {
|
||||
if (timer.historyLabel) {
|
||||
actionLabels[`timer_used_${timerKey}`] = timer.historyLabel
|
||||
}
|
||||
})
|
||||
|
||||
return actionLabels
|
||||
}
|
||||
|
||||
function buildProgressionRules(actionsByKey) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => Array.isArray(action.progression))
|
||||
.map(([key, action]) => [key, action.progression])
|
||||
)
|
||||
}
|
||||
|
||||
export function getMatchStateConfig(rulesetId, bracketPosition) {
|
||||
const ruleset = RULESETS[rulesetId] || RULESETS.folkstyle_usa
|
||||
const phaseStyleKey = phaseStyleKeyForBracketPosition(bracketPosition)
|
||||
const phaseStyle = ruleset.phases[phaseStyleKey]
|
||||
|
||||
return {
|
||||
...ruleset,
|
||||
actionEffects: buildActionEffects(ruleset.actionsByKey),
|
||||
actionLabels: buildActionLabels(ruleset.actionsByKey, ruleset.timers),
|
||||
progressionRules: buildProgressionRules(ruleset.actionsByKey),
|
||||
matchFormat: { id: phaseStyleKey, label: phaseStyle.label },
|
||||
phaseSequence: phaseStyle.sequence
|
||||
}
|
||||
}
|
||||
567
app/assets/javascripts/lib/match_state/engine.js
Normal file
567
app/assets/javascripts/lib/match_state/engine.js
Normal file
@@ -0,0 +1,567 @@
|
||||
export function buildTimerState(config) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config.timers).map(([timerKey, timerConfig]) => [
|
||||
timerKey,
|
||||
{
|
||||
remainingSeconds: timerConfig.maxSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildClockState(config) {
|
||||
return Object.fromEntries(
|
||||
config.phaseSequence
|
||||
.filter((phase) => phase.type === "period")
|
||||
.map((phase) => [
|
||||
phase.key,
|
||||
{
|
||||
durationSeconds: phase.clockSeconds,
|
||||
remainingSeconds: phase.clockSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildInitialState(config) {
|
||||
const openingPhase = config.phaseSequence[0]
|
||||
|
||||
return {
|
||||
participantScores: {
|
||||
w1: 0,
|
||||
w2: 0
|
||||
},
|
||||
control: "neutral",
|
||||
displayControl: "neutral",
|
||||
phaseIndex: 0,
|
||||
selections: {},
|
||||
assignment: {
|
||||
w1: "green",
|
||||
w2: "red"
|
||||
},
|
||||
nextEventId: 1,
|
||||
nextEventGroupId: 1,
|
||||
events: [],
|
||||
clocksByPhase: buildClockState(config),
|
||||
clock: {
|
||||
durationSeconds: openingPhase.clockSeconds,
|
||||
remainingSeconds: openingPhase.clockSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
},
|
||||
timers: {
|
||||
w1: buildTimerState(config),
|
||||
w2: buildTimerState(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEvent(state, phase, clockSeconds, participantKey, actionKey, options = {}) {
|
||||
return {
|
||||
id: state.nextEventId++,
|
||||
phaseKey: phase.key,
|
||||
phaseLabel: phase.label,
|
||||
clockSeconds,
|
||||
participantKey,
|
||||
actionKey,
|
||||
actionGroupId: options.actionGroupId
|
||||
}
|
||||
}
|
||||
|
||||
export function opponentParticipant(participantKey) {
|
||||
return participantKey === "w1" ? "w2" : "w1"
|
||||
}
|
||||
|
||||
export function isProgressiveAction(config, actionKey) {
|
||||
return Object.prototype.hasOwnProperty.call(config.progressionRules || {}, actionKey)
|
||||
}
|
||||
|
||||
export function progressiveActionCountForParticipant(events, participantKey, actionKey) {
|
||||
return events.filter((eventRecord) =>
|
||||
eventRecord.participantKey === participantKey && eventRecord.actionKey === actionKey
|
||||
).length
|
||||
}
|
||||
|
||||
export function progressiveActionPointsForOffense(config, actionKey, offenseNumber) {
|
||||
const progression = config.progressionRules?.[actionKey] || []
|
||||
return progression[Math.min(offenseNumber - 1, progression.length - 1)] || 0
|
||||
}
|
||||
|
||||
export function recordProgressiveAction(config, state, participantKey, actionKey, buildEvent) {
|
||||
const offenseNumber = progressiveActionCountForParticipant(state.events, participantKey, actionKey) + 1
|
||||
const actionGroupId = state.nextEventGroupId++
|
||||
state.events.push(buildEvent(participantKey, actionKey, { actionGroupId }))
|
||||
|
||||
const awardedPoints = progressiveActionPointsForOffense(config, actionKey, offenseNumber)
|
||||
if (awardedPoints > 0) {
|
||||
state.events.push(buildEvent(opponentParticipant(participantKey), `plus_${awardedPoints}`, { actionGroupId }))
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMatchAction(config, state, phase, clockSeconds, participantKey, actionKey) {
|
||||
const effect = config.actionEffects[actionKey]
|
||||
if (!effect) return false
|
||||
|
||||
if (isProgressiveAction(config, actionKey)) {
|
||||
recordProgressiveAction(
|
||||
config,
|
||||
state,
|
||||
participantKey,
|
||||
actionKey,
|
||||
(eventParticipantKey, eventActionKey, options = {}) =>
|
||||
buildEvent(state, phase, clockSeconds, eventParticipantKey, eventActionKey, options)
|
||||
)
|
||||
} else {
|
||||
state.events.push(buildEvent(state, phase, clockSeconds, participantKey, actionKey))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function applyChoiceAction(state, phase, clockSeconds, participantKey, choiceKey) {
|
||||
if (phase.type !== "choice") return { applied: false, deferred: false }
|
||||
|
||||
state.events.push(buildEvent(state, phase, clockSeconds, participantKey, `choice_${choiceKey}`))
|
||||
|
||||
if (choiceKey === "defer") {
|
||||
return { applied: true, deferred: true }
|
||||
}
|
||||
|
||||
state.selections[phase.key] = {
|
||||
participantKey,
|
||||
choiceKey
|
||||
}
|
||||
|
||||
return { applied: true, deferred: false }
|
||||
}
|
||||
|
||||
export function deleteEventFromState(config, state, eventId) {
|
||||
const eventRecord = state.events.find((eventItem) => eventItem.id === eventId)
|
||||
if (!eventRecord) return false
|
||||
|
||||
let eventIdsToDelete = [eventId]
|
||||
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const timer = state.timers[eventRecord.participantKey]?.[timerKey]
|
||||
const maxSeconds = config.timers[timerKey]?.maxSeconds || 0
|
||||
if (timer) {
|
||||
timer.remainingSeconds = Math.min(maxSeconds, timer.remainingSeconds + eventRecord.elapsedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
if (isProgressiveAction(config, eventRecord.actionKey)) {
|
||||
const linkedAward = findLinkedProgressiveAward(state.events, eventRecord)
|
||||
if (linkedAward) {
|
||||
eventIdsToDelete.push(linkedAward.id)
|
||||
}
|
||||
}
|
||||
|
||||
state.events = state.events.filter((eventItem) => !eventIdsToDelete.includes(eventItem.id))
|
||||
return true
|
||||
}
|
||||
|
||||
export function swapEventParticipants(config, state, eventId) {
|
||||
const eventRecord = state.events.find((eventItem) => eventItem.id === eventId)
|
||||
if (!eventRecord) return false
|
||||
|
||||
const originalParticipant = eventRecord.participantKey
|
||||
const swappedParticipant = opponentParticipant(originalParticipant)
|
||||
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
reassignTimerUsage(config, state, eventRecord, swappedParticipant)
|
||||
}
|
||||
|
||||
eventRecord.participantKey = swappedParticipant
|
||||
|
||||
if (isProgressiveAction(config, eventRecord.actionKey)) {
|
||||
swapLinkedProgressiveAward(state.events, eventRecord, swappedParticipant)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function swapPhaseParticipants(config, state, phaseKey) {
|
||||
const phaseEvents = state.events.filter((eventRecord) => eventRecord.phaseKey === phaseKey)
|
||||
if (phaseEvents.length === 0) return false
|
||||
|
||||
phaseEvents.forEach((eventRecord) => {
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
reassignTimerUsage(config, state, eventRecord, opponentParticipant(eventRecord.participantKey))
|
||||
}
|
||||
|
||||
eventRecord.participantKey = opponentParticipant(eventRecord.participantKey)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function phaseIndexForKey(config, phaseKey) {
|
||||
const phaseIndex = config.phaseSequence.findIndex((phase) => phase.key === phaseKey)
|
||||
return phaseIndex === -1 ? Number.MAX_SAFE_INTEGER : phaseIndex
|
||||
}
|
||||
|
||||
export function activeClockForPhase(state, phase) {
|
||||
if (!phase || phase.type !== "period") return null
|
||||
return state.clocksByPhase[phase.key] || null
|
||||
}
|
||||
|
||||
export function hasRunningClockOrTimer(state) {
|
||||
const anyTimerRunning = ["w1", "w2"].some((participantKey) =>
|
||||
Object.values(state.timers[participantKey] || {}).some((timer) => timer.running)
|
||||
)
|
||||
const anyClockRunning = Object.values(state.clocksByPhase || {}).some((clock) => clock.running)
|
||||
return anyTimerRunning || anyClockRunning
|
||||
}
|
||||
|
||||
export function stopAllAuxiliaryTimers(state, now = Date.now()) {
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state.timers[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
if (!timer.running) continue
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timer.startedAt) / 1000)
|
||||
timer.remainingSeconds = Math.max(0, timer.remainingSeconds - elapsedSeconds)
|
||||
timer.running = false
|
||||
timer.startedAt = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function moveToPreviousPhase(config, state) {
|
||||
if (state.phaseIndex === 0) return false
|
||||
state.phaseIndex -= 1
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return true
|
||||
}
|
||||
|
||||
export function moveToNextPhase(config, state) {
|
||||
if (state.phaseIndex >= config.phaseSequence.length - 1) return false
|
||||
state.phaseIndex += 1
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return true
|
||||
}
|
||||
|
||||
export function orderedEvents(config, events) {
|
||||
return [...events].sort((leftEvent, rightEvent) => {
|
||||
const leftPhaseIndex = phaseIndexForKey(config, leftEvent.phaseKey)
|
||||
const rightPhaseIndex = phaseIndexForKey(config, rightEvent.phaseKey)
|
||||
if (leftPhaseIndex !== rightPhaseIndex) {
|
||||
return leftPhaseIndex - rightPhaseIndex
|
||||
}
|
||||
return leftEvent.id - rightEvent.id
|
||||
})
|
||||
}
|
||||
|
||||
export function controlFromChoice(selection) {
|
||||
if (!selection) return "neutral"
|
||||
if (selection.choiceKey === "neutral" || selection.choiceKey === "defer") return "neutral"
|
||||
if (selection.choiceKey === "top") return `${selection.participantKey}_control`
|
||||
if (selection.choiceKey === "bottom") return `${opponentParticipant(selection.participantKey)}_control`
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
export function baseControlForPhase(phase, selections, fallbackControl) {
|
||||
if (phase.type !== "period") return fallbackControl
|
||||
if (phase.startsIn === "neutral") return "neutral"
|
||||
if (phase.startsFromChoice) {
|
||||
return controlFromChoice(selections[phase.startsFromChoice])
|
||||
}
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
export function recomputeDerivedState(config, state) {
|
||||
state.participantScores = { w1: 0, w2: 0 }
|
||||
state.selections = {}
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
|
||||
orderedEvents(config, state.events).forEach((eventRecord) => {
|
||||
if (eventRecord.actionKey.startsWith("choice_")) {
|
||||
const choiceKey = eventRecord.actionKey.replace("choice_", "")
|
||||
if (choiceKey === "defer") return
|
||||
|
||||
state.selections[eventRecord.phaseKey] = {
|
||||
participantKey: eventRecord.participantKey,
|
||||
choiceKey: choiceKey
|
||||
}
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return
|
||||
}
|
||||
|
||||
const effect = config.actionEffects[eventRecord.actionKey]
|
||||
if (!effect) return
|
||||
|
||||
const scoringParticipant = effect.target === "opponent"
|
||||
? opponentParticipant(eventRecord.participantKey)
|
||||
: eventRecord.participantKey
|
||||
const nextScore = state.participantScores[scoringParticipant] + effect.points
|
||||
state.participantScores[scoringParticipant] = Math.max(0, nextScore)
|
||||
|
||||
if (effect.nextPosition === "neutral") {
|
||||
state.control = "neutral"
|
||||
} else if (effect.nextPosition === "top") {
|
||||
state.control = `${eventRecord.participantKey}_control`
|
||||
} else if (effect.nextPosition === "bottom") {
|
||||
state.control = `${opponentParticipant(eventRecord.participantKey)}_control`
|
||||
}
|
||||
})
|
||||
|
||||
state.displayControl = controlForSelectedPhase(config, state)
|
||||
}
|
||||
|
||||
export function controlForSelectedPhase(config, state) {
|
||||
const selectedPhase = config.phaseSequence[state.phaseIndex]
|
||||
let control = baseControlForPhase(selectedPhase, state.selections, state.control)
|
||||
const selectedPhaseIndex = phaseIndexForKey(config, selectedPhase.key)
|
||||
|
||||
orderedEvents(config, state.events).forEach((eventRecord) => {
|
||||
if (phaseIndexForKey(config, eventRecord.phaseKey) > selectedPhaseIndex) return
|
||||
if (eventRecord.phaseKey !== selectedPhase.key) return
|
||||
|
||||
const effect = config.actionEffects[eventRecord.actionKey]
|
||||
if (!effect) return
|
||||
|
||||
if (effect.nextPosition === "neutral") {
|
||||
control = "neutral"
|
||||
} else if (effect.nextPosition === "top") {
|
||||
control = `${eventRecord.participantKey}_control`
|
||||
} else if (effect.nextPosition === "bottom") {
|
||||
control = `${opponentParticipant(eventRecord.participantKey)}_control`
|
||||
}
|
||||
})
|
||||
|
||||
return control
|
||||
}
|
||||
|
||||
export function currentClockSeconds(clockState, now = Date.now()) {
|
||||
if (!clockState) return 0
|
||||
if (!clockState.running || !clockState.startedAt) {
|
||||
return clockState.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - clockState.startedAt) / 1000)
|
||||
return Math.max(0, clockState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function currentAuxiliaryTimerSeconds(timerState, now = Date.now()) {
|
||||
if (!timerState) return 0
|
||||
if (!timerState.running || !timerState.startedAt) {
|
||||
return timerState.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timerState.startedAt) / 1000)
|
||||
return Math.max(0, timerState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function syncClockSnapshot(activeClock) {
|
||||
if (!activeClock) {
|
||||
return {
|
||||
durationSeconds: 0,
|
||||
remainingSeconds: 0,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationSeconds: activeClock.durationSeconds,
|
||||
remainingSeconds: activeClock.remainingSeconds,
|
||||
running: activeClock.running,
|
||||
startedAt: activeClock.startedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function startClockState(activeClock, now = Date.now()) {
|
||||
if (!activeClock || activeClock.running) return false
|
||||
activeClock.running = true
|
||||
activeClock.startedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
export function stopClockState(activeClock, now = Date.now()) {
|
||||
if (!activeClock || !activeClock.running) return false
|
||||
activeClock.remainingSeconds = currentClockSeconds(activeClock, now)
|
||||
activeClock.running = false
|
||||
activeClock.startedAt = null
|
||||
return true
|
||||
}
|
||||
|
||||
export function adjustClockState(activeClock, deltaSeconds, now = Date.now()) {
|
||||
if (!activeClock) return false
|
||||
|
||||
const currentSeconds = currentClockSeconds(activeClock, now)
|
||||
activeClock.remainingSeconds = Math.max(0, currentSeconds + deltaSeconds)
|
||||
if (activeClock.running) {
|
||||
activeClock.startedAt = now
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function startAuxiliaryTimerState(timerState, now = Date.now()) {
|
||||
if (!timerState || timerState.running) return false
|
||||
timerState.running = true
|
||||
timerState.startedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
export function stopAuxiliaryTimerState(timerState, now = Date.now()) {
|
||||
if (!timerState || !timerState.running) return { stopped: false, elapsedSeconds: 0 }
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timerState.startedAt) / 1000)
|
||||
timerState.remainingSeconds = currentAuxiliaryTimerSeconds(timerState, now)
|
||||
timerState.running = false
|
||||
timerState.startedAt = null
|
||||
|
||||
return { stopped: true, elapsedSeconds }
|
||||
}
|
||||
|
||||
export function accumulatedMatchSeconds(config, state, activePhaseKey, now = Date.now()) {
|
||||
return config.phaseSequence
|
||||
.filter((phase) => phase.type === "period")
|
||||
.reduce((totalElapsed, phase) => {
|
||||
const clockState = state.clocksByPhase[phase.key]
|
||||
if (!clockState) return totalElapsed
|
||||
|
||||
const remainingSeconds = phase.key === activePhaseKey
|
||||
? currentClockSeconds(clockState, now)
|
||||
: clockState.remainingSeconds
|
||||
|
||||
const elapsedSeconds = Math.max(0, clockState.durationSeconds - remainingSeconds)
|
||||
return totalElapsed + elapsedSeconds
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function derivedStats(config, events) {
|
||||
const grouped = config.phaseSequence.map((phase) => {
|
||||
const phaseEvents = orderedEvents(config, events).filter((eventRecord) => eventRecord.phaseKey === phase.key)
|
||||
if (phaseEvents.length === 0) return null
|
||||
|
||||
return {
|
||||
label: phase.label,
|
||||
w1: phaseEvents
|
||||
.filter((eventRecord) => eventRecord.participantKey === "w1")
|
||||
.map((eventRecord) => statTextForEvent(config, eventRecord))
|
||||
.filter(Boolean),
|
||||
w2: phaseEvents
|
||||
.filter((eventRecord) => eventRecord.participantKey === "w2")
|
||||
.map((eventRecord) => statTextForEvent(config, eventRecord))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return {
|
||||
w1: formatStatsByPhase(grouped, "w1"),
|
||||
w2: formatStatsByPhase(grouped, "w2")
|
||||
}
|
||||
}
|
||||
|
||||
export function scoreboardStatePayload(config, state, metadata) {
|
||||
return {
|
||||
participantScores: state.participantScores,
|
||||
assignment: state.assignment,
|
||||
phaseIndex: state.phaseIndex,
|
||||
clocksByPhase: state.clocksByPhase,
|
||||
timers: state.timers,
|
||||
metadata: metadata,
|
||||
matchResult: {
|
||||
finished: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function matchResultDefaults(state, options = {}) {
|
||||
const {
|
||||
w1Id = "",
|
||||
w2Id = "",
|
||||
currentPhase = {},
|
||||
accumulationSeconds = 0
|
||||
} = options
|
||||
|
||||
const w1Score = state.participantScores.w1
|
||||
const w2Score = state.participantScores.w2
|
||||
let winnerId = ""
|
||||
let winnerScore = w1Score
|
||||
let loserScore = w2Score
|
||||
|
||||
if (w1Score > w2Score) {
|
||||
winnerId = w1Id || ""
|
||||
winnerScore = w1Score
|
||||
loserScore = w2Score
|
||||
} else if (w2Score > w1Score) {
|
||||
winnerId = w2Id || ""
|
||||
winnerScore = w2Score
|
||||
loserScore = w1Score
|
||||
}
|
||||
|
||||
return {
|
||||
winnerId,
|
||||
overtimeType: currentPhase.overtimeType || "",
|
||||
winnerScore,
|
||||
loserScore,
|
||||
pinMinutes: Math.floor(accumulationSeconds / 60),
|
||||
pinSeconds: accumulationSeconds % 60
|
||||
}
|
||||
}
|
||||
|
||||
function statTextForEvent(config, eventRecord) {
|
||||
if (eventRecord.actionKey.startsWith("timer_used_")) {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const timerConfig = config.timers[timerKey]
|
||||
if (!timerConfig || typeof eventRecord.elapsedSeconds !== "number") return null
|
||||
return `${timerConfig.statCode || timerConfig.label}: ${formatClock(eventRecord.elapsedSeconds)}`
|
||||
}
|
||||
|
||||
const action = config.actionsByKey[eventRecord.actionKey]
|
||||
return action?.statCode || null
|
||||
}
|
||||
|
||||
function formatStatsByPhase(groupedPhases, participantKey) {
|
||||
return groupedPhases
|
||||
.map((phase) => {
|
||||
const items = phase[participantKey]
|
||||
if (!items || items.length === 0) return null
|
||||
return `${phase.label}: ${items.join(" ")}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function formatClock(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function reassignTimerUsage(config, state, eventRecord, newParticipantKey) {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const originalParticipant = eventRecord.participantKey
|
||||
const originalTimer = state.timers[originalParticipant]?.[timerKey]
|
||||
const newTimer = state.timers[newParticipantKey]?.[timerKey]
|
||||
const maxSeconds = config.timers[timerKey]?.maxSeconds || 0
|
||||
|
||||
if (!originalTimer || !newTimer || typeof eventRecord.elapsedSeconds !== "number") return
|
||||
|
||||
originalTimer.remainingSeconds = Math.min(maxSeconds, originalTimer.remainingSeconds + eventRecord.elapsedSeconds)
|
||||
newTimer.remainingSeconds = Math.max(0, newTimer.remainingSeconds - eventRecord.elapsedSeconds)
|
||||
}
|
||||
|
||||
function swapLinkedProgressiveAward(events, eventRecord, offendingParticipant) {
|
||||
const linkedAward = findLinkedProgressiveAward(events, eventRecord)
|
||||
if (linkedAward) {
|
||||
linkedAward.participantKey = opponentParticipant(offendingParticipant)
|
||||
}
|
||||
}
|
||||
|
||||
function findLinkedProgressiveAward(events, eventRecord) {
|
||||
return events.find((candidateEvent) =>
|
||||
candidateEvent.id !== eventRecord.id &&
|
||||
candidateEvent.actionGroupId &&
|
||||
candidateEvent.actionGroupId === eventRecord.actionGroupId &&
|
||||
candidateEvent.actionKey.startsWith("plus_")
|
||||
)
|
||||
}
|
||||
94
app/assets/javascripts/lib/match_state/presenters.js
Normal file
94
app/assets/javascripts/lib/match_state/presenters.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { orderedEvents } from "match-state-engine"
|
||||
|
||||
export function displayLabelForParticipant(assignment, participantKey) {
|
||||
return assignment[participantKey] === "green" ? "Green" : "Red"
|
||||
}
|
||||
|
||||
export function buttonClassForParticipant(assignment, participantKey) {
|
||||
return assignment[participantKey] === "green" ? "btn-success" : "btn-danger"
|
||||
}
|
||||
|
||||
export function humanizeChoice(choiceKey) {
|
||||
if (choiceKey === "top") return "Top"
|
||||
if (choiceKey === "bottom") return "Bottom"
|
||||
if (choiceKey === "neutral") return "Neutral"
|
||||
if (choiceKey === "defer") return "Defer"
|
||||
return choiceKey
|
||||
}
|
||||
|
||||
export function choiceLabelForPhase(phase) {
|
||||
if (phase.chooser === "other") return "Other wrestler chooses"
|
||||
return "Choose wrestler and position"
|
||||
}
|
||||
|
||||
export function eventLogSections(config, state, formatClock) {
|
||||
const eventsByPhase = orderedEvents(config, state.events).reduce((accumulator, eventRecord) => {
|
||||
if (!accumulator[eventRecord.phaseKey]) {
|
||||
accumulator[eventRecord.phaseKey] = []
|
||||
}
|
||||
accumulator[eventRecord.phaseKey].push(eventRecord)
|
||||
return accumulator
|
||||
}, {})
|
||||
|
||||
return config.phaseSequence.map((phase) => {
|
||||
const phaseEvents = eventsByPhase[phase.key]
|
||||
if (!phaseEvents || phaseEvents.length === 0) return null
|
||||
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
items: [...phaseEvents].reverse().map((eventRecord) => ({
|
||||
id: eventRecord.id,
|
||||
participantKey: eventRecord.participantKey,
|
||||
colorLabel: displayLabelForParticipant(state.assignment, eventRecord.participantKey),
|
||||
actionLabel: eventActionLabel(config, eventRecord, formatClock),
|
||||
clockLabel: formatClock(eventRecord.clockSeconds)
|
||||
}))
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
export function choiceViewModel(config, state, phase, participantMeta) {
|
||||
if (phase.type !== "choice") return null
|
||||
|
||||
const phaseEvents = state.events.filter((eventRecord) => eventRecord.phaseKey === phase.key)
|
||||
const deferredParticipants = phaseEvents
|
||||
.filter((eventRecord) => eventRecord.actionKey === "choice_defer")
|
||||
.map((eventRecord) => eventRecord.participantKey)
|
||||
const selection = state.selections[phase.key]
|
||||
|
||||
const selectionText = selection
|
||||
? `Selected: ${displayLabelForParticipant(state.assignment, selection.participantKey)} ${humanizeChoice(selection.choiceKey)}`
|
||||
: deferredParticipants.length > 0
|
||||
? `${deferredParticipants.map((participantKey) => displayLabelForParticipant(state.assignment, participantKey)).join(", ")} deferred. Waiting for the other wrestler to choose.`
|
||||
: "No choice selected."
|
||||
|
||||
const availableParticipants = deferredParticipants.length > 0
|
||||
? ["w1", "w2"].filter((participantKey) => !deferredParticipants.includes(participantKey))
|
||||
: ["w1", "w2"]
|
||||
|
||||
const buttons = availableParticipants.flatMap((participantKey) =>
|
||||
phase.options
|
||||
.filter((choiceKey) => !(deferredParticipants.length > 0 && choiceKey === "defer"))
|
||||
.map((choiceKey) => ({
|
||||
participantKey,
|
||||
choiceKey,
|
||||
buttonClass: buttonClassForParticipant(state.assignment, participantKey),
|
||||
text: `${participantMeta[participantKey].name} (${displayLabelForParticipant(state.assignment, participantKey)}) ${humanizeChoice(choiceKey)}`
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
label: choiceLabelForPhase(phase),
|
||||
selectionText,
|
||||
buttons
|
||||
}
|
||||
}
|
||||
|
||||
function eventActionLabel(config, eventRecord, formatClock) {
|
||||
let actionLabel = config.actionLabels[eventRecord.actionKey] || eventRecord.actionKey
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
actionLabel = `${actionLabel}: ${formatClock(eventRecord.elapsedSeconds)}`
|
||||
}
|
||||
return actionLabel
|
||||
}
|
||||
288
app/assets/javascripts/lib/match_state/scoreboard_presenters.js
Normal file
288
app/assets/javascripts/lib/match_state/scoreboard_presenters.js
Normal file
@@ -0,0 +1,288 @@
|
||||
export function participantForColor(state, color) {
|
||||
if (!state?.assignment) {
|
||||
return color === "red" ? "w2" : "w1"
|
||||
}
|
||||
|
||||
const match = Object.entries(state.assignment).find(([, assignedColor]) => assignedColor === color)
|
||||
return match ? match[0] : (color === "red" ? "w2" : "w1")
|
||||
}
|
||||
|
||||
export function participantColor(state, participantKey) {
|
||||
return state?.assignment?.[participantKey] || (participantKey === "w1" ? "green" : "red")
|
||||
}
|
||||
|
||||
export function participantName(state, participantKey) {
|
||||
return participantKey === "w1" ? state?.metadata?.w1Name : state?.metadata?.w2Name
|
||||
}
|
||||
|
||||
export function participantSchool(state, participantKey) {
|
||||
return participantKey === "w1" ? state?.metadata?.w1School : state?.metadata?.w2School
|
||||
}
|
||||
|
||||
export function participantScore(state, participantKey) {
|
||||
return state?.participantScores?.[participantKey] || 0
|
||||
}
|
||||
|
||||
export function currentPhaseLabel(config, state) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
return config?.phaseSequence?.[phaseIndex]?.label || "Period 1"
|
||||
}
|
||||
|
||||
export function currentClockText(config, state, formatClock, now = Date.now()) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
const phase = config?.phaseSequence?.[phaseIndex]
|
||||
if (!phase || phase.type !== "period") return "-"
|
||||
|
||||
const clockState = state?.clocksByPhase?.[phase.key]
|
||||
if (!clockState) return formatClock(phase.clockSeconds)
|
||||
|
||||
let remainingSeconds = clockState.remainingSeconds
|
||||
if (clockState.running && clockState.startedAt) {
|
||||
const elapsedSeconds = Math.floor((now - clockState.startedAt) / 1000)
|
||||
remainingSeconds = Math.max(0, clockState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
return formatClock(remainingSeconds)
|
||||
}
|
||||
|
||||
export function currentAuxiliaryTimerSeconds(state, participantKey, timerKey, now = Date.now()) {
|
||||
const timer = state?.timers?.[participantKey]?.[timerKey]
|
||||
if (!timer) return 0
|
||||
if (!timer.running || !timer.startedAt) {
|
||||
return timer.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timer.startedAt) / 1000)
|
||||
return Math.max(0, timer.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function runningTimerForParticipant(state, participantKey) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
if (state.timers[participantKey][timerKey]?.running) {
|
||||
return timerKey
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function participantDisplayLabel(state, participantKey) {
|
||||
return `${participantForColor(state, "red") === participantKey ? "Red" : "Green"} ${participantName(state, participantKey)}`
|
||||
}
|
||||
|
||||
export function timerIndicatorLabel(config, state, participantKey, formatClock, now = Date.now()) {
|
||||
const runningTimer = runningTimerForParticipant(state, participantKey)
|
||||
if (!runningTimer) return ""
|
||||
|
||||
const timerConfig = config?.timers?.[runningTimer]
|
||||
if (!timerConfig) return ""
|
||||
|
||||
const remainingSeconds = currentAuxiliaryTimerSeconds(state, participantKey, runningTimer, now)
|
||||
const usedSeconds = Math.max(0, timerConfig.maxSeconds - remainingSeconds)
|
||||
return `${timerConfig.label}: ${formatClock(usedSeconds)}`
|
||||
}
|
||||
|
||||
export function buildRunningTimerSnapshot(state) {
|
||||
const snapshot = {}
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
snapshot[`${participantKey}:${timerKey}`] = Boolean(timer?.running)
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function detectRecentlyStoppedTimer(state, previousTimerSnapshot) {
|
||||
previousTimerSnapshot ||= {}
|
||||
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const snapshotKey = `${participantKey}:${timerKey}`
|
||||
const wasRunning = previousTimerSnapshot[snapshotKey]
|
||||
const isRunning = Boolean(state.timers[participantKey][timerKey]?.running)
|
||||
if (wasRunning && !isRunning) {
|
||||
return { participantKey, timerKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function runningAuxiliaryTimer(state) {
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
if (timer?.running) {
|
||||
return { participantKey, timerKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function mainClockRunning(config, state) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
const phase = config?.phaseSequence?.[phaseIndex]
|
||||
if (!phase || phase.type !== "period") return false
|
||||
return Boolean(state?.clocksByPhase?.[phase.key]?.running)
|
||||
}
|
||||
|
||||
export function timerBannerViewModel(config, state, timerBannerState, formatClock, now = Date.now()) {
|
||||
if (!timerBannerState) return null
|
||||
|
||||
const { participantKey, timerKey, expiresAt } = timerBannerState
|
||||
if (expiresAt && now > expiresAt) return null
|
||||
|
||||
const timer = state?.timers?.[participantKey]?.[timerKey]
|
||||
const timerConfig = config?.timers?.[timerKey]
|
||||
if (!timer || !timerConfig) return null
|
||||
|
||||
const runningSeconds = currentAuxiliaryTimerSeconds(state, participantKey, timerKey, now)
|
||||
const usedSeconds = Math.max(0, timerConfig.maxSeconds - runningSeconds)
|
||||
const color = participantColor(state, participantKey)
|
||||
const label = `${participantDisplayLabel(state, participantKey)} ${timerConfig.label}`
|
||||
|
||||
return {
|
||||
color,
|
||||
label: timer.running ? `${label} Running` : `${label} Used`,
|
||||
clockText: formatClock(usedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
export function populatedBoardViewModel(config, state, liveMatchData, currentBoutNumber, formatClock, now = Date.now()) {
|
||||
const redParticipant = participantForColor(state, "red")
|
||||
const greenParticipant = participantForColor(state, "green")
|
||||
|
||||
return {
|
||||
isEmpty: false,
|
||||
redName: participantName(state, redParticipant),
|
||||
redSchool: participantSchool(state, redParticipant),
|
||||
redScore: participantScore(state, redParticipant).toString(),
|
||||
redTimerIndicator: timerIndicatorLabel(config, state, redParticipant, formatClock, now),
|
||||
greenName: participantName(state, greenParticipant),
|
||||
greenSchool: participantSchool(state, greenParticipant),
|
||||
greenScore: participantScore(state, greenParticipant).toString(),
|
||||
greenTimerIndicator: timerIndicatorLabel(config, state, greenParticipant, formatClock, now),
|
||||
clockText: currentClockText(config, state, formatClock, now),
|
||||
phaseLabel: currentPhaseLabel(config, state),
|
||||
weightLabel: state?.metadata?.weightLabel ? `Weight ${state.metadata.weightLabel}` : "Weight -",
|
||||
boutLabel: currentBoutNumber ? `Bout ${currentBoutNumber}` : "No Bout",
|
||||
redStats: redParticipant === "w1" ? (liveMatchData?.w1_stat || "") : (liveMatchData?.w2_stat || ""),
|
||||
greenStats: greenParticipant === "w1" ? (liveMatchData?.w1_stat || "") : (liveMatchData?.w2_stat || "")
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyBoardViewModel(currentBoutNumber, lastMatchResult) {
|
||||
return {
|
||||
isEmpty: true,
|
||||
redName: "NO MATCH",
|
||||
redSchool: "",
|
||||
redScore: "0",
|
||||
redTimerIndicator: "",
|
||||
greenName: "NO MATCH",
|
||||
greenSchool: "",
|
||||
greenScore: "0",
|
||||
greenTimerIndicator: "",
|
||||
clockText: "-",
|
||||
phaseLabel: "No Match",
|
||||
weightLabel: "Weight -",
|
||||
boutLabel: currentBoutNumber ? `Bout ${currentBoutNumber}` : "No Bout",
|
||||
redStats: "",
|
||||
greenStats: "",
|
||||
lastMatchResult: lastMatchResult || "-"
|
||||
}
|
||||
}
|
||||
|
||||
export function nextTimerBannerState(state, previousTimerSnapshot, now = Date.now()) {
|
||||
if (!state?.timers) {
|
||||
return { timerBannerState: null, previousTimerSnapshot: {} }
|
||||
}
|
||||
|
||||
const activeTimer = runningAuxiliaryTimer(state)
|
||||
const nextSnapshot = buildRunningTimerSnapshot(state)
|
||||
|
||||
if (activeTimer) {
|
||||
return {
|
||||
timerBannerState: {
|
||||
participantKey: activeTimer.participantKey,
|
||||
timerKey: activeTimer.timerKey,
|
||||
expiresAt: null
|
||||
},
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedTimer = detectRecentlyStoppedTimer(state, previousTimerSnapshot)
|
||||
if (stoppedTimer) {
|
||||
return {
|
||||
timerBannerState: {
|
||||
participantKey: stoppedTimer.participantKey,
|
||||
timerKey: stoppedTimer.timerKey,
|
||||
expiresAt: now + 10000
|
||||
},
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timerBannerState: null,
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
export function boardColors(isEmpty) {
|
||||
if (isEmpty) {
|
||||
return {
|
||||
red: "#000",
|
||||
center: "#000",
|
||||
green: "#000"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
red: "#c91f1f",
|
||||
center: "#050505",
|
||||
green: "#1cab2d"
|
||||
}
|
||||
}
|
||||
|
||||
export function timerBannerRenderState(config, state, timerBannerState, formatClock, now = Date.now()) {
|
||||
if (mainClockRunning(config, state)) {
|
||||
return {
|
||||
timerBannerState: timerBannerState?.expiresAt ? null : timerBannerState,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
if (!timerBannerState) {
|
||||
return {
|
||||
timerBannerState: null,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
if (timerBannerState.expiresAt && now > timerBannerState.expiresAt) {
|
||||
return {
|
||||
timerBannerState: null,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
const viewModel = timerBannerViewModel(config, state, timerBannerState, formatClock, now)
|
||||
if (!viewModel) {
|
||||
return {
|
||||
timerBannerState,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timerBannerState,
|
||||
visible: true,
|
||||
viewModel
|
||||
}
|
||||
}
|
||||
158
app/assets/javascripts/lib/match_state/scoreboard_state.js
Normal file
158
app/assets/javascripts/lib/match_state/scoreboard_state.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { buildStorageKey } from "match-state-serializers"
|
||||
|
||||
export function buildScoreboardContext({ initialBoutNumber, matchId }) {
|
||||
const currentQueueBoutNumber = initialBoutNumber > 0 ? initialBoutNumber : null
|
||||
|
||||
return {
|
||||
currentQueueBoutNumber,
|
||||
currentBoutNumber: currentQueueBoutNumber,
|
||||
currentMatchId: matchId || null,
|
||||
liveMatchData: {},
|
||||
lastMatchResult: "",
|
||||
state: null,
|
||||
finished: false,
|
||||
timerBannerState: null,
|
||||
previousTimerSnapshot: {}
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedBoutStorageKey(tournamentId, matId) {
|
||||
return `mat-selected-bout:${tournamentId}:${matId}`
|
||||
}
|
||||
|
||||
export function matchStorageKey(tournamentId, boutNumber) {
|
||||
if (!boutNumber) return null
|
||||
return buildStorageKey(tournamentId, boutNumber)
|
||||
}
|
||||
|
||||
export function extractLiveMatchData(data) {
|
||||
const extracted = {}
|
||||
if (data.w1_stat !== undefined) extracted.w1_stat = data.w1_stat
|
||||
if (data.w2_stat !== undefined) extracted.w2_stat = data.w2_stat
|
||||
if (data.score !== undefined) extracted.score = data.score
|
||||
if (data.win_type !== undefined) extracted.win_type = data.win_type
|
||||
if (data.winner_name !== undefined) extracted.winner_name = data.winner_name
|
||||
if (data.finished !== undefined) extracted.finished = data.finished
|
||||
return extracted
|
||||
}
|
||||
|
||||
export function applyStatePayloadContext(currentContext, payload) {
|
||||
return {
|
||||
...currentContext,
|
||||
state: payload,
|
||||
finished: Boolean(payload?.matchResult?.finished),
|
||||
currentBoutNumber: payload?.metadata?.boutNumber || currentContext.currentBoutNumber
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMatchPayloadContext(currentContext, data) {
|
||||
const nextContext = { ...currentContext }
|
||||
|
||||
if (data.scoreboard_state) {
|
||||
Object.assign(nextContext, applyStatePayloadContext(nextContext, data.scoreboard_state))
|
||||
}
|
||||
|
||||
nextContext.liveMatchData = {
|
||||
...currentContext.liveMatchData,
|
||||
...extractLiveMatchData(data)
|
||||
}
|
||||
|
||||
if (data.finished !== undefined) {
|
||||
nextContext.finished = Boolean(data.finished)
|
||||
}
|
||||
|
||||
return nextContext
|
||||
}
|
||||
|
||||
export function applyMatPayloadContext(currentContext, data) {
|
||||
const currentQueueBoutNumber = data.queue1_bout_number || null
|
||||
const lastMatchResult = data.last_match_result || ""
|
||||
|
||||
if (currentContext.sourceMode === "localstorage") {
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber: data.selected_bout_number || currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
unsubscribeMatch: false,
|
||||
subscribeMatchId: null,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
const nextMatchId = data.selected_match_id || data.queue1_match_id || null
|
||||
const nextBoutNumber = data.selected_bout_number || data.queue1_bout_number || null
|
||||
const matchChanged = nextMatchId !== currentContext.currentMatchId
|
||||
|
||||
if (!nextMatchId) {
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: nextBoutNumber,
|
||||
state: null,
|
||||
liveMatchData: {},
|
||||
resetTimerBanner: true,
|
||||
unsubscribeMatch: true,
|
||||
subscribeMatchId: null,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
currentMatchId: nextMatchId,
|
||||
currentBoutNumber: nextBoutNumber,
|
||||
state: matchChanged ? null : currentContext.state,
|
||||
liveMatchData: matchChanged ? {} : currentContext.liveMatchData,
|
||||
resetTimerBanner: matchChanged,
|
||||
unsubscribeMatch: false,
|
||||
subscribeMatchId: matchChanged ? nextMatchId : null,
|
||||
renderNow: matchChanged
|
||||
}
|
||||
}
|
||||
|
||||
export function connectionPlan(sourceMode, currentMatchId) {
|
||||
return {
|
||||
useStorageListener: sourceMode === "localstorage",
|
||||
subscribeMat: sourceMode === "localstorage" || sourceMode === "mat_websocket",
|
||||
subscribeMatch: sourceMode === "mat_websocket" || sourceMode === "websocket",
|
||||
matchId: sourceMode === "mat_websocket" || sourceMode === "websocket" ? currentMatchId : null,
|
||||
loadSelectedBout: sourceMode === "localstorage",
|
||||
loadLocalState: sourceMode === "localstorage"
|
||||
}
|
||||
}
|
||||
|
||||
export function storageChangePlan(currentContext, eventKey, tournamentId, matId) {
|
||||
const selectedKey = selectedBoutStorageKey(tournamentId, matId)
|
||||
if (eventKey === selectedKey) {
|
||||
return {
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
const storageKey = matchStorageKey(tournamentId, currentContext.currentBoutNumber)
|
||||
if (!storageKey || eventKey !== storageKey) {
|
||||
return {
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false,
|
||||
renderNow: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedBoutNumber(selection, currentQueueBoutNumber) {
|
||||
return selection?.boutNumber || currentQueueBoutNumber
|
||||
}
|
||||
66
app/assets/javascripts/lib/match_state/serializers.js
Normal file
66
app/assets/javascripts/lib/match_state/serializers.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
|
||||
export function buildMatchMetadata(values) {
|
||||
return {
|
||||
tournamentId: values.tournamentId,
|
||||
boutNumber: values.boutNumber,
|
||||
weightLabel: values.weightLabel,
|
||||
ruleset: values.ruleset,
|
||||
bracketPosition: values.bracketPosition,
|
||||
w1Name: values.w1Name,
|
||||
w2Name: values.w2Name,
|
||||
w1School: values.w1School,
|
||||
w2School: values.w2School
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStorageKey(tournamentId, boutNumber) {
|
||||
return `match-state:${tournamentId}:${boutNumber}`
|
||||
}
|
||||
|
||||
export function buildPersistedState(state, metadata) {
|
||||
return {
|
||||
...state,
|
||||
metadata
|
||||
}
|
||||
}
|
||||
|
||||
export function restorePersistedState(config, parsedState) {
|
||||
const initialState = buildInitialState(config)
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
...parsedState,
|
||||
participantScores: {
|
||||
...initialState.participantScores,
|
||||
...(parsedState.participantScores || {})
|
||||
},
|
||||
assignment: {
|
||||
...initialState.assignment,
|
||||
...(parsedState.assignment || {})
|
||||
},
|
||||
clock: {
|
||||
...initialState.clock,
|
||||
...(parsedState.clock || {})
|
||||
},
|
||||
timers: {
|
||||
w1: {
|
||||
...initialState.timers.w1,
|
||||
...(parsedState.timers?.w1 || {})
|
||||
},
|
||||
w2: {
|
||||
...initialState.timers.w2,
|
||||
...(parsedState.timers?.w2 || {})
|
||||
}
|
||||
},
|
||||
clocksByPhase: Object.fromEntries(
|
||||
Object.entries(initialState.clocksByPhase).map(([phaseKey, defaultClock]) => [
|
||||
phaseKey,
|
||||
{
|
||||
...defaultClock,
|
||||
...(parsedState.clocksByPhase?.[phaseKey] || {})
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/assets/javascripts/lib/match_state/transport.js
Normal file
39
app/assets/javascripts/lib/match_state/transport.js
Normal file
@@ -0,0 +1,39 @@
|
||||
export function loadJson(storage, key) {
|
||||
try {
|
||||
const rawValue = storage.getItem(key)
|
||||
if (!rawValue) return null
|
||||
return JSON.parse(rawValue)
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJson(storage, key, value) {
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(value))
|
||||
return true
|
||||
} catch (_error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function removeKey(storage, key) {
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
return true
|
||||
} catch (_error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function performIfChanged(subscription, action, payload, lastSerializedPayload) {
|
||||
if (!subscription) return lastSerializedPayload
|
||||
|
||||
const serializedPayload = JSON.stringify(payload)
|
||||
if (serializedPayload === lastSerializedPayload) {
|
||||
return lastSerializedPayload
|
||||
}
|
||||
|
||||
subscription.perform(action, payload)
|
||||
return serializedPayload
|
||||
}
|
||||
15
app/channels/mat_scoreboard_channel.rb
Normal file
15
app/channels/mat_scoreboard_channel.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class MatScoreboardChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
@mat = Mat.find_by(id: params[:mat_id])
|
||||
return reject unless @mat
|
||||
|
||||
stream_for @mat
|
||||
transmit(scoreboard_payload(@mat))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scoreboard_payload(mat)
|
||||
mat.scoreboard_payload
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class MatchChannel < ApplicationCable::Channel
|
||||
SCOREBOARD_CACHE_TTL = 1.hours
|
||||
|
||||
def subscribed
|
||||
@match = Match.find_by(id: params[:match_id])
|
||||
Rails.logger.info "[MatchChannel] Client subscribed with match_id: #{params[:match_id]}. Match found: #{@match.present?}"
|
||||
@@ -11,6 +13,19 @@ class MatchChannel < ApplicationCable::Channel
|
||||
end
|
||||
end
|
||||
|
||||
def send_scoreboard(data)
|
||||
unless @match
|
||||
Rails.logger.error "[MatchChannel] Error: send_scoreboard called but @match is nil. Client params on sub: #{params[:match_id]}"
|
||||
return
|
||||
end
|
||||
|
||||
scoreboard_state = data["scoreboard_state"]
|
||||
return if scoreboard_state.blank?
|
||||
|
||||
Rails.cache.write(scoreboard_cache_key, scoreboard_state, expires_in: SCOREBOARD_CACHE_TTL)
|
||||
MatchChannel.broadcast_to(@match, { scoreboard_state: scoreboard_state })
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
Rails.logger.info "[MatchChannel] Client unsubscribed for match #{@match&.id}"
|
||||
end
|
||||
@@ -75,7 +90,8 @@ class MatchChannel < ApplicationCable::Channel
|
||||
win_type: @match.win_type,
|
||||
winner_name: @match.winner&.name,
|
||||
winner_id: @match.winner_id,
|
||||
finished: @match.finished
|
||||
finished: @match.finished,
|
||||
scoreboard_state: Rails.cache.read(scoreboard_cache_key)
|
||||
}.compact
|
||||
|
||||
if payload.present?
|
||||
@@ -85,4 +101,10 @@ class MatchChannel < ApplicationCable::Channel
|
||||
Rails.logger.info "[MatchChannel] request_sync payload empty for match #{@match.id}, not transmitting."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scoreboard_cache_key
|
||||
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class MatchesController < ApplicationController
|
||||
before_action :set_match, only: [:show, :edit, :update, :stat, :spectate, :edit_assignment, :update_assignment]
|
||||
before_action :check_access, only: [:edit, :update, :stat, :edit_assignment, :update_assignment]
|
||||
before_action :set_match, only: [:show, :edit, :update, :stat, :state, :spectate, :edit_assignment, :update_assignment]
|
||||
before_action :check_access, only: [:edit, :update, :stat, :state, :edit_assignment, :update_assignment]
|
||||
before_action :check_read_access, only: [:spectate]
|
||||
|
||||
# GET /matches/1
|
||||
# GET /matches/1.json
|
||||
@@ -22,49 +23,12 @@ class MatchesController < ApplicationController
|
||||
end
|
||||
|
||||
def stat
|
||||
# @show_next_bout_button = false
|
||||
if params[:match]
|
||||
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
|
||||
end
|
||||
@wrestlers = []
|
||||
if @match
|
||||
if @match.w1
|
||||
@wrestler1_name = @match.wrestler1.name
|
||||
@wrestler1_school_name = @match.wrestler1.school.name
|
||||
@wrestler1_last_match = @match.wrestler1.last_match
|
||||
@wrestlers.push(@match.wrestler1)
|
||||
else
|
||||
@wrestler1_name = "Not assigned"
|
||||
@wrestler1_school_name = "N/A"
|
||||
@wrestler1_last_match = nil
|
||||
end
|
||||
if @match.w2
|
||||
@wrestler2_name = @match.wrestler2.name
|
||||
@wrestler2_school_name = @match.wrestler2.school.name
|
||||
@wrestler2_last_match = @match.wrestler2.last_match
|
||||
@wrestlers.push(@match.wrestler2)
|
||||
else
|
||||
@wrestler2_name = "Not assigned"
|
||||
@wrestler2_school_name = "N/A"
|
||||
@wrestler2_last_match = nil
|
||||
end
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
if @match&.mat
|
||||
@mat = @match.mat
|
||||
queue_position = @mat.queue_position_for_match(@match)
|
||||
@next_match = queue_position == 1 ? @mat.queue2_match : nil
|
||||
@show_next_bout_button = queue_position == 1
|
||||
if request.referer&.include?("/tournaments/#{@tournament.id}/matches")
|
||||
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
|
||||
else
|
||||
session[:return_path] = mat_path(@mat)
|
||||
end
|
||||
session[:error_return_path] = "/matches/#{@match.id}/stat"
|
||||
else
|
||||
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
|
||||
session[:error_return_path] = "/matches/#{@match.id}/stat"
|
||||
end
|
||||
load_match_stat_context
|
||||
end
|
||||
|
||||
def state
|
||||
load_match_stat_context
|
||||
@match_state_ruleset = "folkstyle_usa"
|
||||
end
|
||||
|
||||
# GET /matches/:id/spectate
|
||||
@@ -142,26 +106,19 @@ class MatchesController < ApplicationController
|
||||
win_type: @match.win_type,
|
||||
winner_id: @match.winner_id,
|
||||
winner_name: @match.winner&.name,
|
||||
finished: @match.finished
|
||||
finished: @match.finished,
|
||||
scoreboard_state: Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
|
||||
}
|
||||
)
|
||||
|
||||
if session[:return_path]
|
||||
sanitized_return_path = sanitize_return_path(session[:return_path])
|
||||
format.html { redirect_to sanitized_return_path, notice: 'Match was successfully updated.' }
|
||||
session.delete(:return_path) # Remove the session variable
|
||||
else
|
||||
format.html { redirect_to "/tournaments/#{@match.tournament.id}", notice: 'Match was successfully updated.' }
|
||||
end
|
||||
redirect_path = resolve_match_redirect_path(session[:return_path]) || "/tournaments/#{@match.tournament.id}"
|
||||
format.html { redirect_to redirect_path, notice: 'Match was successfully updated.' }
|
||||
session.delete(:return_path)
|
||||
format.json { head :no_content }
|
||||
else
|
||||
if session[:error_return_path]
|
||||
format.html { redirect_to session.delete(:error_return_path), alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
format.json { render json: @match.errors, status: :unprocessable_entity }
|
||||
else
|
||||
format.html { redirect_to "/tournaments/#{@match.tournament.id}", alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
format.json { render json: @match.errors, status: :unprocessable_entity }
|
||||
end
|
||||
error_path = resolve_match_redirect_path(session[:error_return_path]) || "/tournaments/#{@match.tournament.id}"
|
||||
format.html { redirect_to error_path, alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
format.json { render json: @match.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -182,11 +139,66 @@ class MatchesController < ApplicationController
|
||||
authorize! :manage, @match.tournament
|
||||
end
|
||||
|
||||
def sanitize_return_path(path)
|
||||
def check_read_access
|
||||
authorize! :read, @match.tournament
|
||||
end
|
||||
|
||||
def sanitize_redirect_path(path)
|
||||
return nil if path.blank?
|
||||
|
||||
uri = URI.parse(path)
|
||||
params = Rack::Utils.parse_nested_query(uri.query)
|
||||
params.delete("bout_number") # Remove the bout_number param
|
||||
uri.query = params.to_query.presence # Rebuild the query string or set it to nil if empty
|
||||
uri.to_s # Return the full path as a string
|
||||
end
|
||||
return nil if uri.scheme.present? || uri.host.present?
|
||||
|
||||
uri.to_s
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_match_redirect_path(fallback_path)
|
||||
sanitize_redirect_path(params[:redirect_to].presence) || sanitize_redirect_path(fallback_path)
|
||||
end
|
||||
|
||||
def load_match_stat_context
|
||||
if params[:match]
|
||||
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
|
||||
end
|
||||
|
||||
@wrestlers = []
|
||||
if @match
|
||||
if @match.w1
|
||||
@wrestler1_name = @match.wrestler1.name
|
||||
@wrestler1_school_name = @match.wrestler1.school.name
|
||||
@wrestler1_last_match = @match.wrestler1.last_match
|
||||
@wrestlers.push(@match.wrestler1)
|
||||
else
|
||||
@wrestler1_name = "Not assigned"
|
||||
@wrestler1_school_name = "N/A"
|
||||
@wrestler1_last_match = nil
|
||||
end
|
||||
|
||||
if @match.w2
|
||||
@wrestler2_name = @match.wrestler2.name
|
||||
@wrestler2_school_name = @match.wrestler2.school.name
|
||||
@wrestler2_last_match = @match.wrestler2.last_match
|
||||
@wrestlers.push(@match.wrestler2)
|
||||
else
|
||||
@wrestler2_name = "Not assigned"
|
||||
@wrestler2_school_name = "N/A"
|
||||
@wrestler2_last_match = nil
|
||||
end
|
||||
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
|
||||
if @match&.mat
|
||||
@mat = @match.mat
|
||||
queue_position = @mat.queue_position_for_match(@match)
|
||||
@next_match = queue_position == 1 ? @mat.queue2_match : nil
|
||||
@show_next_bout_button = queue_position == 1
|
||||
end
|
||||
|
||||
@match_results_redirect_path = sanitize_redirect_path(params[:redirect_to].presence) || "/tournaments/#{@tournament.id}/matches"
|
||||
session[:return_path] = @match_results_redirect_path
|
||||
session[:error_return_path] = request.original_fullpath
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class MatsController < ApplicationController
|
||||
before_action :set_mat, only: [:show, :edit, :update, :destroy, :assign_next_match]
|
||||
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :assign_next_match]
|
||||
before_action :set_mat, only: [:show, :state, :scoreboard, :edit, :update, :destroy, :assign_next_match, :select_match]
|
||||
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :state, :scoreboard, :assign_next_match, :select_match]
|
||||
|
||||
# GET /mats/1
|
||||
# GET /mats/1.json
|
||||
@@ -44,10 +44,33 @@ class MatsController < ApplicationController
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
|
||||
session[:return_path] = request.original_fullpath
|
||||
@match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath)
|
||||
session[:return_path] = @match_results_redirect_path
|
||||
session[:error_return_path] = request.original_fullpath
|
||||
end
|
||||
|
||||
def scoreboard
|
||||
@match = @mat.selected_scoreboard_match || @mat.queue1_match
|
||||
@tournament = @mat.tournament
|
||||
end
|
||||
|
||||
def state
|
||||
load_mat_match_context
|
||||
@match_state_ruleset = "folkstyle_usa"
|
||||
end
|
||||
|
||||
def select_match
|
||||
selected_match = @mat.queue_matches.compact.find do |match|
|
||||
match.id == params[:match_id].to_i || match.bout_number == params[:bout_number].to_i
|
||||
end
|
||||
|
||||
return head :unprocessable_entity unless selected_match || params[:last_match_result].present?
|
||||
|
||||
@mat.set_selected_scoreboard_match!(selected_match) if selected_match
|
||||
@mat.set_last_match_result!(params[:last_match_result]) if params.key?(:last_match_result)
|
||||
head :no_content
|
||||
end
|
||||
|
||||
# GET /mats/new
|
||||
def new
|
||||
@mat = Mat.new
|
||||
@@ -139,6 +162,66 @@ class MatsController < ApplicationController
|
||||
end
|
||||
authorize! :manage, @tournament
|
||||
end
|
||||
|
||||
def sanitize_mat_redirect_path(path)
|
||||
return nil if path.blank?
|
||||
|
||||
uri = URI.parse(path)
|
||||
return nil if uri.scheme.present? || uri.host.present?
|
||||
|
||||
params = Rack::Utils.parse_nested_query(uri.query)
|
||||
params.delete("bout_number")
|
||||
uri.query = params.to_query.presence
|
||||
uri.to_s
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def load_mat_match_context
|
||||
bout_number_param = params[:bout_number]
|
||||
@queue_matches = @mat.queue_matches
|
||||
@match = if bout_number_param
|
||||
@queue_matches.compact.find { |match| match.bout_number == bout_number_param.to_i }
|
||||
else
|
||||
@queue_matches[0]
|
||||
end
|
||||
@match ||= @queue_matches[0]
|
||||
@next_match = @queue_matches[1]
|
||||
@show_next_bout_button = false
|
||||
|
||||
@wrestlers = []
|
||||
if @match
|
||||
if @match.w1
|
||||
@wrestler1_name = @match.wrestler1.name
|
||||
@wrestler1_school_name = @match.wrestler1.school.name
|
||||
@wrestler1_last_match = @match.wrestler1.last_match
|
||||
@wrestlers.push(@match.wrestler1)
|
||||
else
|
||||
@wrestler1_name = "Not assigned"
|
||||
@wrestler1_school_name = "N/A"
|
||||
@wrestler1_last_match = nil
|
||||
end
|
||||
|
||||
if @match.w2
|
||||
@wrestler2_name = @match.wrestler2.name
|
||||
@wrestler2_school_name = @match.wrestler2.school.name
|
||||
@wrestler2_last_match = @match.wrestler2.last_match
|
||||
@wrestlers.push(@match.wrestler2)
|
||||
else
|
||||
@wrestler2_name = "Not assigned"
|
||||
@wrestler2_school_name = "N/A"
|
||||
@wrestler2_last_match = nil
|
||||
end
|
||||
|
||||
@tournament = @match.tournament
|
||||
else
|
||||
@tournament = @mat.tournament
|
||||
end
|
||||
|
||||
@match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath)
|
||||
session[:return_path] = @match_results_redirect_path
|
||||
session[:error_return_path] = request.original_fullpath
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
class TournamentsController < ApplicationController
|
||||
before_action :set_tournament, only: [:all_results, :delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:bout_sheets,:swap,:weigh_in_sheet,:error,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:remove_delegate,:school_delegate,:delegate,:matches,:weigh_in,:weigh_in_weight,:create_custom_weights,:show,:edit,:update,:destroy,:up_matches,:no_matches,:team_scores,:generate_matches,:bracket,:all_brackets,:qrcode]
|
||||
before_action :set_tournament, only: [:all_results, :delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:bout_sheets,:swap,:weigh_in_sheet,:error,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:remove_delegate,:school_delegate,:delegate,:matches,:weigh_in,:weigh_in_weight,:create_custom_weights,:show,:edit,:update,:destroy,:up_matches,:no_matches,:team_scores,:generate_matches,:bracket,:all_brackets,:qrcode,:live_scores]
|
||||
before_action :check_access_manage, only: [:delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:swap,:weigh_in_sheet,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:school_delegate,:weigh_in,:weigh_in_weight,:create_custom_weights,:update,:edit,:generate_matches,:matches,:qrcode]
|
||||
before_action :check_access_destroy, only: [:destroy,:delegate,:remove_delegate]
|
||||
before_action :check_tournament_errors, only: [:generate_matches]
|
||||
before_action :check_for_matches, only: [:all_results,:bracket,:all_brackets]
|
||||
before_action :check_access_read, only: [:all_results,:up_matches,:bracket,:all_brackets]
|
||||
before_action :check_access_read, only: [:all_results,:up_matches,:bracket,:all_brackets,:live_scores]
|
||||
|
||||
def weigh_in_sheet
|
||||
@schools = @tournament.schools.includes(wrestlers: :weight)
|
||||
@@ -242,6 +242,10 @@ class TournamentsController < ApplicationController
|
||||
@bracket_position = nil
|
||||
end
|
||||
|
||||
def live_scores
|
||||
@mats = @tournament.mats.sort_by(&:name)
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
GenerateTournamentMatches.new(@tournament).generate
|
||||
end
|
||||
|
||||
@@ -7,6 +7,8 @@ class Mat < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
|
||||
QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze
|
||||
SCOREBOARD_SELECTION_CACHE_TTL = 1.hours
|
||||
LAST_MATCH_RESULT_CACHE_TTL = 1.hours
|
||||
|
||||
after_save :clear_queue_matches_cache
|
||||
after_commit :broadcast_up_matches_board, on: :update, if: :up_matches_queue_changed?
|
||||
@@ -191,6 +193,56 @@ class Mat < ApplicationRecord
|
||||
matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number}
|
||||
end
|
||||
|
||||
def scoreboard_payload
|
||||
selected_match = selected_scoreboard_match
|
||||
{
|
||||
mat_id: id,
|
||||
queue1_bout_number: queue1_match&.bout_number,
|
||||
queue1_match_id: queue1_match&.id,
|
||||
selected_bout_number: selected_match&.bout_number,
|
||||
selected_match_id: selected_match&.id,
|
||||
last_match_result: last_match_result_text
|
||||
}
|
||||
end
|
||||
|
||||
def set_selected_scoreboard_match!(match)
|
||||
if match
|
||||
Rails.cache.write(
|
||||
scoreboard_selection_cache_key,
|
||||
{ match_id: match.id, bout_number: match.bout_number },
|
||||
expires_in: SCOREBOARD_SELECTION_CACHE_TTL
|
||||
)
|
||||
else
|
||||
Rails.cache.delete(scoreboard_selection_cache_key)
|
||||
end
|
||||
broadcast_current_match
|
||||
end
|
||||
|
||||
def selected_scoreboard_match
|
||||
selection = Rails.cache.read(scoreboard_selection_cache_key)
|
||||
return nil unless selection
|
||||
|
||||
match_id = selection[:match_id] || selection["match_id"]
|
||||
selected_match = queue_matches.compact.find { |match| match.id == match_id }
|
||||
return selected_match if selected_match
|
||||
|
||||
Rails.cache.delete(scoreboard_selection_cache_key)
|
||||
nil
|
||||
end
|
||||
|
||||
def set_last_match_result!(text)
|
||||
if text.present?
|
||||
Rails.cache.write(last_match_result_cache_key, text, expires_in: LAST_MATCH_RESULT_CACHE_TTL)
|
||||
else
|
||||
Rails.cache.delete(last_match_result_cache_key)
|
||||
end
|
||||
broadcast_current_match
|
||||
end
|
||||
|
||||
def last_match_result_text
|
||||
Rails.cache.read(last_match_result_cache_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clear_queue_matches_cache
|
||||
@@ -276,6 +328,15 @@ class Mat < ApplicationRecord
|
||||
show_next_bout_button: true
|
||||
}
|
||||
)
|
||||
MatScoreboardChannel.broadcast_to(self, scoreboard_payload)
|
||||
end
|
||||
|
||||
def scoreboard_selection_cache_key
|
||||
"tournament:#{tournament_id}:mat:#{id}:scoreboard_selection"
|
||||
end
|
||||
|
||||
def last_match_result_cache_key
|
||||
"tournament:#{tournament_id}:mat:#{id}:last_match_result"
|
||||
end
|
||||
|
||||
def broadcast_up_matches_board
|
||||
|
||||
@@ -27,9 +27,10 @@
|
||||
<% end %>
|
||||
<li><%= link_to "All Brackets (Printable)", "/tournaments/#{@tournament.id}/all_brackets?print=true", target: :_blank %></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></li>
|
||||
<% end %>
|
||||
</li>
|
||||
<li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></li>
|
||||
<li><%= link_to " Live Scores" , "/tournaments/#{@tournament.id}/live_scores", class: "fas fa-tv" %></li>
|
||||
<% end %>
|
||||
<% if can? :manage, @tournament %>
|
||||
<li class="dropdown">
|
||||
<a class="dropdown-toggle" data-toggle="dropdown" href="#director"><i class="fas fa-tools"> Director Links</i>
|
||||
|
||||
70
app/views/matches/_match_results_fields.html.erb
Normal file
70
app/views/matches/_match_results_fields.html.erb
Normal file
@@ -0,0 +1,70 @@
|
||||
<% submit_label = local_assigns.fetch(:submit_label, "Update Match") %>
|
||||
<% redirect_path = local_assigns[:redirect_path] %>
|
||||
|
||||
<h4>Match Results</h4>
|
||||
<br>
|
||||
<div data-controller="match-score" data-match-score-finished-value="<%= @match.finished == 1 %>">
|
||||
<div class="field">
|
||||
<%= f.label "Win type" %><br>
|
||||
<%= f.select :win_type, Match::WIN_TYPES, { include_blank: false }, {
|
||||
data: {
|
||||
match_score_target: "winType",
|
||||
action: "change->match-score#winTypeChanged"
|
||||
}
|
||||
} %>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Overtime Type" %> Leave blank if not overtime. For High School the 1st overtime is SV-1, second overtime is TB-1, third overtime is UTB.<br>
|
||||
<%= f.select(:overtime_type, Match::OVERTIME_TYPES, {}, {
|
||||
data: {
|
||||
match_score_target: "overtimeSelect"
|
||||
}
|
||||
}) %>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Winner" %> Please choose the winner<br>
|
||||
<%= f.collection_select :winner_id, @wrestlers, :id, :name_with_school,
|
||||
{ include_blank: true },
|
||||
{
|
||||
data: {
|
||||
match_score_target: "winnerSelect",
|
||||
action: "change->match-score#winnerChanged"
|
||||
}
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Final Score" %>
|
||||
<br>
|
||||
<% if @match.finished == 1 %>
|
||||
<%= f.text_field :score, id: "final-score-field", data: { match_score_target: "finalScoreField" }, class: "form-control", style: "max-width: 220px;" %>
|
||||
<% else %>
|
||||
<span id="score-help-text">
|
||||
The input will adjust based on the selected win type.
|
||||
</span>
|
||||
<br>
|
||||
<div id="dynamic-score-input" data-match-score-target="dynamicScoreInput"></div>
|
||||
<p id="pin-time-tip" class="text-muted mt-2" style="display: none;" data-match-score-target="pinTimeTip">
|
||||
<strong>Tip:</strong> Pin time is an accumulation over the match, not how much time was left in the current period.
|
||||
<br>For example, if all 3 periods are 2 minutes and a pin happened with 1:27 left in the second period,
|
||||
the pin time would be <strong>2:33</strong> (2 minutes for the first period + 33 seconds elapsed in the second period).
|
||||
</p>
|
||||
<%= f.hidden_field :score, id: "final-score-field", data: { match_score_target: "finalScoreField" } %>
|
||||
<% end %>
|
||||
<div id="validation-alerts" class="text-danger mt-2" data-match-score-target="validationAlerts"></div>
|
||||
<%= hidden_field_tag :redirect_to, redirect_path if redirect_path.present? %>
|
||||
<%= f.hidden_field :finished, value: 1 %>
|
||||
<%= f.hidden_field :round, value: @match.round %>
|
||||
|
||||
<br>
|
||||
<%= f.submit submit_label, id: "update-match-btn",
|
||||
data: {
|
||||
match_score_target: "submitButton",
|
||||
action: "click->match-score#confirmWinner"
|
||||
},
|
||||
class: "btn btn-success" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,65 +130,6 @@
|
||||
<br>
|
||||
<br>
|
||||
<br>
|
||||
<h4>Match Results</h4>
|
||||
<br>
|
||||
<div data-controller="match-score">
|
||||
<div class="field">
|
||||
<%= f.label "Win type" %><br>
|
||||
<%= f.select :win_type, Match::WIN_TYPES, { include_blank: false }, {
|
||||
data: {
|
||||
match_score_target: "winType",
|
||||
action: "change->match-score#winTypeChanged"
|
||||
}
|
||||
} %>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Overtime Type" %> Leave blank if not overtime. For High School the 1st overtime is SV-1, second overtime is TB-1, third overtime is UTB.<br>
|
||||
<%= f.select(:overtime_type, Match::OVERTIME_TYPES) %>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Winner" %> Please choose the winner<br>
|
||||
<%= f.collection_select :winner_id, @wrestlers, :id, :name_with_school,
|
||||
{ include_blank: true },
|
||||
{
|
||||
data: {
|
||||
match_score_target: "winnerSelect",
|
||||
action: "change->match-score#winnerChanged"
|
||||
}
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Final Score" %>
|
||||
<br>
|
||||
<span id="score-help-text">
|
||||
The input will adjust based on the selected win type.
|
||||
</span>
|
||||
<br>
|
||||
<div id="dynamic-score-input" data-match-score-target="dynamicScoreInput"></div>
|
||||
<p id="pin-time-tip" class="text-muted mt-2" style="display: none;" data-match-score-target="pinTimeTip">
|
||||
<strong>Tip:</strong> Pin time is an accumulation over the match, not how much time was left in the current period.
|
||||
<br>For example, if all 3 periods are 2 minutes and a pin happened with 1:27 left in the second period,
|
||||
the pin time would be <strong>2:33</strong> (2 minutes for the first period + 33 seconds elapsed in the second period).
|
||||
</p>
|
||||
<div id="validation-alerts" class="text-danger mt-2" data-match-score-target="validationAlerts"></div>
|
||||
<%= f.hidden_field :score, id: "final-score-field", data: { match_score_target: "finalScoreField" } %>
|
||||
|
||||
<br>
|
||||
<%= f.submit "Update Match", id: "update-match-btn",
|
||||
data: {
|
||||
match_score_target: "submitButton",
|
||||
action: "click->match-score#confirmWinner"
|
||||
},
|
||||
class: "btn btn-success" %>
|
||||
</div>
|
||||
</div><!-- End of match-score controller -->
|
||||
<%= render "matches/match_results_fields", f: f, redirect_path: @match_results_redirect_path %>
|
||||
</div><!-- End of match-data controller div -->
|
||||
|
||||
<br>
|
||||
<%= f.hidden_field :finished, :value => 1 %>
|
||||
<%= f.hidden_field :round, :value => @match.round %>
|
||||
<% end %><!-- End of form_for -->
|
||||
|
||||
113
app/views/matches/_scoreboard.html.erb
Normal file
113
app/views/matches/_scoreboard.html.erb
Normal file
@@ -0,0 +1,113 @@
|
||||
<%
|
||||
source_mode = local_assigns.fetch(:source_mode, "localstorage")
|
||||
display_mode = local_assigns.fetch(:display_mode, "fullscreen")
|
||||
show_mat_banner = local_assigns.fetch(:show_mat_banner, false)
|
||||
show_stats = local_assigns.fetch(:show_stats, false)
|
||||
mat = local_assigns[:mat]
|
||||
match = local_assigns[:match]
|
||||
tournament = local_assigns[:tournament] || match&.tournament
|
||||
|
||||
fullscreen = display_mode == "fullscreen"
|
||||
root_style = if fullscreen
|
||||
"min-height: 100vh; background: #000; color: #fff; display: flex; align-items: stretch; justify-content: stretch; padding: 0; margin: 0; position: relative;"
|
||||
elsif show_stats
|
||||
"background: #000; color: #fff; padding: 0; margin: 0; position: relative; width: 100%; border: 1px solid #222;"
|
||||
else
|
||||
"background: #000; color: #fff; display: flex; align-items: stretch; justify-content: stretch; padding: 0; margin: 0; position: relative; width: 100%; min-height: 260px; border: 1px solid #222;"
|
||||
end
|
||||
inner_style = if fullscreen
|
||||
"display: grid; grid-template-rows: auto 1fr; width: 100vw; min-height: 100vh;"
|
||||
elsif show_stats
|
||||
"display: grid; grid-template-rows: auto auto auto; width: 100%;"
|
||||
else
|
||||
"display: grid; grid-template-rows: auto 1fr; width: 100%; min-height: 260px;"
|
||||
end
|
||||
board_style = fullscreen ? "display: grid; grid-template-columns: 1fr 1.3fr 1fr; width: 100%; min-height: 0;" : "display: grid; grid-template-columns: 1fr 1.2fr 1fr; width: 100%; min-height: 0; min-height: 260px;"
|
||||
panel_padding = fullscreen ? "2vw" : "1rem"
|
||||
name_size = fullscreen ? "clamp(2rem, 4vw, 4rem)" : "clamp(1rem, 2vw, 1.8rem)"
|
||||
school_size = fullscreen ? "clamp(1rem, 2vw, 2rem)" : "clamp(0.85rem, 1.3vw, 1.1rem)"
|
||||
score_size = fullscreen ? "clamp(8rem, 18vw, 16rem)" : "clamp(3rem, 8vw, 6rem)"
|
||||
period_size = fullscreen ? "clamp(1.5rem, 3vw, 2.5rem)" : "clamp(1rem, 2vw, 1.5rem)"
|
||||
clock_size = fullscreen ? "clamp(6rem, 16vw, 14rem)" : "clamp(3rem, 10vw, 5.5rem)"
|
||||
banner_offset = fullscreen ? "6vw" : "2rem"
|
||||
banner_border = fullscreen ? "0.45vw" : "4px"
|
||||
center_border = fullscreen ? "1vw" : "6px"
|
||||
%>
|
||||
|
||||
<div
|
||||
data-controller="match-scoreboard"
|
||||
data-match-scoreboard-source-mode-value="<%= source_mode %>"
|
||||
data-match-scoreboard-display-mode-value="<%= display_mode %>"
|
||||
data-match-scoreboard-match-id-value="<%= match&.id || 0 %>"
|
||||
data-match-scoreboard-mat-id-value="<%= mat&.id || 0 %>"
|
||||
data-match-scoreboard-tournament-id-value="<%= tournament&.id || 0 %>"
|
||||
data-match-scoreboard-initial-bout-number-value="<%= match&.bout_number || 0 %>"
|
||||
style="<%= root_style %>">
|
||||
<div style="<%= inner_style %>">
|
||||
<% if show_mat_banner %>
|
||||
<div style="background: #111; color: #fff; text-align: center; padding: 1vh 2vw; border-bottom: 0.5vw solid #fff;">
|
||||
<div style="font-size: clamp(1.5rem, 3vw, 3rem); font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;">
|
||||
Mat <%= mat&.name %>
|
||||
</div>
|
||||
<div data-match-scoreboard-target="boutLabel" style="font-size: clamp(1rem, 2vw, 1.75rem); letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.95; margin-top: 0.3rem;">
|
||||
Bout <%= match&.bout_number || "" %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div style="background: #111; color: #fff; text-align: center; padding: 0.35rem 1rem; border-bottom: 4px solid #fff;">
|
||||
<div data-match-scoreboard-target="boutLabel" style="font-size: clamp(0.85rem, 1.3vw, 1.1rem); letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.95;">
|
||||
Bout <%= match&.bout_number || "" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div style="<%= board_style %>">
|
||||
<section data-match-scoreboard-target="redSection" style="background: #c91f1f; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: <%= panel_padding %>; text-align: center;">
|
||||
<div data-match-scoreboard-target="redName" style="font-size: <%= name_size %>; font-weight: 700; line-height: 1;">NO MATCH</div>
|
||||
<div data-match-scoreboard-target="redSchool" style="font-size: <%= school_size %>; margin-top: 0.75rem; opacity: 0.95;"></div>
|
||||
<div data-match-scoreboard-target="redTimerIndicator" style="font-size: <%= school_size %>; margin-top: 0.75rem;"></div>
|
||||
<div data-match-scoreboard-target="redScore" style="font-size: <%= score_size %>; font-weight: 800; line-height: 0.9; margin-top: 3vh;">0</div>
|
||||
</section>
|
||||
|
||||
<section data-match-scoreboard-target="centerSection" style="background: #050505; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: <%= panel_padding %>; text-align: center; border-left: <%= center_border %> solid #fff; border-right: <%= center_border %> solid #fff;">
|
||||
<div data-match-scoreboard-target="periodLabel" style="font-size: <%= period_size %>; font-weight: 700; margin-top: 2vh; text-transform: uppercase;">No Match</div>
|
||||
<div data-match-scoreboard-target="clock" style="font-size: <%= clock_size %>; font-weight: 800; line-height: 0.9; margin-top: 3vh;">-</div>
|
||||
<div data-match-scoreboard-target="emptyState" style="display: none; margin-top: 4vh; font-size: clamp(1.5rem, 3vw, 4rem); font-weight: 700; color: #fff;">No Match</div>
|
||||
</section>
|
||||
|
||||
<section data-match-scoreboard-target="greenSection" style="background: #1cab2d; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: <%= panel_padding %>; text-align: center;">
|
||||
<div data-match-scoreboard-target="greenName" style="font-size: <%= name_size %>; font-weight: 700; line-height: 1;">NO MATCH</div>
|
||||
<div data-match-scoreboard-target="greenSchool" style="font-size: <%= school_size %>; margin-top: 0.75rem; opacity: 0.95;"></div>
|
||||
<div data-match-scoreboard-target="greenTimerIndicator" style="font-size: <%= school_size %>; margin-top: 0.75rem;"></div>
|
||||
<div data-match-scoreboard-target="greenScore" style="font-size: <%= score_size %>; font-weight: 800; line-height: 0.9; margin-top: 3vh;">0</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<% if show_stats %>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; background: #fff; color: #111; padding: 12px;">
|
||||
<section style="border: 1px solid #d9d9d9; padding: 12px; min-width: 0;">
|
||||
<div style="font-weight: 700; margin-bottom: 8px;">Red Stats</div>
|
||||
<pre data-match-scoreboard-target="redStats" style="margin: 0; background: #f7f7f7; border: 1px solid #ececec; padding: 10px; min-height: 120px; white-space: pre-wrap; overflow-wrap: anywhere;"></pre>
|
||||
</section>
|
||||
|
||||
<section style="border: 1px solid #d9d9d9; padding: 12px; min-width: 0;">
|
||||
<div style="font-weight: 700; margin-bottom: 8px;">Green Stats</div>
|
||||
<pre data-match-scoreboard-target="greenStats" style="margin: 0; background: #f7f7f7; border: 1px solid #ececec; padding: 10px; min-height: 120px; white-space: pre-wrap; overflow-wrap: anywhere;"></pre>
|
||||
</section>
|
||||
</div>
|
||||
<div style="background: #fff; color: #111; padding: 0 12px 12px;">
|
||||
<section style="border: 1px solid #d9d9d9; padding: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 8px;">Last Match Result</div>
|
||||
<div data-match-scoreboard-target="lastMatchResult">-</div>
|
||||
</section>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-match-scoreboard-target="timerBanner"
|
||||
style="display: none; position: absolute; left: <%= banner_offset %>; right: <%= banner_offset %>; top: 50%; transform: translateY(-50%); background: rgba(15, 15, 15, 0.96); border: <%= banner_border %> solid #fff; padding: 1.5vh 2vw; text-align: center; z-index: 20;">
|
||||
<div data-match-scoreboard-target="timerBannerLabel" style="font-size: <%= fullscreen ? "clamp(1.3rem, 2.6vw, 2.6rem)" : "clamp(0.9rem, 1.6vw, 1.25rem)" %>; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;"></div>
|
||||
<div data-match-scoreboard-target="timerBannerClock" style="font-size: <%= fullscreen ? "clamp(2.5rem, 6vw, 6rem)" : "clamp(1.6rem, 4vw, 3rem)" %>; font-weight: 800; line-height: 1; margin-top: 0.5vh;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,6 +10,17 @@
|
||||
class="alert alert-secondary"
|
||||
style="padding: 5px; margin-bottom: 10px; border-radius: 4px;"></div>
|
||||
|
||||
<% unless @match.finished %>
|
||||
<div data-match-spectate-target="scoreboardContainer" style="margin-bottom: 20px;">
|
||||
<%= render "matches/scoreboard",
|
||||
source_mode: "websocket",
|
||||
display_mode: "embedded",
|
||||
show_mat_banner: false,
|
||||
match: @match,
|
||||
tournament: @tournament %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="match-details">
|
||||
<div class="wrestler-info wrestler1">
|
||||
<h4><%= @wrestler1_name %> (<%= @wrestler1_school_name %>)</h4>
|
||||
@@ -83,4 +94,4 @@
|
||||
color: white;
|
||||
}
|
||||
*/
|
||||
</style>
|
||||
</style>
|
||||
|
||||
108
app/views/matches/state.html.erb
Normal file
108
app/views/matches/state.html.erb
Normal file
@@ -0,0 +1,108 @@
|
||||
<h1><%= @wrestler1_name %> VS. <%= @wrestler2_name %></h1>
|
||||
|
||||
<div
|
||||
data-controller="match-state"
|
||||
data-match-state-match-id-value="<%= @match.id %>"
|
||||
data-match-state-tournament-id-value="<%= @match.tournament.id %>"
|
||||
data-match-state-bout-number-value="<%= @match.bout_number %>"
|
||||
data-match-state-weight-label-value="<%= @match.weight&.max %>"
|
||||
data-match-state-bracket-position-value="<%= @match.bracket_position %>"
|
||||
data-match-state-ruleset-value="<%= @match_state_ruleset %>"
|
||||
data-match-state-w1-id-value="<%= @match.w1 || 0 %>"
|
||||
data-match-state-w2-id-value="<%= @match.w2 || 0 %>"
|
||||
data-match-state-w1-name-value="<%= @wrestler1_name %>"
|
||||
data-match-state-w2-name-value="<%= @wrestler2_name %>"
|
||||
data-match-state-w1-school-value="<%= @wrestler1_school_name %>"
|
||||
data-match-state-w2-school-value="<%= @wrestler2_school_name %>">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-success" data-match-state-target="greenPanel">
|
||||
<div class="panel-heading">
|
||||
<strong data-match-state-target="greenLabel">Green</strong> <span data-match-state-target="greenName"><%= @wrestler1_name %></span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="text-muted" data-match-state-target="greenSchool"><%= @wrestler1_school_name %></p>
|
||||
<h2 data-match-state-target="greenScore">0</h2>
|
||||
<div data-match-state-target="greenControls"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Bout <%= @match.bout_number %></strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p><strong>Bracket Position:</strong> <%= @match.bracket_position %></p>
|
||||
<p><strong>Period:</strong> <span data-match-state-target="periodLabel"></span></p>
|
||||
<p><strong>Clock:</strong> <span data-match-state-target="clock">2:00</span></p>
|
||||
<p><strong>Status:</strong> <span data-match-state-target="clockStatus">Stopped</span></p>
|
||||
<p><strong>Match Position:</strong> <span data-match-state-target="matchPosition"></span></p>
|
||||
<p><strong>Format:</strong> <span data-match-state-target="formatName"></span></p>
|
||||
<div class="btn-group" style="margin-top: 10px;">
|
||||
<button type="button" class="btn btn-success btn-sm" data-action="click->match-state#startClock">Start</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-action="click->match-state#stopClock">Stop</button>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>Adjust Match Clock</strong>
|
||||
<div class="btn-group" style="margin-top: 8px;">
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#subtractMinute">-1m</button>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#subtractSecond">-1s</button>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#addSecond">+1s</button>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#addMinute">+1m</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-action="click->match-state#swapColors">Swap Red/Green</button>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<strong>Match Period Navigation</strong>
|
||||
<div class="text-muted">Use these to move between periods and choice periods.</div>
|
||||
<div class="btn-group" style="margin-top: 8px;">
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#previousPhase">Previous Period</button>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#nextPhase">Next Period</button>
|
||||
</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<strong>Match Time Accumulation:</strong>
|
||||
<span data-match-state-target="accumulationClock">0:00</span>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<button type="button" class="btn btn-warning btn-sm" data-action="click->match-state#resetMatch">Reset Match</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-match-state-target="choiceActions" style="margin-top: 12px;"></div>
|
||||
<hr>
|
||||
<h4>Event Log</h4>
|
||||
<div data-match-state-target="eventLog"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-danger" data-match-state-target="redPanel">
|
||||
<div class="panel-heading">
|
||||
<strong data-match-state-target="redLabel">Red</strong> <span data-match-state-target="redName"><%= @wrestler2_name %></span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="text-muted" data-match-state-target="redSchool"><%= @wrestler2_school_name %></p>
|
||||
<h2 data-match-state-target="redScore">0</h2>
|
||||
<div data-match-state-target="redControls"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default" data-match-state-target="matchResultsPanel">
|
||||
<div class="panel-heading">
|
||||
<strong>Submit Match Results</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<%= form_for(@match) do |f| %>
|
||||
<%= f.hidden_field :w1_stat, data: { match_state_target: "w1StatField" } %>
|
||||
<%= f.hidden_field :w2_stat, data: { match_state_target: "w2StatField" } %>
|
||||
<%= render "matches/match_results_fields", f: f, redirect_path: @match_results_redirect_path, submit_label: "Update Match" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
7
app/views/mats/scoreboard.html.erb
Normal file
7
app/views/mats/scoreboard.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<%= render "matches/scoreboard",
|
||||
source_mode: "localstorage",
|
||||
display_mode: "fullscreen",
|
||||
show_mat_banner: true,
|
||||
mat: @mat,
|
||||
match: @match,
|
||||
tournament: @tournament %>
|
||||
33
app/views/mats/state.html.erb
Normal file
33
app/views/mats/state.html.erb
Normal file
@@ -0,0 +1,33 @@
|
||||
<% if @match %>
|
||||
<div
|
||||
data-controller="mat-state"
|
||||
data-mat-state-tournament-id-value="<%= @tournament.id %>"
|
||||
data-mat-state-mat-id-value="<%= @mat.id %>"
|
||||
data-mat-state-bout-number-value="<%= @match.bout_number %>"
|
||||
data-mat-state-match-id-value="<%= @match.id %>"
|
||||
data-mat-state-select-match-url-value="<%= select_match_mat_path(@mat) %>"
|
||||
data-mat-state-weight-label-value="<%= @match.weight&.max %>"
|
||||
data-mat-state-w1-id-value="<%= @match.w1 || 0 %>"
|
||||
data-mat-state-w2-id-value="<%= @match.w2 || 0 %>"
|
||||
data-mat-state-w1-name-value="<%= @wrestler1_name %>"
|
||||
data-mat-state-w2-name-value="<%= @wrestler2_name %>"
|
||||
data-mat-state-w1-school-value="<%= @wrestler1_school_name %>"
|
||||
data-mat-state-w2-school-value="<%= @wrestler2_school_name %>">
|
||||
<h3>Mat <%= @mat.name %></h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<% @queue_matches.each_with_index do |queue_match, index| %>
|
||||
<% queue_label = "Queue #{index + 1}" %>
|
||||
<% if queue_match %>
|
||||
<% button_class = queue_match.id == @match.id ? "btn btn-success btn-sm" : "btn btn-primary btn-sm" %>
|
||||
<%= link_to "#{queue_label}: Bout #{queue_match.bout_number}", state_mat_path(@mat, bout_number: queue_match.bout_number), class: button_class %>
|
||||
<% else %>
|
||||
<button type="button" class="btn btn-default btn-sm" disabled><%= "#{queue_label}: Not assigned" %></button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render template: "matches/state" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<h3>Mat <%= @mat.name %></h3>
|
||||
<p>No matches assigned to this mat.</p>
|
||||
<% end %>
|
||||
103
app/views/tournaments/_live_score_card.html.erb
Normal file
103
app/views/tournaments/_live_score_card.html.erb
Normal file
@@ -0,0 +1,103 @@
|
||||
<%
|
||||
match = local_assigns[:match]
|
||||
mat = local_assigns[:mat]
|
||||
tournament = local_assigns[:tournament]
|
||||
%>
|
||||
|
||||
<div
|
||||
data-controller="match-scoreboard"
|
||||
data-match-scoreboard-source-mode-value="mat_websocket"
|
||||
data-match-scoreboard-display-mode-value="embedded"
|
||||
data-match-scoreboard-match-id-value="<%= match&.id || 0 %>"
|
||||
data-match-scoreboard-mat-id-value="<%= mat.id %>"
|
||||
data-match-scoreboard-tournament-id-value="<%= tournament.id %>"
|
||||
data-match-scoreboard-initial-bout-number-value="<%= match&.bout_number || 0 %>">
|
||||
|
||||
<div class="panel panel-default" style="margin-bottom: 0;">
|
||||
<div class="panel-heading">
|
||||
<strong>Mat <%= mat.name %></strong>
|
||||
</div>
|
||||
|
||||
<table class="table table-bordered table-condensed" style="margin-bottom: 0; table-layout: fixed;">
|
||||
<tr class="active">
|
||||
<td>
|
||||
<strong data-match-scoreboard-target="boutLabel">Bout <%= match&.bout_number || "" %></strong>
|
||||
<span style="margin-left: 12px;" data-match-scoreboard-target="weightLabel">Weight <%= match&.weight&.max || "-" %></span>
|
||||
</td>
|
||||
<td class="text-right" style="white-space: nowrap;">
|
||||
<span class="label label-default" data-match-scoreboard-target="clock">-</span>
|
||||
<span class="label label-primary" data-match-scoreboard-target="periodLabel">No Match</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
<div data-match-scoreboard-target="greenName" style="font-weight: 700; font-size: 1.15em;">NO MATCH</div>
|
||||
<div class="text-muted" data-match-scoreboard-target="greenSchool"></div>
|
||||
<div data-match-scoreboard-target="greenTimerIndicator" style="margin-top: 6px;"></div>
|
||||
</td>
|
||||
<td data-match-scoreboard-target="greenSection" class="text-center" style="background: #1cab2d; color: #fff; font-size: 2rem; font-weight: 700; vertical-align: middle; width: 110px;">
|
||||
<span data-match-scoreboard-target="greenScore">0</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
<div data-match-scoreboard-target="redName" style="font-weight: 700; font-size: 1.15em;">NO MATCH</div>
|
||||
<div class="text-muted" data-match-scoreboard-target="redSchool"></div>
|
||||
<div data-match-scoreboard-target="redTimerIndicator" style="margin-top: 6px;"></div>
|
||||
</td>
|
||||
<td data-match-scoreboard-target="redSection" class="text-center" style="background: #c91f1f; color: #fff; font-size: 2rem; font-weight: 700; vertical-align: middle; width: 110px;">
|
||||
<span data-match-scoreboard-target="redScore">0</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 0;">
|
||||
<div class="panel panel-default" style="margin: 10px;">
|
||||
<div class="panel-heading">
|
||||
<a data-toggle="collapse" href="#live-score-stats-<%= mat.id %>" aria-expanded="false" aria-controls="live-score-stats-<%= mat.id %>" style="display: flex; justify-content: space-between; align-items: center; color: #333; text-decoration: none; background: transparent; outline: none;">
|
||||
<strong>Stats</strong>
|
||||
<span class="text-muted" style="font-size: 0.9em;">Show/Hide</span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="live-score-stats-<%= mat.id %>" class="panel-collapse collapse">
|
||||
<div class="panel-body" style="padding-bottom: 0;">
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<div class="label label-success" style="display: inline-block; margin-bottom: 8px;">Green</div>
|
||||
<pre data-match-scoreboard-target="greenStats" class="well well-sm" style="min-height: 100px; white-space: pre-wrap; overflow-wrap: anywhere; background: #fff;"></pre>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="label label-danger" style="display: inline-block; margin-bottom: 8px;">Red</div>
|
||||
<pre data-match-scoreboard-target="redStats" class="well well-sm" style="min-height: 100px; white-space: pre-wrap; overflow-wrap: anywhere; background: #fff;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 0;">
|
||||
<div class="panel panel-default" style="margin: 10px;">
|
||||
<div class="panel-heading">
|
||||
<a data-toggle="collapse" href="#live-score-last-result-<%= mat.id %>" aria-expanded="false" aria-controls="live-score-last-result-<%= mat.id %>" style="display: flex; justify-content: space-between; align-items: center; color: #333; text-decoration: none; background: transparent; outline: none;">
|
||||
<strong>Last Match Result</strong>
|
||||
<span class="text-muted" style="font-size: 0.9em;">Show/Hide</span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="live-score-last-result-<%= mat.id %>" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<div data-match-scoreboard-target="lastMatchResult">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-match-scoreboard-target="emptyState" style="display: none;"></div>
|
||||
<div data-match-scoreboard-target="centerSection" style="display: none;"></div>
|
||||
<div data-match-scoreboard-target="timerBanner" style="display: none;"></div>
|
||||
<div data-match-scoreboard-target="timerBannerLabel" style="display: none;"></div>
|
||||
<div data-match-scoreboard-target="timerBannerClock" style="display: none;"></div>
|
||||
</div>
|
||||
12
app/views/tournaments/live_scores.html.erb
Normal file
12
app/views/tournaments/live_scores.html.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
<h1><%= @tournament.name %> Live Scores</h1>
|
||||
|
||||
<% if @mats.any? %>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(520px, 1fr)); gap: 20px; align-items: start;">
|
||||
<% @mats.each do |mat| %>
|
||||
<% match = mat.selected_scoreboard_match || mat.queue1_match %>
|
||||
<%= render "tournaments/live_score_card", mat: mat, match: match, tournament: @tournament %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p>No mats have been created for this tournament.</p>
|
||||
<% end %>
|
||||
@@ -129,13 +129,24 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Current Match</th>
|
||||
<th><%= link_to " New Mat" , "/mats/new?tournament=#{@tournament.id}", :class=>"fas fa-plus" %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @mats.each do |mat| %>
|
||||
<% current_match = mat.queue1_match %>
|
||||
<tr>
|
||||
<td><%= link_to "Mat #{mat.name}", mat %></td>
|
||||
<td>
|
||||
<% if current_match %>
|
||||
<%= link_to "Stat Match", stat_match_path(current_match), class: "btn btn-primary btn-sm" %>
|
||||
<%= link_to "State Match", state_mat_path(mat), class: "btn btn-success btn-sm" %>
|
||||
<%= link_to "Scoreboard", scoreboard_mat_path(mat, print: true), class: "btn btn-warning btn-sm", target: "_blank", rel: "noopener" %>
|
||||
<% else %>
|
||||
<%= link_to "Scoreboard", scoreboard_mat_path(mat, print: true), class: "btn btn-warning btn-sm", target: "_blank", rel: "noopener" %>
|
||||
<% end %>
|
||||
</td>
|
||||
<% if can? :manage, @tournament %>
|
||||
<td>
|
||||
<%= link_to mat, data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete Mat #{mat.name}?" }, class: "text-decoration-none" do %>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
project_dir="$(dirname $( dirname $(readlink -f ${BASH_SOURCE[0]})))"
|
||||
|
||||
cd ${project_dir}
|
||||
npm install
|
||||
npm run test:js
|
||||
bundle exec rake db:migrate RAILS_ENV=test
|
||||
CI=true brakeman
|
||||
bundle audit
|
||||
|
||||
@@ -3,4 +3,4 @@ project_dir="$(dirname $(readlink -f ${BASH_SOURCE[0]}))/.."
|
||||
|
||||
docker build -f ${project_dir}/deploy/rails-prod-Dockerfile -t wrestlingdevtests ${project_dir}/.
|
||||
docker run --rm -it wrestlingdevtests bash /rails/bin/run-all-tests.sh
|
||||
bash ${project_dir}/cypress-tests/run-cypress-tests.sh
|
||||
# bash ${project_dir}/cypress-tests/run-cypress-tests.sh
|
||||
|
||||
@@ -19,6 +19,13 @@ pin "bootstrap", to: "bootstrap.min.js"
|
||||
|
||||
# Pin controllers from app/assets/javascripts/controllers
|
||||
pin_all_from "app/assets/javascripts/controllers", under: "controllers"
|
||||
pin "match-state-config", to: "lib/match_state/config.js"
|
||||
pin "match-state-engine", to: "lib/match_state/engine.js"
|
||||
pin "match-state-serializers", to: "lib/match_state/serializers.js"
|
||||
pin "match-state-presenters", to: "lib/match_state/presenters.js"
|
||||
pin "match-state-transport", to: "lib/match_state/transport.js"
|
||||
pin "match-state-scoreboard-presenters", to: "lib/match_state/scoreboard_presenters.js"
|
||||
pin "match-state-scoreboard-state", to: "lib/match_state/scoreboard_state.js"
|
||||
|
||||
# Pin all JS files from app/assets/javascripts directory
|
||||
pin_all_from "app/assets/javascripts", under: "assets/javascripts"
|
||||
|
||||
@@ -3,12 +3,19 @@ Wrestling::Application.routes.draw do
|
||||
mount ActionCable.server => '/cable'
|
||||
mount MissionControl::Jobs::Engine, at: "/jobs"
|
||||
|
||||
resources :mats
|
||||
resources :mats do
|
||||
member do
|
||||
get :state
|
||||
get :scoreboard
|
||||
post :select_match
|
||||
end
|
||||
end
|
||||
post "mats/:id/assign_next_match" => "mats#assign_next_match", :as => :assign_next_match
|
||||
|
||||
resources :matches do
|
||||
member do
|
||||
get :stat
|
||||
get :state
|
||||
get :spectate
|
||||
get :edit_assignment
|
||||
patch :update_assignment
|
||||
@@ -74,6 +81,7 @@ Wrestling::Application.routes.draw do
|
||||
get 'tournaments/:id/no_matches' => 'tournaments#no_matches'
|
||||
get 'tournaments/:id/matches' => 'tournaments#matches'
|
||||
get 'tournaments/:id/qrcode' => 'tournaments#qrcode'
|
||||
get 'tournaments/:id/live_scores' => 'tournaments#live_scores'
|
||||
get 'tournaments/:id/delegate' => 'tournaments#delegate', :as => :tournament_delegate
|
||||
post 'tournaments/:id/delegate' => 'tournaments#delegate', :as => :set_tournament_delegate
|
||||
delete 'tournaments/:id/:delegate/remove_delegate' => 'tournaments#remove_delegate', :as => :delete_delegate_path
|
||||
|
||||
2162
package-lock.json
generated
Normal file
2162
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "wrestlingapp-js-tests",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:js": "vitest run",
|
||||
"test:js:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
110
test/channels/mat_scoreboard_channel_test.rb
Normal file
110
test/channels/mat_scoreboard_channel_test.rb
Normal file
@@ -0,0 +1,110 @@
|
||||
require "test_helper"
|
||||
|
||||
class MatScoreboardChannelTest < ActionCable::Channel::TestCase
|
||||
setup do
|
||||
@mat = mats(:one)
|
||||
@match = matches(:tournament_1_bout_1000)
|
||||
@alternate_match = matches(:tournament_1_bout_1001)
|
||||
Rails.cache.clear
|
||||
@mat.update!(queue1: @match.id, queue2: @alternate_match.id, queue3: nil, queue4: nil)
|
||||
@mat.set_selected_scoreboard_match!(@match)
|
||||
@mat.set_last_match_result!("106 lbs - Example Winner Decision Example Loser 3-1")
|
||||
end
|
||||
|
||||
test "subscribes to a valid mat stream and transmits scoreboard payload" do
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert subscription.confirmed?
|
||||
assert_has_stream_for @mat
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => @match.bout_number,
|
||||
"queue1_match_id" => @match.id,
|
||||
"selected_bout_number" => @match.bout_number,
|
||||
"selected_match_id" => @match.id,
|
||||
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
|
||||
test "rejects subscription for an invalid mat" do
|
||||
subscribe(mat_id: -1)
|
||||
|
||||
assert subscription.rejected?
|
||||
end
|
||||
|
||||
test "transmits payload with queue1 and no selected match" do
|
||||
@mat.set_selected_scoreboard_match!(nil)
|
||||
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => @match.bout_number,
|
||||
"queue1_match_id" => @match.id,
|
||||
"selected_bout_number" => nil,
|
||||
"selected_match_id" => nil,
|
||||
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
|
||||
test "transmits payload when selected match differs from queue1" do
|
||||
@mat.set_selected_scoreboard_match!(@alternate_match)
|
||||
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => @match.bout_number,
|
||||
"queue1_match_id" => @match.id,
|
||||
"selected_bout_number" => @alternate_match.bout_number,
|
||||
"selected_match_id" => @alternate_match.id,
|
||||
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
|
||||
test "transmits payload when no queue1 match exists" do
|
||||
@mat.update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
|
||||
@mat.set_selected_scoreboard_match!(nil)
|
||||
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => nil,
|
||||
"queue1_match_id" => nil,
|
||||
"selected_bout_number" => nil,
|
||||
"selected_match_id" => nil,
|
||||
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
|
||||
test "transmits payload with blank last match result" do
|
||||
@mat.set_last_match_result!(nil)
|
||||
|
||||
subscribe(mat_id: @mat.id)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"mat_id" => @mat.id,
|
||||
"queue1_bout_number" => @match.bout_number,
|
||||
"queue1_match_id" => @match.id,
|
||||
"selected_bout_number" => @match.bout_number,
|
||||
"selected_match_id" => @match.id,
|
||||
"last_match_result" => nil
|
||||
},
|
||||
transmissions.last
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,152 @@
|
||||
require "test_helper"
|
||||
|
||||
class MatchChannelTest < ActionCable::Channel::TestCase
|
||||
# test "subscribes" do
|
||||
# subscribe
|
||||
# assert subscription.confirmed?
|
||||
# end
|
||||
setup do
|
||||
@match = matches(:tournament_1_bout_1000)
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
test "subscribes to a valid match stream" do
|
||||
subscribe(match_id: @match.id)
|
||||
|
||||
assert subscription.confirmed?
|
||||
assert_has_stream_for @match
|
||||
end
|
||||
|
||||
test "invalid match subscription confirms but does not stream" do
|
||||
subscribe(match_id: -1)
|
||||
|
||||
assert subscription.confirmed?
|
||||
assert_empty subscription.streams
|
||||
end
|
||||
|
||||
test "send_stat updates the match and broadcasts stats" do
|
||||
subscribe(match_id: @match.id)
|
||||
|
||||
assert_broadcast_on(@match, { w1_stat: "T3", w2_stat: "E1" }) do
|
||||
perform :send_stat, {
|
||||
new_w1_stat: "T3",
|
||||
new_w2_stat: "E1"
|
||||
}
|
||||
end
|
||||
|
||||
@match.reload
|
||||
assert_equal "T3", @match.w1_stat
|
||||
assert_equal "E1", @match.w2_stat
|
||||
end
|
||||
|
||||
test "send_stat updates only w1 stat when only w1 is provided" do
|
||||
subscribe(match_id: @match.id)
|
||||
|
||||
assert_broadcast_on(@match, { w1_stat: "T3", w2_stat: nil }.compact) do
|
||||
perform :send_stat, { new_w1_stat: "T3" }
|
||||
end
|
||||
|
||||
@match.reload
|
||||
assert_equal "T3", @match.w1_stat
|
||||
assert_nil @match.w2_stat
|
||||
end
|
||||
|
||||
test "send_stat updates only w2 stat when only w2 is provided" do
|
||||
subscribe(match_id: @match.id)
|
||||
|
||||
assert_broadcast_on(@match, { w1_stat: nil, w2_stat: "E1" }.compact) do
|
||||
perform :send_stat, { new_w2_stat: "E1" }
|
||||
end
|
||||
|
||||
@match.reload
|
||||
assert_nil @match.w1_stat
|
||||
assert_equal "E1", @match.w2_stat
|
||||
end
|
||||
|
||||
test "send_stat with empty payload does not update or broadcast" do
|
||||
subscribe(match_id: @match.id)
|
||||
stream = MatchChannel.broadcasting_for(@match)
|
||||
ActionCable.server.pubsub.broadcasts(stream).clear
|
||||
|
||||
perform :send_stat, {}
|
||||
|
||||
@match.reload
|
||||
assert_nil @match.w1_stat
|
||||
assert_nil @match.w2_stat
|
||||
assert_empty ActionCable.server.pubsub.broadcasts(stream)
|
||||
end
|
||||
|
||||
test "send_scoreboard caches and broadcasts scoreboard state" do
|
||||
subscribe(match_id: @match.id)
|
||||
scoreboard_state = {
|
||||
"participantScores" => { "w1" => 2, "w2" => 0 },
|
||||
"metadata" => { "boutNumber" => @match.bout_number }
|
||||
}
|
||||
|
||||
assert_broadcast_on(@match, { scoreboard_state: scoreboard_state }) do
|
||||
perform :send_scoreboard, { scoreboard_state: scoreboard_state }
|
||||
end
|
||||
|
||||
cached_state = Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
|
||||
assert_equal scoreboard_state, cached_state
|
||||
end
|
||||
|
||||
test "send_scoreboard with blank payload does not cache or broadcast" do
|
||||
subscribe(match_id: @match.id)
|
||||
stream = MatchChannel.broadcasting_for(@match)
|
||||
ActionCable.server.pubsub.broadcasts(stream).clear
|
||||
|
||||
perform :send_scoreboard, { scoreboard_state: nil }
|
||||
|
||||
assert_nil Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
|
||||
assert_empty ActionCable.server.pubsub.broadcasts(stream)
|
||||
end
|
||||
|
||||
test "request_sync transmits match data and cached scoreboard state" do
|
||||
@match.update!(
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
)
|
||||
scoreboard_state = {
|
||||
"participantScores" => { "w1" => 3, "w2" => 1 },
|
||||
"metadata" => { "boutNumber" => @match.bout_number }
|
||||
}
|
||||
Rails.cache.write(
|
||||
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state",
|
||||
scoreboard_state
|
||||
)
|
||||
|
||||
subscribe(match_id: @match.id)
|
||||
perform :request_sync
|
||||
|
||||
assert_equal({
|
||||
"w1_stat" => "T3",
|
||||
"w2_stat" => "E1",
|
||||
"score" => "3-1",
|
||||
"win_type" => "Decision",
|
||||
"winner_name" => @match.wrestler1.name,
|
||||
"winner_id" => @match.w1,
|
||||
"finished" => 1,
|
||||
"scoreboard_state" => scoreboard_state
|
||||
}, transmissions.last)
|
||||
end
|
||||
|
||||
test "request_sync transmits unfinished match data without scoreboard cache" do
|
||||
@match.update!(
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: nil,
|
||||
win_type: nil,
|
||||
winner_id: nil,
|
||||
finished: nil
|
||||
)
|
||||
|
||||
subscribe(match_id: @match.id)
|
||||
perform :request_sync
|
||||
|
||||
assert_equal({
|
||||
"w1_stat" => "T3",
|
||||
"w2_stat" => "E1"
|
||||
}, transmissions.last)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
require 'test_helper'
|
||||
require "json"
|
||||
|
||||
class MatchesControllerTest < ActionController::TestCase
|
||||
# Remove Devise helpers since we're no longer using Devise
|
||||
# include Devise::Test::ControllerHelpers # Needed to sign in
|
||||
include ActionView::Helpers::DateHelper # Needed for time ago in words
|
||||
include ActionCable::TestHelper
|
||||
|
||||
setup do
|
||||
@tournament = Tournament.find(1)
|
||||
@@ -34,6 +36,18 @@ class MatchesControllerTest < ActionController::TestCase
|
||||
get :stat, params: { id: @match.id }
|
||||
end
|
||||
|
||||
def get_state
|
||||
get :state, params: { id: @match.id }
|
||||
end
|
||||
|
||||
def get_state_with_params(extra_params = {})
|
||||
get :state, params: { id: @match.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def get_spectate(extra_params = {})
|
||||
get :spectate, params: { id: @match.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def get_edit_assignment(extra_params = {})
|
||||
get :edit_assignment, params: { id: @match.id }.merge(extra_params)
|
||||
end
|
||||
@@ -106,11 +120,44 @@ class MatchesControllerTest < ActionController::TestCase
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user should not get state match page if not owner" do
|
||||
sign_in_non_owner
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not get state match page if not owner" do
|
||||
sign_in_school_delegate
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not get stat match page" do
|
||||
get_stat
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not get state match page" do
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "valid school permission key cannot get state match page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_state_with_params(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot get state match page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_state_with_params(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "non logged in user should get post update match" do
|
||||
post_update
|
||||
assert_redirected_to '/static_pages/not_allowed'
|
||||
@@ -139,6 +186,202 @@ class MatchesControllerTest < ActionController::TestCase
|
||||
get_stat
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner should get state match page" do
|
||||
sign_in_owner
|
||||
get_state
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should get state match page" do
|
||||
sign_in_tournament_delegate
|
||||
get_state
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in school delegate cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
sign_in_school_delegate
|
||||
get_spectate
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
sign_in_non_owner
|
||||
get_spectate
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
sign_in_tournament_delegate
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
sign_in_owner
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "non logged in user cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
get_spectate
|
||||
redirect
|
||||
end
|
||||
|
||||
test "valid school permission key cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_spectate(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot get spectate match page when tournament is not public" do
|
||||
@tournament.update!(is_public: false)
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_spectate(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "logged in school delegate can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
sign_in_school_delegate
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in user can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
sign_in_non_owner
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
sign_in_tournament_delegate
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
sign_in_owner
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "non logged in user can get spectate match page when tournament is public" do
|
||||
@tournament.update!(is_public: true)
|
||||
get_spectate
|
||||
success
|
||||
end
|
||||
|
||||
test "spectate renders embedded scoreboard when match is unfinished" do
|
||||
@tournament.update!(is_public: true)
|
||||
@match.update!(finished: nil)
|
||||
|
||||
get_spectate
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "data-match-spectate-target=\"scoreboardContainer\""
|
||||
assert_includes response.body, "data-controller=\"match-scoreboard\""
|
||||
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"websocket\""
|
||||
assert_includes response.body, "data-match-scoreboard-display-mode-value=\"embedded\""
|
||||
assert_includes response.body, "data-match-scoreboard-target=\"greenTimerIndicator\""
|
||||
assert_includes response.body, "data-match-scoreboard-target=\"redTimerIndicator\""
|
||||
end
|
||||
|
||||
test "spectate hides embedded scoreboard when match is finished" do
|
||||
@tournament.update!(is_public: true)
|
||||
@match.update!(finished: 1, winner_id: @match.w1, win_type: "Decision", score: "3-1")
|
||||
|
||||
get_spectate
|
||||
|
||||
assert_response :success
|
||||
assert_not_includes response.body, "data-match-spectate-target=\"scoreboardContainer\""
|
||||
end
|
||||
|
||||
test "posting a match update from match state redirects to all matches" do
|
||||
sign_in_owner
|
||||
get :state, params: { id: @match.id }
|
||||
patch :update, params: { id: @match.id, match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 } }
|
||||
|
||||
assert_redirected_to "/tournaments/#{@tournament.id}/matches"
|
||||
end
|
||||
|
||||
test "state page renders hidden stat fields for generated stats submission" do
|
||||
sign_in_owner
|
||||
get_state
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, 'name="match[w1_stat]"'
|
||||
assert_includes response.body, 'name="match[w2_stat]"'
|
||||
assert_includes response.body, 'data-match-state-target="w1StatField"'
|
||||
assert_includes response.body, 'data-match-state-target="w2StatField"'
|
||||
end
|
||||
|
||||
test "posting a match update from match state respects redirect_to param" do
|
||||
sign_in_owner
|
||||
get :state, params: { id: @match.id, redirect_to: "/mats/#{@match.mat_id}" }
|
||||
patch :update, params: {
|
||||
id: @match.id,
|
||||
redirect_to: "/mats/#{@match.mat_id}",
|
||||
match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 }
|
||||
}
|
||||
|
||||
assert_redirected_to "/mats/#{@match.mat_id}"
|
||||
end
|
||||
|
||||
test "posting a match update broadcasts match data and cached scoreboard state" do
|
||||
sign_in_owner
|
||||
scoreboard_state = {
|
||||
"participantScores" => { "w1" => 3, "w2" => 1 },
|
||||
"metadata" => { "boutNumber" => @match.bout_number }
|
||||
}
|
||||
stream = MatchChannel.broadcasting_for(@match)
|
||||
ActionCable.server.pubsub.clear
|
||||
ActionCable.server.pubsub.broadcasts(stream).clear
|
||||
Rails.cache.write(
|
||||
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state",
|
||||
scoreboard_state
|
||||
)
|
||||
|
||||
patch :update, params: {
|
||||
id: @match.id,
|
||||
match: {
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
}
|
||||
}
|
||||
|
||||
payload = JSON.parse(ActionCable.server.pubsub.broadcasts(stream).last)
|
||||
assert_equal(
|
||||
{
|
||||
"w1_stat" => "T3",
|
||||
"w2_stat" => "E1",
|
||||
"score" => "3-1",
|
||||
"win_type" => "Decision",
|
||||
"winner_id" => @match.w1,
|
||||
"winner_name" => @match.wrestler1.name,
|
||||
"finished" => 1,
|
||||
"scoreboard_state" => scoreboard_state
|
||||
},
|
||||
payload
|
||||
)
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should post update match" do
|
||||
sign_in_tournament_delegate
|
||||
|
||||
@@ -31,6 +31,30 @@ class MatsControllerTest < ActionController::TestCase
|
||||
get :show, params: { id: 1 }
|
||||
end
|
||||
|
||||
def get_state
|
||||
get :state, params: { id: @mat.id }
|
||||
end
|
||||
|
||||
def get_state_with_params(extra_params = {})
|
||||
get :state, params: { id: @mat.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def get_scoreboard
|
||||
get :scoreboard, params: { id: @mat.id }
|
||||
end
|
||||
|
||||
def get_scoreboard_with_params(extra_params = {})
|
||||
get :scoreboard, params: { id: @mat.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def post_select_match(extra_params = {})
|
||||
post :select_match, params: { id: @mat.id, match_id: @match.id, bout_number: @match.bout_number }.merge(extra_params)
|
||||
end
|
||||
|
||||
def post_select_match_with_params(extra_params = {})
|
||||
post :select_match, params: { id: @mat.id }.merge(extra_params)
|
||||
end
|
||||
|
||||
def post_update
|
||||
patch :update, params: { id: @mat.id, mat: {name: @mat.name, tournament_id: @mat.tournament_id} }
|
||||
end
|
||||
@@ -211,6 +235,18 @@ class MatsControllerTest < ActionController::TestCase
|
||||
show
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user should not get state mat page" do
|
||||
sign_in_non_owner
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user should not get scoreboard mat page" do
|
||||
sign_in_non_owner
|
||||
get_scoreboard
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not get show mat" do
|
||||
sign_in_school_delegate
|
||||
@@ -218,11 +254,116 @@ class MatsControllerTest < ActionController::TestCase
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not get state mat page" do
|
||||
sign_in_school_delegate
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not get scoreboard mat page" do
|
||||
sign_in_school_delegate
|
||||
get_scoreboard
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not get state mat page" do
|
||||
get_state
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not get scoreboard mat page" do
|
||||
get_scoreboard
|
||||
redirect
|
||||
end
|
||||
|
||||
test "valid school permission key cannot get state mat page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_state_with_params(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot get state mat page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_state_with_params(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "valid school permission key cannot get scoreboard mat page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_scoreboard_with_params(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot get scoreboard mat page" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
get_scoreboard_with_params(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "logged in user should not post select_match on mat" do
|
||||
sign_in_non_owner
|
||||
post_select_match
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged school delegate should not post select_match on mat" do
|
||||
sign_in_school_delegate
|
||||
post_select_match
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user should not post select_match on mat" do
|
||||
post_select_match
|
||||
redirect
|
||||
end
|
||||
|
||||
test "valid school permission key cannot post select_match on mat" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
post_select_match_with_params(school_permission_key: "valid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "invalid school permission key cannot post select_match on mat" do
|
||||
school = @tournament.schools.first
|
||||
school.update!(permission_key: "valid-school-key")
|
||||
|
||||
post_select_match_with_params(school_permission_key: "invalid-school-key")
|
||||
assert_redirected_to "/static_pages/not_allowed"
|
||||
end
|
||||
|
||||
test "logged in tournament owner should get show mat" do
|
||||
sign_in_owner
|
||||
show
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner should get state mat page" do
|
||||
sign_in_owner
|
||||
get_state
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner should get scoreboard mat page" do
|
||||
sign_in_owner
|
||||
get_scoreboard
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can post select_match on mat" do
|
||||
sign_in_owner
|
||||
post_select_match
|
||||
assert_response :no_content
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should get show mat" do
|
||||
sign_in_tournament_delegate
|
||||
@@ -230,6 +371,118 @@ class MatsControllerTest < ActionController::TestCase
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should get state mat page" do
|
||||
sign_in_tournament_delegate
|
||||
get_state
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate should get scoreboard mat page" do
|
||||
sign_in_tournament_delegate
|
||||
get_scoreboard
|
||||
success
|
||||
end
|
||||
|
||||
test "state mat page renders queue buttons and mat-state controller" do
|
||||
sign_in_owner
|
||||
get_state
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "data-controller=\"mat-state\""
|
||||
assert_includes response.body, "Queue 1:"
|
||||
assert_includes response.body, "Queue 2:"
|
||||
assert_includes response.body, "Queue 3:"
|
||||
assert_includes response.body, "Queue 4:"
|
||||
end
|
||||
|
||||
test "scoreboard mat page renders match-scoreboard controller" do
|
||||
sign_in_owner
|
||||
get_scoreboard_with_params(print: true)
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "data-controller=\"match-scoreboard\""
|
||||
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"localstorage\""
|
||||
end
|
||||
|
||||
test "scoreboard mat page uses selected scoreboard match as initial bout" do
|
||||
sign_in_owner
|
||||
alternate_match = @mat.queue2_match
|
||||
if alternate_match.nil?
|
||||
alternate_match = @tournament.matches.where(mat_id: nil).first
|
||||
@mat.assign_match_to_queue!(alternate_match, 2)
|
||||
alternate_match = @mat.reload.queue2_match
|
||||
end
|
||||
@mat.set_selected_scoreboard_match!(alternate_match)
|
||||
|
||||
get_scoreboard
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "data-match-scoreboard-initial-bout-number-value=\"#{alternate_match.bout_number}\""
|
||||
end
|
||||
|
||||
test "state mat page renders no matches assigned when queue is empty" do
|
||||
sign_in_owner
|
||||
@mat.clear_queue!
|
||||
|
||||
get_state
|
||||
|
||||
assert_response :success
|
||||
assert_includes response.body, "No matches assigned to this mat."
|
||||
end
|
||||
|
||||
test "posting a match update from mat state redirects back to mat state" do
|
||||
sign_in_owner
|
||||
get :state, params: { id: @mat.id, bout_number: @match.bout_number }
|
||||
|
||||
old_controller = @controller
|
||||
@controller = MatchesController.new
|
||||
patch :update, params: { id: @match.id, match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 } }
|
||||
@controller = old_controller
|
||||
|
||||
assert_redirected_to "/mats/#{@mat.id}/state"
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can post select_match on mat" do
|
||||
sign_in_tournament_delegate
|
||||
post_select_match
|
||||
assert_response :no_content
|
||||
end
|
||||
|
||||
test "select_match updates selected scoreboard match" do
|
||||
sign_in_owner
|
||||
alternate_match = @mat.queue2_match
|
||||
if alternate_match.nil?
|
||||
alternate_match = @tournament.matches.where(mat_id: nil).first
|
||||
@mat.assign_match_to_queue!(alternate_match, 2)
|
||||
alternate_match = @mat.reload.queue2_match
|
||||
end
|
||||
|
||||
post :select_match, params: { id: @mat.id, match_id: alternate_match.id, bout_number: alternate_match.bout_number }
|
||||
|
||||
assert_response :no_content
|
||||
assert_equal alternate_match.id, @mat.selected_scoreboard_match&.id
|
||||
end
|
||||
|
||||
test "select_match updates last match result without changing selected match" do
|
||||
sign_in_owner
|
||||
@mat.set_selected_scoreboard_match!(@match)
|
||||
|
||||
post :select_match, params: { id: @mat.id, last_match_result: "106 lbs - Winner Decision Loser 3-1" }
|
||||
|
||||
assert_response :no_content
|
||||
assert_equal @match.id, @mat.selected_scoreboard_match&.id
|
||||
assert_equal "106 lbs - Winner Decision Loser 3-1", @mat.last_match_result_text
|
||||
end
|
||||
|
||||
test "select_match returns unprocessable entity for a non queued match without last result" do
|
||||
sign_in_owner
|
||||
non_queued_match = @tournament.matches.where(mat_id: nil).first
|
||||
|
||||
post :select_match, params: { id: @mat.id, match_id: non_queued_match.id, bout_number: non_queued_match.bout_number }
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "ads are hidden on mat show" do
|
||||
sign_in_owner
|
||||
show
|
||||
|
||||
@@ -28,6 +28,10 @@ class TournamentsControllerTest < ActionController::TestCase
|
||||
get :up_matches, params: { id: 1 }
|
||||
end
|
||||
|
||||
def get_live_scores
|
||||
get :live_scores, params: { id: 1 }
|
||||
end
|
||||
|
||||
def get_qrcode(params = {})
|
||||
get :qrcode, params: { id: 1 }.merge(params)
|
||||
end
|
||||
@@ -591,6 +595,116 @@ class TournamentsControllerTest < ActionController::TestCase
|
||||
end
|
||||
# END UP MATCHES PAGE PERMISSIONS
|
||||
|
||||
# LIVE SCORES PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC
|
||||
test "logged in school delegate cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
sign_in_school_delegate
|
||||
get_live_scores
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in user cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
sign_in_non_owner
|
||||
get_live_scores
|
||||
redirect
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
sign_in_delegate
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
sign_in_owner
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "non logged in user cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
get_live_scores
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user with valid school permission key cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
@school.update(permission_key: "valid-key")
|
||||
get :live_scores, params: { id: @tournament.id, school_permission_key: "valid-key" }
|
||||
redirect
|
||||
end
|
||||
|
||||
test "non logged in user with invalid school permission key cannot get live scores page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
@tournament.save
|
||||
@school.update(permission_key: "valid-key")
|
||||
get :live_scores, params: { id: @tournament.id, school_permission_key: "invalid-key" }
|
||||
redirect
|
||||
end
|
||||
|
||||
# LIVE SCORES PAGE PERMISSIONS WHEN TOURNAMENT IS PUBLIC
|
||||
test "logged in school delegate can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
sign_in_school_delegate
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in user can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
sign_in_non_owner
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament delegate can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
sign_in_delegate
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "logged in tournament owner can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
sign_in_owner
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "non logged in user can get live scores page when tournament is public" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
get_live_scores
|
||||
success
|
||||
end
|
||||
|
||||
test "live scores page renders mat cards and scoreboard controller" do
|
||||
@tournament.is_public = true
|
||||
@tournament.save
|
||||
get_live_scores
|
||||
success
|
||||
assert_includes response.body, "Live Scores"
|
||||
assert_includes response.body, "data-controller=\"match-scoreboard\""
|
||||
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"mat_websocket\""
|
||||
assert_includes response.body, "data-match-scoreboard-display-mode-value=\"embedded\""
|
||||
assert_includes response.body, "Last Match Result"
|
||||
assert_includes response.body, "Stats"
|
||||
assert_not_includes response.body, "Result</h4>"
|
||||
end
|
||||
|
||||
# ALL_RESULTS PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC
|
||||
test "logged in school delegate cannot get all_results page when tournament is not public" do
|
||||
@tournament.is_public = false
|
||||
|
||||
85
test/integration/state_page_redirect_flow_test.rb
Normal file
85
test/integration/state_page_redirect_flow_test.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
require "test_helper"
|
||||
|
||||
class StatePageRedirectFlowTest < ActionDispatch::IntegrationTest
|
||||
fixtures :all
|
||||
|
||||
setup do
|
||||
@tournament = tournaments(:one)
|
||||
@mat = mats(:one)
|
||||
@match = matches(:tournament_1_bout_1000)
|
||||
@owner = users(:one)
|
||||
ensure_login_password(@owner)
|
||||
end
|
||||
|
||||
test "match state update redirects to all matches by default" do
|
||||
log_in(@owner)
|
||||
|
||||
get state_match_path(@match)
|
||||
assert_response :success
|
||||
|
||||
patch match_path(@match), params: {
|
||||
match: {
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to "/tournaments/#{@tournament.id}/matches"
|
||||
end
|
||||
|
||||
test "match state update respects redirect_to param through request flow" do
|
||||
log_in(@owner)
|
||||
|
||||
get state_match_path(@match), params: { redirect_to: mat_path(@mat) }
|
||||
assert_response :success
|
||||
|
||||
patch match_path(@match), params: {
|
||||
redirect_to: mat_path(@mat),
|
||||
match: {
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to mat_path(@mat)
|
||||
end
|
||||
|
||||
test "mat state update redirects back to mat state through request flow" do
|
||||
log_in(@owner)
|
||||
|
||||
get state_mat_path(@mat), params: { bout_number: @match.bout_number }
|
||||
assert_response :success
|
||||
|
||||
patch match_path(@match), params: {
|
||||
match: {
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_id: @match.w1,
|
||||
finished: 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to state_mat_path(@mat)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_login_password(user)
|
||||
return if user.password_digest.present?
|
||||
|
||||
user.update_column(:password_digest, BCrypt::Password.create("password"))
|
||||
end
|
||||
|
||||
def log_in(user)
|
||||
post login_path, params: {
|
||||
session: {
|
||||
email: user.email,
|
||||
password: "password"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
177
test/javascript/controllers/mat_state_controller.test.js
Normal file
177
test/javascript/controllers/mat_state_controller.test.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatStateController from "../../../app/assets/javascripts/controllers/mat_state_controller.js"
|
||||
|
||||
class FakeFormElement {}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatStateController()
|
||||
controller.element = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
controller.tournamentIdValue = 8
|
||||
controller.matIdValue = 3
|
||||
controller.boutNumberValue = 1001
|
||||
controller.matchIdValue = 22
|
||||
controller.selectMatchUrlValue = "/mats/3/select_match"
|
||||
controller.hasSelectMatchUrlValue = true
|
||||
controller.hasWeightLabelValue = true
|
||||
controller.weightLabelValue = "106"
|
||||
controller.w1IdValue = 11
|
||||
controller.w2IdValue = 12
|
||||
controller.w1NameValue = "Alpha"
|
||||
controller.w2NameValue = "Bravo"
|
||||
controller.w1SchoolValue = "School A"
|
||||
controller.w2SchoolValue = "School B"
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("mat state controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
global.HTMLFormElement = FakeFormElement
|
||||
global.window = {
|
||||
localStorage: {
|
||||
setItem: vi.fn(),
|
||||
getItem: vi.fn(() => ""),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
}
|
||||
global.document = {
|
||||
querySelector: vi.fn(() => ({ content: "csrf-token" }))
|
||||
}
|
||||
global.fetch = vi.fn(() => Promise.resolve())
|
||||
})
|
||||
|
||||
it("connect saves and broadcasts the selected bout and binds submit handling", () => {
|
||||
const controller = buildController()
|
||||
controller.saveSelectedBout = vi.fn()
|
||||
controller.broadcastSelectedBout = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.saveSelectedBout).toHaveBeenCalledTimes(1)
|
||||
expect(controller.broadcastSelectedBout).toHaveBeenCalledTimes(1)
|
||||
expect(controller.element.addEventListener).toHaveBeenCalledWith("submit", controller.boundHandleSubmit)
|
||||
})
|
||||
|
||||
it("saves the selected bout in tournament-scoped localStorage", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.saveSelectedBout()
|
||||
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledTimes(1)
|
||||
const [key, value] = window.localStorage.setItem.mock.calls[0]
|
||||
expect(key).toBe("mat-selected-bout:8:3")
|
||||
expect(JSON.parse(value)).toMatchObject({
|
||||
boutNumber: 1001,
|
||||
matchId: 22
|
||||
})
|
||||
})
|
||||
|
||||
it("broadcasts the selected bout with the last saved result", () => {
|
||||
const controller = buildController()
|
||||
controller.loadLastMatchResult = vi.fn(() => "Last result")
|
||||
|
||||
controller.broadcastSelectedBout()
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetch.mock.calls[0]
|
||||
expect(url).toBe("/mats/3/select_match")
|
||||
expect(options.method).toBe("POST")
|
||||
expect(options.body.get("match_id")).toBe("22")
|
||||
expect(options.body.get("bout_number")).toBe("1001")
|
||||
expect(options.body.get("last_match_result")).toBe("Last result")
|
||||
})
|
||||
|
||||
it("builds the last match result string from the results form", () => {
|
||||
const controller = buildController()
|
||||
const values = {
|
||||
"#match_winner_id": "11",
|
||||
"#match_win_type": "Pin",
|
||||
"#final-score-field": "01:09"
|
||||
}
|
||||
const form = new FakeFormElement()
|
||||
form.querySelector = vi.fn((selector) => ({ value: values[selector] }))
|
||||
|
||||
expect(controller.buildLastMatchResult(form)).toBe(
|
||||
"106 lbs - Alpha (School A) Pin Bravo (School B) 01:09"
|
||||
)
|
||||
})
|
||||
|
||||
it("handleSubmit saves and broadcasts the last match result", () => {
|
||||
const controller = buildController()
|
||||
const form = new FakeFormElement()
|
||||
controller.buildLastMatchResult = vi.fn(() => "Result text")
|
||||
controller.saveLastMatchResult = vi.fn()
|
||||
controller.broadcastCurrentState = vi.fn()
|
||||
|
||||
controller.handleSubmit({ target: form })
|
||||
|
||||
expect(controller.saveLastMatchResult).toHaveBeenCalledWith("Result text")
|
||||
expect(controller.broadcastCurrentState).toHaveBeenCalledWith("Result text")
|
||||
})
|
||||
|
||||
it("broadcastCurrentState posts the selected match and latest result", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.broadcastCurrentState("Result text")
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetch.mock.calls[0]
|
||||
expect(url).toBe("/mats/3/select_match")
|
||||
expect(options.keepalive).toBe(true)
|
||||
expect(options.body.get("match_id")).toBe("22")
|
||||
expect(options.body.get("bout_number")).toBe("1001")
|
||||
expect(options.body.get("last_match_result")).toBe("Result text")
|
||||
})
|
||||
|
||||
it("does not write selected bout storage without a mat id", () => {
|
||||
const controller = buildController()
|
||||
controller.matIdValue = 0
|
||||
|
||||
controller.saveSelectedBout()
|
||||
|
||||
expect(window.localStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not broadcast selected bout without a select-match url", () => {
|
||||
const controller = buildController()
|
||||
controller.hasSelectMatchUrlValue = false
|
||||
controller.selectMatchUrlValue = ""
|
||||
|
||||
controller.broadcastSelectedBout()
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("saves and clears the last match result in localStorage", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.saveLastMatchResult("Result text")
|
||||
expect(window.localStorage.setItem).toHaveBeenCalledWith("mat-last-match-result:8:3", "Result text")
|
||||
|
||||
controller.saveLastMatchResult("")
|
||||
expect(window.localStorage.removeItem).toHaveBeenCalledWith("mat-last-match-result:8:3")
|
||||
})
|
||||
|
||||
it("returns blank last match result when required form values are missing or unknown", () => {
|
||||
const controller = buildController()
|
||||
const form = new FakeFormElement()
|
||||
form.querySelector = vi.fn((selector) => {
|
||||
if (selector === "#match_winner_id") return { value: "" }
|
||||
if (selector === "#match_win_type") return { value: "Pin" }
|
||||
return { value: "01:09" }
|
||||
})
|
||||
|
||||
expect(controller.buildLastMatchResult(form)).toBe("")
|
||||
|
||||
form.querySelector = vi.fn((selector) => {
|
||||
if (selector === "#match_winner_id") return { value: "999" }
|
||||
if (selector === "#match_win_type") return { value: "Pin" }
|
||||
return { value: "01:09" }
|
||||
})
|
||||
|
||||
expect(controller.buildLastMatchResult(form)).toBe("")
|
||||
})
|
||||
})
|
||||
179
test/javascript/controllers/match_data_controller.test.js
Normal file
179
test/javascript/controllers/match_data_controller.test.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatchDataController from "../../../app/assets/javascripts/controllers/match_data_controller.js"
|
||||
|
||||
function makeTarget(value = "") {
|
||||
return {
|
||||
value,
|
||||
innerText: "",
|
||||
addEventListener: vi.fn(),
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchDataController()
|
||||
controller.tournamentIdValue = 8
|
||||
controller.boutNumberValue = 1001
|
||||
controller.matchIdValue = 22
|
||||
controller.w1StatTarget = makeTarget("Initial W1")
|
||||
controller.w2StatTarget = makeTarget("Initial W2")
|
||||
controller.statusIndicatorTarget = makeTarget()
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match data controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(Date.prototype, "toISOString").mockReturnValue("2026-04-10T00:00:00.000Z")
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
global.window = {
|
||||
App: null
|
||||
}
|
||||
global.document = {
|
||||
getElementById: vi.fn(() => null)
|
||||
}
|
||||
global.setTimeout = vi.fn((fn) => {
|
||||
fn()
|
||||
return 1
|
||||
})
|
||||
global.clearTimeout = vi.fn()
|
||||
global.setInterval = vi.fn(() => 123)
|
||||
global.clearInterval = vi.fn()
|
||||
})
|
||||
|
||||
it("connect initializes wrestler state, localStorage, textarea handlers, and subscription", () => {
|
||||
const controller = buildController()
|
||||
controller.initializeFromLocalStorage = vi.fn()
|
||||
controller.setupSubscription = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.w1.stats).toBe("Initial W1")
|
||||
expect(controller.w2.stats).toBe("Initial W2")
|
||||
expect(controller.w1StatTarget.addEventListener).toHaveBeenCalledWith("input", expect.any(Function))
|
||||
expect(controller.w2StatTarget.addEventListener).toHaveBeenCalledWith("input", expect.any(Function))
|
||||
expect(controller.initializeFromLocalStorage).toHaveBeenCalledTimes(1)
|
||||
expect(controller.setupSubscription).toHaveBeenCalledWith(22)
|
||||
})
|
||||
|
||||
it("generates tournament and bout scoped localStorage keys", () => {
|
||||
const controller = buildController()
|
||||
|
||||
expect(controller.generateKey("w1")).toBe("w1-8-1001")
|
||||
expect(controller.generateKey("w2")).toBe("w2-8-1001")
|
||||
})
|
||||
|
||||
it("updateStats updates textareas, localStorage, and sends websocket stat payloads", () => {
|
||||
const controller = buildController()
|
||||
controller.connect()
|
||||
controller.matchSubscription = { perform: vi.fn() }
|
||||
|
||||
controller.updateStats(controller.w1, "T3")
|
||||
|
||||
expect(controller.w1.stats).toBe("Initial W1T3 ")
|
||||
expect(controller.w1StatTarget.value).toBe("Initial W1T3 ")
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
"w1-8-1001",
|
||||
expect.stringContaining('"stats":"Initial W1T3 "')
|
||||
)
|
||||
expect(controller.matchSubscription.perform).toHaveBeenCalledWith("send_stat", {
|
||||
new_w1_stat: "Initial W1T3 "
|
||||
})
|
||||
})
|
||||
|
||||
it("textarea input saves local state and marks pending sync when disconnected", () => {
|
||||
const controller = buildController()
|
||||
controller.connect()
|
||||
controller.isConnected = false
|
||||
controller.matchSubscription = { perform: vi.fn() }
|
||||
|
||||
controller.handleTextAreaInput({ value: "Manual stat" }, controller.w2)
|
||||
|
||||
expect(controller.w2.stats).toBe("Manual stat")
|
||||
expect(controller.pendingLocalSync.w2).toBe(true)
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
"w2-8-1001",
|
||||
expect.stringContaining('"stats":"Manual stat"')
|
||||
)
|
||||
expect(controller.matchSubscription.perform).toHaveBeenCalledWith("send_stat", {
|
||||
new_w2_stat: "Manual stat"
|
||||
})
|
||||
})
|
||||
|
||||
it("loads persisted localStorage stats and timers into the textareas", () => {
|
||||
const controller = buildController()
|
||||
localStorage.getItem = vi.fn((key) => {
|
||||
if (key === "w1-8-1001") {
|
||||
return JSON.stringify({
|
||||
stats: "Saved W1",
|
||||
updated_at: "timestamp",
|
||||
timers: {
|
||||
injury: { time: 5, startTime: null, interval: null },
|
||||
blood: { time: 0, startTime: null, interval: null }
|
||||
}
|
||||
})
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.w1.stats).toBe("Saved W1")
|
||||
expect(controller.w1StatTarget.value).toBe("Saved W1")
|
||||
expect(controller.w1.timers.injury.time).toBe(5)
|
||||
})
|
||||
|
||||
it("subscription callbacks update status, request sync, and apply received stats", () => {
|
||||
const controller = buildController()
|
||||
const subscription = { perform: vi.fn(), unsubscribe: vi.fn() }
|
||||
let callbacks
|
||||
global.App = {
|
||||
cable: {
|
||||
subscriptions: {
|
||||
create: vi.fn((_identifier, receivedCallbacks) => {
|
||||
callbacks = receivedCallbacks
|
||||
return subscription
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
window.App = global.App
|
||||
|
||||
controller.connect()
|
||||
controller.w1.stats = "Local W1"
|
||||
controller.w2.stats = "Local W2"
|
||||
callbacks.connected()
|
||||
|
||||
expect(controller.isConnected).toBe(true)
|
||||
expect(controller.statusIndicatorTarget.innerText).toBe("Connected: Stats will update in real-time.")
|
||||
expect(subscription.perform).toHaveBeenCalledWith("send_stat", {
|
||||
new_w1_stat: "Local W1",
|
||||
new_w2_stat: "Local W2"
|
||||
})
|
||||
|
||||
controller.pendingLocalSync.w1 = false
|
||||
controller.pendingLocalSync.w2 = false
|
||||
callbacks.received({ w1_stat: "Remote W1", w2_stat: "Remote W2" })
|
||||
|
||||
expect(controller.w1StatTarget.value).toBe("Remote W1")
|
||||
expect(controller.w2StatTarget.value).toBe("Remote W2")
|
||||
|
||||
callbacks.disconnected()
|
||||
expect(controller.isConnected).toBe(false)
|
||||
expect(controller.statusIndicatorTarget.innerText).toBe("Disconnected: Stats updates paused.")
|
||||
})
|
||||
|
||||
it("setupSubscription reports websocket unavailable when ActionCable is missing", () => {
|
||||
const controller = buildController()
|
||||
controller.connect()
|
||||
|
||||
expect(controller.statusIndicatorTarget.innerText).toBe("Error: WebSockets unavailable. Stats won't update in real-time.")
|
||||
expect(controller.statusIndicatorTarget.classList.add).toHaveBeenCalledWith("alert-danger")
|
||||
})
|
||||
})
|
||||
269
test/javascript/controllers/match_score_controller.test.js
Normal file
269
test/javascript/controllers/match_score_controller.test.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatchScoreController from "../../../app/assets/javascripts/controllers/match_score_controller.js"
|
||||
|
||||
class FakeNode {
|
||||
constructor(tagName = "div") {
|
||||
this.tagName = tagName
|
||||
this.children = []
|
||||
this.value = ""
|
||||
this.innerHTML = ""
|
||||
this.innerText = ""
|
||||
this.id = ""
|
||||
this.placeholder = ""
|
||||
this.type = ""
|
||||
this.style = {}
|
||||
this.listeners = {}
|
||||
this.classList = {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
this.children.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this[name] = value
|
||||
}
|
||||
|
||||
addEventListener(eventName, callback) {
|
||||
this.listeners[eventName] = callback
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
if (selector === "input") return this.allInputs()[0] || null
|
||||
if (!selector.startsWith("#")) return null
|
||||
return this.findById(selector.slice(1))
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
if (selector !== "input") return []
|
||||
return this.allInputs()
|
||||
}
|
||||
|
||||
findById(id) {
|
||||
if (this.id === id) return this
|
||||
for (const child of this.children) {
|
||||
const match = child.findById?.(id)
|
||||
if (match) return match
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
allInputs() {
|
||||
const matches = this.tagName === "input" ? [this] : []
|
||||
for (const child of this.children) {
|
||||
matches.push(...(child.allInputs?.() || []))
|
||||
}
|
||||
return matches
|
||||
}
|
||||
}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchScoreController()
|
||||
controller.element = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
controller.winTypeTarget = { value: "Decision" }
|
||||
controller.winnerSelectTarget = { value: "", options: [{ value: "" }, { value: "11" }], selectedIndex: 1 }
|
||||
controller.overtimeSelectTarget = { value: "", options: [{ value: "" }, { value: "SV-1" }] }
|
||||
controller.submitButtonTarget = { disabled: false }
|
||||
controller.dynamicScoreInputTarget = new FakeNode("div")
|
||||
controller.finalScoreFieldTarget = { value: "" }
|
||||
controller.validationAlertsTarget = {
|
||||
innerHTML: "",
|
||||
style: {},
|
||||
classList: { add: vi.fn(), remove: vi.fn() }
|
||||
}
|
||||
controller.pinTimeTipTarget = { style: {} }
|
||||
controller.manualOverrideValue = false
|
||||
controller.finishedValue = false
|
||||
controller.winnerScoreValue = "0"
|
||||
controller.loserScoreValue = "0"
|
||||
controller.pinMinutesValue = "0"
|
||||
controller.pinSecondsValue = "00"
|
||||
controller.hasWinnerSelectTarget = true
|
||||
controller.hasOvertimeSelectTarget = true
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match score controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
global.document = {
|
||||
createElement: vi.fn((tagName) => new FakeNode(tagName))
|
||||
}
|
||||
})
|
||||
|
||||
it("connect binds manual-override listeners and initializes unfinished forms", () => {
|
||||
const controller = buildController()
|
||||
controller.updateScoreInput = vi.fn()
|
||||
controller.validateForm = vi.fn()
|
||||
vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => {
|
||||
fn()
|
||||
return 1
|
||||
})
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.element.addEventListener).toHaveBeenCalledWith("input", controller.boundMarkManualOverride)
|
||||
expect(controller.element.addEventListener).toHaveBeenCalledWith("change", controller.boundMarkManualOverride)
|
||||
expect(controller.updateScoreInput).toHaveBeenCalledTimes(1)
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("connect skips score initialization for finished forms", () => {
|
||||
const controller = buildController()
|
||||
controller.finishedValue = true
|
||||
controller.updateScoreInput = vi.fn()
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.updateScoreInput).not.toHaveBeenCalled()
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("applyDefaultResults fills derived defaults until the user overrides them", () => {
|
||||
const controller = buildController()
|
||||
controller.updateScoreInput = vi.fn()
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.applyDefaultResults({
|
||||
winnerId: 11,
|
||||
overtimeType: "SV-1",
|
||||
winnerScore: 5,
|
||||
loserScore: 2,
|
||||
pinMinutes: 1,
|
||||
pinSeconds: 9
|
||||
})
|
||||
|
||||
expect(controller.winnerSelectTarget.value).toBe("11")
|
||||
expect(controller.overtimeSelectTarget.value).toBe("SV-1")
|
||||
expect(controller.winnerScoreValue).toBe("5")
|
||||
expect(controller.loserScoreValue).toBe("2")
|
||||
expect(controller.pinMinutesValue).toBe("1")
|
||||
expect(controller.pinSecondsValue).toBe("09")
|
||||
expect(controller.updateScoreInput).toHaveBeenCalledTimes(1)
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("applyDefaultResults does nothing after manual override", () => {
|
||||
const controller = buildController()
|
||||
controller.manualOverrideValue = true
|
||||
controller.updateScoreInput = vi.fn()
|
||||
|
||||
controller.applyDefaultResults({ winnerId: 11, winnerScore: 5 })
|
||||
|
||||
expect(controller.winnerSelectTarget.value).toBe("")
|
||||
expect(controller.updateScoreInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("markManualOverride only reacts to trusted events", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.markManualOverride({ isTrusted: false })
|
||||
expect(controller.manualOverrideValue).toBe(false)
|
||||
|
||||
controller.markManualOverride({ isTrusted: true })
|
||||
expect(controller.manualOverrideValue).toBe(true)
|
||||
})
|
||||
|
||||
it("validateForm disables submit for invalid decision scores and enables it for valid ones", () => {
|
||||
const controller = buildController()
|
||||
controller.winTypeTarget.value = "Decision"
|
||||
controller.winnerSelectTarget.value = "11"
|
||||
controller.winnerScoreValue = "2"
|
||||
controller.loserScoreValue = "3"
|
||||
|
||||
expect(controller.validateForm()).toBe(false)
|
||||
expect(controller.submitButtonTarget.disabled).toBe(true)
|
||||
expect(controller.validationAlertsTarget.style.display).toBe("block")
|
||||
|
||||
controller.winnerScoreValue = "5"
|
||||
controller.loserScoreValue = "2"
|
||||
controller.validationAlertsTarget.style = {}
|
||||
|
||||
expect(controller.validateForm()).toBe(true)
|
||||
expect(controller.submitButtonTarget.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it("winnerChanged and winTypeChanged revalidate the form", () => {
|
||||
const controller = buildController()
|
||||
controller.updateScoreInput = vi.fn()
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.winTypeChanged()
|
||||
expect(controller.updateScoreInput).toHaveBeenCalledTimes(1)
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(1)
|
||||
|
||||
controller.winnerChanged()
|
||||
expect(controller.validateForm).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("updateScoreInput builds pin inputs and writes pin time score", () => {
|
||||
const controller = buildController()
|
||||
controller.winTypeTarget.value = "Pin"
|
||||
controller.pinMinutesValue = "1"
|
||||
controller.pinSecondsValue = "09"
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.updateScoreInput()
|
||||
|
||||
expect(controller.pinTimeTipTarget.style.display).toBe("block")
|
||||
expect(controller.dynamicScoreInputTarget.querySelector("#minutes").value).toBe("1")
|
||||
expect(controller.dynamicScoreInputTarget.querySelector("#seconds").value).toBe("09")
|
||||
expect(controller.finalScoreFieldTarget.value).toBe("01:09")
|
||||
})
|
||||
|
||||
it("updateScoreInput builds point inputs and writes point score", () => {
|
||||
const controller = buildController()
|
||||
controller.winTypeTarget.value = "Decision"
|
||||
controller.winnerScoreValue = "7"
|
||||
controller.loserScoreValue = "3"
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.updateScoreInput()
|
||||
|
||||
expect(controller.pinTimeTipTarget.style.display).toBe("none")
|
||||
expect(controller.dynamicScoreInputTarget.querySelector("#winner-score").value).toBe("7")
|
||||
expect(controller.dynamicScoreInputTarget.querySelector("#loser-score").value).toBe("3")
|
||||
expect(controller.finalScoreFieldTarget.value).toBe("7-3")
|
||||
})
|
||||
|
||||
it("updateScoreInput clears score for non-score win types", () => {
|
||||
const controller = buildController()
|
||||
controller.winTypeTarget.value = "Forfeit"
|
||||
controller.finalScoreFieldTarget.value = "7-3"
|
||||
controller.validateForm = vi.fn()
|
||||
|
||||
controller.updateScoreInput()
|
||||
|
||||
expect(controller.pinTimeTipTarget.style.display).toBe("none")
|
||||
expect(controller.finalScoreFieldTarget.value).toBe("")
|
||||
expect(controller.dynamicScoreInputTarget.children.at(-1).innerText).toBe("No score required for Forfeit win type.")
|
||||
})
|
||||
|
||||
it("validateForm enforces major and tech fall score boundaries", () => {
|
||||
const controller = buildController()
|
||||
controller.winnerSelectTarget.value = "11"
|
||||
controller.winTypeTarget.value = "Decision"
|
||||
controller.winnerScoreValue = "10"
|
||||
controller.loserScoreValue = "2"
|
||||
|
||||
expect(controller.validateForm()).toBe(false)
|
||||
expect(controller.validationAlertsTarget.innerHTML).toContain("Major")
|
||||
|
||||
controller.winTypeTarget.value = "Tech Fall"
|
||||
controller.winnerScoreValue = "17"
|
||||
controller.loserScoreValue = "2"
|
||||
controller.validationAlertsTarget.innerHTML = ""
|
||||
controller.validationAlertsTarget.style = {}
|
||||
|
||||
expect(controller.validateForm()).toBe(true)
|
||||
expect(controller.submitButtonTarget.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
355
test/javascript/controllers/match_scoreboard_controller.test.js
Normal file
355
test/javascript/controllers/match_scoreboard_controller.test.js
Normal file
@@ -0,0 +1,355 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatchScoreboardController from "../../../app/assets/javascripts/controllers/match_scoreboard_controller.js"
|
||||
|
||||
function makeTarget() {
|
||||
return {
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
style: {}
|
||||
}
|
||||
}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchScoreboardController()
|
||||
|
||||
controller.sourceModeValue = "localstorage"
|
||||
controller.displayModeValue = "fullscreen"
|
||||
controller.initialBoutNumberValue = 1001
|
||||
controller.matchIdValue = 22
|
||||
controller.matIdValue = 3
|
||||
controller.tournamentIdValue = 8
|
||||
|
||||
controller.hasRedSectionTarget = true
|
||||
controller.hasCenterSectionTarget = true
|
||||
controller.hasGreenSectionTarget = true
|
||||
controller.hasEmptyStateTarget = true
|
||||
controller.hasRedNameTarget = true
|
||||
controller.hasRedSchoolTarget = true
|
||||
controller.hasRedScoreTarget = true
|
||||
controller.hasRedTimerIndicatorTarget = true
|
||||
controller.hasGreenNameTarget = true
|
||||
controller.hasGreenSchoolTarget = true
|
||||
controller.hasGreenScoreTarget = true
|
||||
controller.hasGreenTimerIndicatorTarget = true
|
||||
controller.hasClockTarget = true
|
||||
controller.hasPeriodLabelTarget = true
|
||||
controller.hasWeightLabelTarget = true
|
||||
controller.hasBoutLabelTarget = true
|
||||
controller.hasTimerBannerTarget = true
|
||||
controller.hasTimerBannerLabelTarget = true
|
||||
controller.hasTimerBannerClockTarget = true
|
||||
controller.hasRedStatsTarget = true
|
||||
controller.hasGreenStatsTarget = true
|
||||
controller.hasLastMatchResultTarget = true
|
||||
|
||||
controller.redSectionTarget = makeTarget()
|
||||
controller.centerSectionTarget = makeTarget()
|
||||
controller.greenSectionTarget = makeTarget()
|
||||
controller.emptyStateTarget = makeTarget()
|
||||
controller.redNameTarget = makeTarget()
|
||||
controller.redSchoolTarget = makeTarget()
|
||||
controller.redScoreTarget = makeTarget()
|
||||
controller.redTimerIndicatorTarget = makeTarget()
|
||||
controller.greenNameTarget = makeTarget()
|
||||
controller.greenSchoolTarget = makeTarget()
|
||||
controller.greenScoreTarget = makeTarget()
|
||||
controller.greenTimerIndicatorTarget = makeTarget()
|
||||
controller.clockTarget = makeTarget()
|
||||
controller.periodLabelTarget = makeTarget()
|
||||
controller.weightLabelTarget = makeTarget()
|
||||
controller.boutLabelTarget = makeTarget()
|
||||
controller.timerBannerTarget = makeTarget()
|
||||
controller.timerBannerLabelTarget = makeTarget()
|
||||
controller.timerBannerClockTarget = makeTarget()
|
||||
controller.redStatsTarget = makeTarget()
|
||||
controller.greenStatsTarget = makeTarget()
|
||||
controller.lastMatchResultTarget = makeTarget()
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match scoreboard controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_000)
|
||||
global.window = {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => null)
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
setInterval: vi.fn(() => 123),
|
||||
clearInterval: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
it("connects in localstorage mode using the extracted connection plan", () => {
|
||||
const controller = buildController()
|
||||
controller.setupMatSubscription = vi.fn()
|
||||
controller.setupMatchSubscription = vi.fn()
|
||||
controller.loadSelectedBoutNumber = vi.fn()
|
||||
controller.loadStateFromLocalStorage = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(window.addEventListener).toHaveBeenCalledWith("storage", controller.storageListener)
|
||||
expect(controller.loadSelectedBoutNumber).toHaveBeenCalledTimes(1)
|
||||
expect(controller.loadStateFromLocalStorage).toHaveBeenCalledTimes(1)
|
||||
expect(controller.setupMatSubscription).toHaveBeenCalledTimes(1)
|
||||
expect(controller.setupMatchSubscription).not.toHaveBeenCalled()
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("connects with populated localStorage state before any timer snapshot exists", () => {
|
||||
const controller = buildController()
|
||||
const persistedState = {
|
||||
metadata: {
|
||||
boutNumber: 1001,
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
w1Name: "Alpha",
|
||||
w2Name: "Bravo"
|
||||
},
|
||||
assignment: { w1: "green", w2: "red" },
|
||||
participantScores: { w1: 0, w2: 0 },
|
||||
phaseIndex: 0,
|
||||
clocksByPhase: {
|
||||
period_1: { remainingSeconds: 120, running: false, startedAt: null }
|
||||
},
|
||||
timers: {
|
||||
w1: { blood: { remainingSeconds: 300, running: false, startedAt: null } },
|
||||
w2: { blood: { remainingSeconds: 300, running: false, startedAt: null } }
|
||||
}
|
||||
}
|
||||
|
||||
window.localStorage.getItem = vi.fn((key) => {
|
||||
if (key === "match-state:8:1001") return JSON.stringify(persistedState)
|
||||
return null
|
||||
})
|
||||
controller.setupMatSubscription = vi.fn()
|
||||
|
||||
expect(() => controller.connect()).not.toThrow()
|
||||
expect(controller.previousTimerSnapshot).toEqual({
|
||||
"w1:blood": false,
|
||||
"w2:blood": false
|
||||
})
|
||||
controller.disconnect()
|
||||
})
|
||||
|
||||
it("renders populated scoreboard targets from state", () => {
|
||||
const controller = buildController()
|
||||
controller.currentBoutNumber = 1001
|
||||
controller.liveMatchData = { w1_stat: "T3", w2_stat: "E1" }
|
||||
controller.lastMatchResult = "Previous result"
|
||||
controller.state = {
|
||||
metadata: {
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
weightLabel: "106",
|
||||
w1Name: "Alpha",
|
||||
w2Name: "Bravo",
|
||||
w1School: "School A",
|
||||
w2School: "School B"
|
||||
},
|
||||
assignment: { w1: "green", w2: "red" },
|
||||
participantScores: { w1: 3, w2: 1 },
|
||||
phaseIndex: 0,
|
||||
clocksByPhase: {
|
||||
period_1: { remainingSeconds: 120, running: false, startedAt: null }
|
||||
},
|
||||
timers: { w1: {}, w2: {} }
|
||||
}
|
||||
controller.timerBannerState = null
|
||||
|
||||
controller.render()
|
||||
|
||||
expect(controller.emptyStateTarget.style.display).toBe("none")
|
||||
expect(controller.redNameTarget.textContent).toBe("Bravo")
|
||||
expect(controller.redSchoolTarget.textContent).toBe("School B")
|
||||
expect(controller.redScoreTarget.textContent).toBe("1")
|
||||
expect(controller.greenNameTarget.textContent).toBe("Alpha")
|
||||
expect(controller.greenSchoolTarget.textContent).toBe("School A")
|
||||
expect(controller.greenScoreTarget.textContent).toBe("3")
|
||||
expect(controller.clockTarget.textContent).toBe("2:00")
|
||||
expect(controller.periodLabelTarget.textContent).toBe("Period 1")
|
||||
expect(controller.weightLabelTarget.textContent).toBe("Weight 106")
|
||||
expect(controller.boutLabelTarget.textContent).toBe("Bout 1001")
|
||||
expect(controller.redStatsTarget.textContent).toBe("E1")
|
||||
expect(controller.greenStatsTarget.textContent).toBe("T3")
|
||||
expect(controller.lastMatchResultTarget.textContent).toBe("Previous result")
|
||||
expect(controller.redSectionTarget.style.background).toBe("#c91f1f")
|
||||
expect(controller.greenSectionTarget.style.background).toBe("#1cab2d")
|
||||
})
|
||||
|
||||
it("renders empty scoreboard targets when there is no match state", () => {
|
||||
const controller = buildController()
|
||||
controller.currentBoutNumber = 1005
|
||||
controller.lastMatchResult = "Last result"
|
||||
controller.state = null
|
||||
|
||||
controller.render()
|
||||
|
||||
expect(controller.emptyStateTarget.style.display).toBe("block")
|
||||
expect(controller.redNameTarget.textContent).toBe("NO MATCH")
|
||||
expect(controller.greenNameTarget.textContent).toBe("NO MATCH")
|
||||
expect(controller.clockTarget.textContent).toBe("-")
|
||||
expect(controller.periodLabelTarget.textContent).toBe("No Match")
|
||||
expect(controller.boutLabelTarget.textContent).toBe("Bout 1005")
|
||||
expect(controller.lastMatchResultTarget.textContent).toBe("Last result")
|
||||
expect(controller.redSectionTarget.style.background).toBe("#000")
|
||||
expect(controller.greenSectionTarget.style.background).toBe("#000")
|
||||
})
|
||||
|
||||
it("renders a visible timer banner when the match clock is not running", () => {
|
||||
const controller = buildController()
|
||||
controller.config = {
|
||||
phaseSequence: [{ key: "period_1", type: "period" }],
|
||||
timers: { blood: { label: "Blood", maxSeconds: 300 } }
|
||||
}
|
||||
controller.state = {
|
||||
phaseIndex: 0,
|
||||
metadata: { w1Name: "Alpha" },
|
||||
assignment: { w1: "green", w2: "red" },
|
||||
clocksByPhase: { period_1: { running: false, remainingSeconds: 120, startedAt: null } },
|
||||
timers: {
|
||||
w1: { blood: { running: true, remainingSeconds: 300, startedAt: 1_000 } },
|
||||
w2: {}
|
||||
}
|
||||
}
|
||||
controller.timerBannerState = { participantKey: "w1", timerKey: "blood", expiresAt: null }
|
||||
|
||||
controller.renderTimerBanner()
|
||||
|
||||
expect(controller.timerBannerTarget.style.display).toBe("block")
|
||||
expect(controller.timerBannerLabelTarget.textContent).toBe("Green Alpha Blood Running")
|
||||
expect(controller.timerBannerClockTarget.textContent).toBe("0:00")
|
||||
})
|
||||
|
||||
it("hides an expiring timer banner when the main clock is running", () => {
|
||||
const controller = buildController()
|
||||
controller.config = {
|
||||
phaseSequence: [{ key: "period_1", type: "period" }],
|
||||
timers: { blood: { label: "Blood", maxSeconds: 300 } }
|
||||
}
|
||||
controller.state = {
|
||||
phaseIndex: 0,
|
||||
metadata: { w1Name: "Alpha" },
|
||||
assignment: { w1: "green", w2: "red" },
|
||||
clocksByPhase: { period_1: { running: true, remainingSeconds: 120, startedAt: 1_000 } },
|
||||
timers: {
|
||||
w1: { blood: { running: false, remainingSeconds: 280, startedAt: null } },
|
||||
w2: {}
|
||||
}
|
||||
}
|
||||
controller.timerBannerState = { participantKey: "w1", timerKey: "blood", expiresAt: 5_000 }
|
||||
|
||||
controller.renderTimerBanner()
|
||||
|
||||
expect(controller.timerBannerState).toBe(null)
|
||||
expect(controller.timerBannerTarget.style.display).toBe("none")
|
||||
})
|
||||
|
||||
it("handles mat payload changes by switching match subscriptions and rendering", () => {
|
||||
const controller = buildController()
|
||||
controller.sourceModeValue = "mat_websocket"
|
||||
controller.currentQueueBoutNumber = 1001
|
||||
controller.currentBoutNumber = 1001
|
||||
controller.currentMatchId = 10
|
||||
controller.liveMatchData = { w1_stat: "Old" }
|
||||
controller.state = { metadata: { boutNumber: 1001 } }
|
||||
controller.setupMatchSubscription = vi.fn()
|
||||
controller.unsubscribeMatchSubscription = vi.fn()
|
||||
controller.loadSelectedBoutNumber = vi.fn()
|
||||
controller.loadStateFromLocalStorage = vi.fn()
|
||||
controller.resetTimerBannerState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.handleMatPayload({
|
||||
queue1_bout_number: 1001,
|
||||
queue1_match_id: 10,
|
||||
selected_bout_number: 1002,
|
||||
selected_match_id: 11,
|
||||
last_match_result: "Result"
|
||||
})
|
||||
|
||||
expect(controller.currentMatchId).toBe(11)
|
||||
expect(controller.currentBoutNumber).toBe(1002)
|
||||
expect(controller.lastMatchResult).toBe("Result")
|
||||
expect(controller.resetTimerBannerState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.setupMatchSubscription).toHaveBeenCalledWith(11)
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("loads selected mat payload state from localStorage when the selected-bout key is missing", () => {
|
||||
const controller = buildController()
|
||||
const selectedState = {
|
||||
metadata: {
|
||||
boutNumber: 1002,
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64"
|
||||
},
|
||||
matchResult: { finished: false }
|
||||
}
|
||||
|
||||
window.localStorage.getItem = vi.fn((key) => {
|
||||
if (key === "match-state:8:1002") return JSON.stringify(selectedState)
|
||||
return null
|
||||
})
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.handleMatPayload({
|
||||
queue1_bout_number: 1001,
|
||||
selected_bout_number: 1002,
|
||||
last_match_result: ""
|
||||
})
|
||||
|
||||
expect(controller.currentBoutNumber).toBe(1002)
|
||||
expect(controller.state).toEqual(selectedState)
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("reloads selected bout and local state on selected-bout storage changes", () => {
|
||||
const controller = buildController()
|
||||
controller.currentQueueBoutNumber = 1001
|
||||
controller.currentBoutNumber = 1001
|
||||
controller.loadSelectedBoutNumber = vi.fn()
|
||||
controller.loadStateFromLocalStorage = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.handleStorageChange({ key: "mat-selected-bout:8:3" })
|
||||
|
||||
expect(controller.loadSelectedBoutNumber).toHaveBeenCalledTimes(1)
|
||||
expect(controller.loadStateFromLocalStorage).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("applies match websocket payloads into live match data and scoreboard state", () => {
|
||||
const controller = buildController()
|
||||
controller.currentBoutNumber = 1001
|
||||
controller.liveMatchData = { w1_stat: "Old" }
|
||||
controller.state = null
|
||||
|
||||
controller.handleMatchPayload({
|
||||
scoreboard_state: {
|
||||
metadata: { boutNumber: 1002 },
|
||||
matchResult: { finished: true }
|
||||
},
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
finished: 1
|
||||
})
|
||||
|
||||
expect(controller.currentBoutNumber).toBe(1002)
|
||||
expect(controller.finished).toBe(true)
|
||||
expect(controller.liveMatchData).toEqual({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
finished: 1
|
||||
})
|
||||
expect(controller.state).toEqual({
|
||||
metadata: { boutNumber: 1002 },
|
||||
matchResult: { finished: true }
|
||||
})
|
||||
})
|
||||
})
|
||||
140
test/javascript/controllers/match_spectate_controller.test.js
Normal file
140
test/javascript/controllers/match_spectate_controller.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import MatchSpectateController from "../../../app/assets/javascripts/controllers/match_spectate_controller.js"
|
||||
|
||||
function makeTarget() {
|
||||
return {
|
||||
textContent: "",
|
||||
style: {},
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchSpectateController()
|
||||
controller.matchIdValue = 22
|
||||
controller.hasW1StatsTarget = true
|
||||
controller.hasW2StatsTarget = true
|
||||
controller.hasWinnerTarget = true
|
||||
controller.hasWinTypeTarget = true
|
||||
controller.hasScoreTarget = true
|
||||
controller.hasFinishedTarget = true
|
||||
controller.hasStatusIndicatorTarget = true
|
||||
controller.hasScoreboardContainerTarget = true
|
||||
controller.w1StatsTarget = makeTarget()
|
||||
controller.w2StatsTarget = makeTarget()
|
||||
controller.winnerTarget = makeTarget()
|
||||
controller.winTypeTarget = makeTarget()
|
||||
controller.scoreTarget = makeTarget()
|
||||
controller.finishedTarget = makeTarget()
|
||||
controller.statusIndicatorTarget = makeTarget()
|
||||
controller.scoreboardContainerTarget = makeTarget()
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match spectate controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete global.App
|
||||
})
|
||||
|
||||
it("connect subscribes to the match when a match id is present", () => {
|
||||
const controller = buildController()
|
||||
controller.setupSubscription = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.setupSubscription).toHaveBeenCalledWith(22)
|
||||
})
|
||||
|
||||
it("setupSubscription reports ActionCable missing", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.setupSubscription(22)
|
||||
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Error: AC Not Loaded")
|
||||
expect(controller.statusIndicatorTarget.classList.add).toHaveBeenCalledWith("alert-danger", "text-danger")
|
||||
})
|
||||
|
||||
it("subscription callbacks update status and request initial sync", () => {
|
||||
const controller = buildController()
|
||||
const subscription = { perform: vi.fn(), unsubscribe: vi.fn() }
|
||||
let callbacks
|
||||
global.App = {
|
||||
cable: {
|
||||
subscriptions: {
|
||||
create: vi.fn((_identifier, receivedCallbacks) => {
|
||||
callbacks = receivedCallbacks
|
||||
return subscription
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.setupSubscription(22)
|
||||
callbacks.initialized()
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Connecting to backend for live updates...")
|
||||
|
||||
callbacks.connected()
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Connected to backend for live updates...")
|
||||
expect(subscription.perform).toHaveBeenCalledWith("request_sync")
|
||||
|
||||
callbacks.disconnected()
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Disconnected from backend for live updates. Retrying...")
|
||||
|
||||
callbacks.rejected()
|
||||
expect(controller.statusIndicatorTarget.textContent).toBe("Connection to backend rejected")
|
||||
expect(controller.matchSubscription).toBe(null)
|
||||
})
|
||||
|
||||
it("received websocket payloads update stats and result fields", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.updateDisplayElements({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_name: "Alpha",
|
||||
finished: 0
|
||||
})
|
||||
|
||||
expect(controller.w1StatsTarget.textContent).toBe("T3")
|
||||
expect(controller.w2StatsTarget.textContent).toBe("E1")
|
||||
expect(controller.scoreTarget.textContent).toBe("3-1")
|
||||
expect(controller.winTypeTarget.textContent).toBe("Decision")
|
||||
expect(controller.winnerTarget.textContent).toBe("Alpha")
|
||||
expect(controller.finishedTarget.textContent).toBe("No")
|
||||
expect(controller.scoreboardContainerTarget.style.display).toBe("block")
|
||||
})
|
||||
|
||||
it("finished websocket payload hides the embedded scoreboard", () => {
|
||||
const controller = buildController()
|
||||
|
||||
controller.updateDisplayElements({
|
||||
winner_id: 11,
|
||||
score: "",
|
||||
win_type: "",
|
||||
finished: 1
|
||||
})
|
||||
|
||||
expect(controller.winnerTarget.textContent).toBe("ID: 11")
|
||||
expect(controller.scoreTarget.textContent).toBe("-")
|
||||
expect(controller.winTypeTarget.textContent).toBe("-")
|
||||
expect(controller.finishedTarget.textContent).toBe("Yes")
|
||||
expect(controller.scoreboardContainerTarget.style.display).toBe("none")
|
||||
})
|
||||
|
||||
it("disconnect unsubscribes from the match channel", () => {
|
||||
const controller = buildController()
|
||||
const subscription = { unsubscribe: vi.fn() }
|
||||
controller.matchSubscription = subscription
|
||||
|
||||
controller.disconnect()
|
||||
|
||||
expect(subscription.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(controller.matchSubscription).toBe(null)
|
||||
})
|
||||
})
|
||||
315
test/javascript/controllers/match_state_controller.test.js
Normal file
315
test/javascript/controllers/match_state_controller.test.js
Normal file
@@ -0,0 +1,315 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
import MatchStateController from "../../../app/assets/javascripts/controllers/match_state_controller.js"
|
||||
|
||||
function buildController() {
|
||||
const controller = new MatchStateController()
|
||||
controller.element = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
querySelector: vi.fn(() => null)
|
||||
}
|
||||
controller.application = {
|
||||
getControllerForElementAndIdentifier: vi.fn()
|
||||
}
|
||||
controller.matchIdValue = 22
|
||||
controller.tournamentIdValue = 8
|
||||
controller.boutNumberValue = 1001
|
||||
controller.weightLabelValue = "106"
|
||||
controller.bracketPositionValue = "Bracket Round of 64"
|
||||
controller.rulesetValue = "folkstyle_usa"
|
||||
controller.w1IdValue = 11
|
||||
controller.w2IdValue = 12
|
||||
controller.w1NameValue = "Alpha"
|
||||
controller.w2NameValue = "Bravo"
|
||||
controller.w1SchoolValue = "School A"
|
||||
controller.w2SchoolValue = "School B"
|
||||
controller.hasW1StatFieldTarget = true
|
||||
controller.hasW2StatFieldTarget = true
|
||||
controller.w1StatFieldTarget = { value: "" }
|
||||
controller.w2StatFieldTarget = { value: "" }
|
||||
controller.hasMatchResultsPanelTarget = true
|
||||
controller.matchResultsPanelTarget = { querySelector: vi.fn(() => ({})) }
|
||||
return controller
|
||||
}
|
||||
|
||||
describe("match state controller", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
global.window = {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
},
|
||||
setInterval: vi.fn(() => 123),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout: vi.fn((fn) => {
|
||||
fn()
|
||||
return 1
|
||||
}),
|
||||
clearTimeout: vi.fn(),
|
||||
confirm: vi.fn(() => true)
|
||||
}
|
||||
})
|
||||
|
||||
it("connect initializes state, restores persistence, renders, and subscribes", () => {
|
||||
const controller = buildController()
|
||||
controller.initializeState = vi.fn()
|
||||
controller.loadPersistedState = vi.fn()
|
||||
controller.syncClockFromActivePhase = vi.fn()
|
||||
controller.hasRunningClockOrTimer = vi.fn(() => true)
|
||||
controller.startTicking = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
controller.setupSubscription = vi.fn()
|
||||
|
||||
controller.connect()
|
||||
|
||||
expect(controller.element.addEventListener).toHaveBeenCalledWith("click", controller.boundHandleClick)
|
||||
expect(controller.initializeState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.loadPersistedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.syncClockFromActivePhase).toHaveBeenCalledTimes(1)
|
||||
expect(controller.startTicking).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledWith({ rebuildControls: true })
|
||||
expect(controller.setupSubscription).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("applyAction recomputes and rerenders when a match action is accepted", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.recomputeDerivedState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.applyAction({
|
||||
dataset: {
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
}
|
||||
})
|
||||
|
||||
expect(controller.recomputeDerivedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledWith({ rebuildControls: true })
|
||||
})
|
||||
|
||||
it("applyChoice rerenders on defer and advances on a committed choice", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.state.phaseIndex = 1
|
||||
controller.recomputeDerivedState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
controller.advancePhase = vi.fn()
|
||||
|
||||
controller.applyChoice({
|
||||
dataset: {
|
||||
participantKey: "w1",
|
||||
choiceKey: "defer"
|
||||
}
|
||||
})
|
||||
|
||||
expect(controller.recomputeDerivedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledWith({ rebuildControls: true })
|
||||
expect(controller.advancePhase).not.toHaveBeenCalled()
|
||||
|
||||
controller.recomputeDerivedState.mockClear()
|
||||
controller.render.mockClear()
|
||||
|
||||
controller.applyChoice({
|
||||
dataset: {
|
||||
participantKey: "w1",
|
||||
choiceKey: "top"
|
||||
}
|
||||
})
|
||||
|
||||
expect(controller.advancePhase).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("updateStatFieldsAndBroadcast writes hidden fields and pushes both channel payloads", () => {
|
||||
const controller = buildController()
|
||||
controller.derivedStats = vi.fn(() => ({ w1: "Period 1: T3", w2: "Period 1: E1" }))
|
||||
controller.pushDerivedStatsToChannel = vi.fn()
|
||||
controller.pushScoreboardStateToChannel = vi.fn()
|
||||
|
||||
controller.updateStatFieldsAndBroadcast()
|
||||
|
||||
expect(controller.w1StatFieldTarget.value).toBe("Period 1: T3")
|
||||
expect(controller.w2StatFieldTarget.value).toBe("Period 1: E1")
|
||||
expect(controller.lastDerivedStats).toEqual({ w1: "Period 1: T3", w2: "Period 1: E1" })
|
||||
expect(controller.pushDerivedStatsToChannel).toHaveBeenCalledTimes(1)
|
||||
expect(controller.pushScoreboardStateToChannel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("pushes derived stats and scoreboard payloads through the match subscription with dedupe", () => {
|
||||
const controller = buildController()
|
||||
controller.matchSubscription = { perform: vi.fn() }
|
||||
controller.lastDerivedStats = { w1: "Period 1: T3", w2: "Period 1: E1" }
|
||||
controller.scoreboardStatePayload = vi.fn(() => ({ participantScores: { w1: 3, w2: 1 } }))
|
||||
|
||||
controller.pushDerivedStatsToChannel()
|
||||
controller.pushScoreboardStateToChannel()
|
||||
controller.pushDerivedStatsToChannel()
|
||||
controller.pushScoreboardStateToChannel()
|
||||
|
||||
expect(controller.matchSubscription.perform).toHaveBeenCalledTimes(2)
|
||||
expect(controller.matchSubscription.perform).toHaveBeenNthCalledWith(1, "send_stat", {
|
||||
new_w1_stat: "Period 1: T3",
|
||||
new_w2_stat: "Period 1: E1"
|
||||
})
|
||||
expect(controller.matchSubscription.perform).toHaveBeenNthCalledWith(2, "send_scoreboard", {
|
||||
scoreboard_state: { participantScores: { w1: 3, w2: 1 } }
|
||||
})
|
||||
})
|
||||
|
||||
it("starts, stops, and adjusts the active match clock", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.render = vi.fn()
|
||||
controller.startTicking = vi.fn()
|
||||
controller.stopTicking = vi.fn()
|
||||
|
||||
controller.startClock()
|
||||
expect(controller.activeClock().running).toBe(true)
|
||||
expect(controller.startTicking).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
|
||||
controller.stopClock()
|
||||
expect(controller.activeClock().running).toBe(false)
|
||||
expect(controller.stopTicking).toHaveBeenCalledTimes(1)
|
||||
|
||||
const beforeAdjustment = controller.activeClock().remainingSeconds
|
||||
controller.adjustClock(-1)
|
||||
expect(controller.activeClock().remainingSeconds).toBe(beforeAdjustment - 1)
|
||||
})
|
||||
|
||||
it("stopping an auxiliary timer records a timer-used event", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.state.timers.w1.blood.running = true
|
||||
controller.state.timers.w1.blood.startedAt = 1_000
|
||||
controller.state.timers.w1.blood.remainingSeconds = 300
|
||||
controller.render = vi.fn()
|
||||
vi.spyOn(Date, "now").mockReturnValue(6_000)
|
||||
|
||||
controller.stopAuxiliaryTimer("w1", "blood")
|
||||
|
||||
expect(controller.state.timers.w1.blood.running).toBe(false)
|
||||
expect(controller.state.events).toHaveLength(1)
|
||||
expect(controller.state.events[0]).toMatchObject({
|
||||
participantKey: "w1",
|
||||
actionKey: "timer_used_blood",
|
||||
elapsedSeconds: 5
|
||||
})
|
||||
expect(controller.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("deletes, swaps events, and swaps whole phases through button dataset ids", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.state.events = [
|
||||
{
|
||||
id: 1,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 120,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 100,
|
||||
participantKey: "w2",
|
||||
actionKey: "escape_1"
|
||||
}
|
||||
]
|
||||
controller.recomputeDerivedState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
|
||||
controller.swapEvent({ dataset: { eventId: "1" } })
|
||||
expect(controller.state.events[0].participantKey).toBe("w2")
|
||||
|
||||
controller.swapPhase({ dataset: { phaseKey: "period_1" } })
|
||||
expect(controller.state.events.map((eventRecord) => eventRecord.participantKey)).toEqual(["w1", "w1"])
|
||||
|
||||
controller.deleteEvent({ dataset: { eventId: "2" } })
|
||||
expect(controller.state.events.map((eventRecord) => eventRecord.id)).toEqual([1])
|
||||
expect(controller.recomputeDerivedState).toHaveBeenCalledTimes(3)
|
||||
expect(controller.render).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("delegated click dispatches dynamic state buttons", () => {
|
||||
const controller = buildController()
|
||||
controller.applyAction = vi.fn()
|
||||
const button = {
|
||||
dataset: { matchStateButton: "score-action" }
|
||||
}
|
||||
|
||||
controller.handleDelegatedClick({
|
||||
target: {
|
||||
closest: vi.fn(() => button)
|
||||
}
|
||||
})
|
||||
|
||||
expect(controller.applyAction).toHaveBeenCalledWith(button)
|
||||
})
|
||||
|
||||
it("applyMatchResultDefaults forwards derived defaults to the nested match-score controller", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.state = buildInitialState(controller.config)
|
||||
controller.state.participantScores = { w1: 4, w2: 2 }
|
||||
controller.currentPhase = vi.fn(() => controller.config.phaseSequence[0])
|
||||
controller.accumulatedMatchSeconds = vi.fn(() => 69)
|
||||
const nested = { applyDefaultResults: vi.fn() }
|
||||
controller.application.getControllerForElementAndIdentifier.mockReturnValue(nested)
|
||||
|
||||
controller.applyMatchResultDefaults()
|
||||
|
||||
expect(nested.applyDefaultResults).toHaveBeenCalledTimes(1)
|
||||
expect(nested.applyDefaultResults.mock.calls[0][0]).toMatchObject({
|
||||
winnerId: 11,
|
||||
winnerScore: 4,
|
||||
loserScore: 2
|
||||
})
|
||||
})
|
||||
|
||||
it("resetMatch reinitializes state and clears persistence when confirmed", () => {
|
||||
const controller = buildController()
|
||||
controller.initializeState = vi.fn()
|
||||
controller.syncClockFromActivePhase = vi.fn()
|
||||
controller.clearPersistedState = vi.fn()
|
||||
controller.render = vi.fn()
|
||||
controller.stopTicking = vi.fn()
|
||||
|
||||
controller.resetMatch()
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledTimes(1)
|
||||
expect(controller.stopTicking).toHaveBeenCalledTimes(1)
|
||||
expect(controller.initializeState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.syncClockFromActivePhase).toHaveBeenCalledTimes(1)
|
||||
expect(controller.clearPersistedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.render).toHaveBeenCalledWith({ rebuildControls: true })
|
||||
})
|
||||
|
||||
it("loadPersistedState restores saved state and clears invalid saved state", () => {
|
||||
const controller = buildController()
|
||||
controller.config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
controller.buildInitialState = vi.fn(() => ({ fresh: true }))
|
||||
controller.clearPersistedState = vi.fn()
|
||||
|
||||
window.localStorage.getItem.mockReturnValueOnce('{"participantScores":{"w1":3,"w2":1}}')
|
||||
controller.loadPersistedState()
|
||||
expect(controller.state.participantScores).toEqual({ w1: 3, w2: 1 })
|
||||
|
||||
window.localStorage.getItem.mockReturnValue("{bad-json")
|
||||
controller.loadPersistedState()
|
||||
expect(controller.clearPersistedState).toHaveBeenCalledTimes(1)
|
||||
expect(controller.state).toEqual({ fresh: true })
|
||||
})
|
||||
})
|
||||
435
test/javascript/match_state/engine.test.js
Normal file
435
test/javascript/match_state/engine.test.js
Normal file
@@ -0,0 +1,435 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import {
|
||||
accumulatedMatchSeconds,
|
||||
activeClockForPhase,
|
||||
adjustClockState,
|
||||
applyChoiceAction,
|
||||
applyMatchAction,
|
||||
buildInitialState,
|
||||
deleteEventFromState,
|
||||
derivedStats,
|
||||
hasRunningClockOrTimer,
|
||||
matchResultDefaults,
|
||||
moveToNextPhase,
|
||||
moveToPreviousPhase,
|
||||
recordProgressiveAction,
|
||||
recomputeDerivedState,
|
||||
scoreboardStatePayload,
|
||||
startAuxiliaryTimerState,
|
||||
startClockState,
|
||||
stopAuxiliaryTimerState,
|
||||
stopClockState,
|
||||
stopAllAuxiliaryTimers,
|
||||
swapEventParticipants,
|
||||
syncClockSnapshot,
|
||||
swapPhaseParticipants
|
||||
} from "match-state-engine"
|
||||
|
||||
function buildEvent(overrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 120,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3",
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe("match state engine", () => {
|
||||
it("replays takedown and escape into score and control", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
state.events = [
|
||||
buildEvent(),
|
||||
buildEvent({
|
||||
id: 2,
|
||||
participantKey: "w2",
|
||||
actionKey: "escape_1",
|
||||
clockSeconds: 80
|
||||
})
|
||||
]
|
||||
|
||||
recomputeDerivedState(config, state)
|
||||
|
||||
expect(state.participantScores).toEqual({ w1: 3, w2: 1 })
|
||||
expect(state.control).toBe("neutral")
|
||||
expect(state.displayControl).toBe("neutral")
|
||||
})
|
||||
|
||||
it("stores non defer choices and applies chosen starting control to later periods", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
state.phaseIndex = 2
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
phaseKey: "choice_1",
|
||||
phaseLabel: "Choice 1",
|
||||
clockSeconds: 0,
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_top"
|
||||
})
|
||||
]
|
||||
|
||||
recomputeDerivedState(config, state)
|
||||
|
||||
expect(state.selections.choice_1).toEqual({ participantKey: "w1", choiceKey: "top" })
|
||||
expect(state.control).toBe("w1_control")
|
||||
expect(state.displayControl).toBe("w1_control")
|
||||
})
|
||||
|
||||
it("ignores defer as a final selection", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
state.phaseIndex = 2
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
phaseKey: "choice_1",
|
||||
phaseLabel: "Choice 1",
|
||||
clockSeconds: 0,
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_defer"
|
||||
})
|
||||
]
|
||||
|
||||
recomputeDerivedState(config, state)
|
||||
|
||||
expect(state.selections).toEqual({})
|
||||
expect(state.control).toBe("neutral")
|
||||
expect(state.displayControl).toBe("neutral")
|
||||
})
|
||||
|
||||
it("derives legacy stats grouped by period", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const stats = derivedStats(config, [
|
||||
buildEvent(),
|
||||
buildEvent({
|
||||
id: 2,
|
||||
participantKey: "w2",
|
||||
actionKey: "escape_1",
|
||||
clockSeconds: 80
|
||||
}),
|
||||
buildEvent({
|
||||
id: 3,
|
||||
phaseKey: "choice_1",
|
||||
phaseLabel: "Choice 1",
|
||||
clockSeconds: 0,
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_defer"
|
||||
})
|
||||
])
|
||||
|
||||
expect(stats.w1).toBe("Period 1: T3\nChoice 1: |Deferred|")
|
||||
expect(stats.w2).toBe("Period 1: E1")
|
||||
})
|
||||
|
||||
it("derives accumulated match time from period clocks", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
state.phaseIndex = 2
|
||||
state.clocksByPhase.period_1.remainingSeconds = 42
|
||||
state.clocksByPhase.period_2.remainingSeconds = 75
|
||||
state.clocksByPhase.period_3.remainingSeconds = 120
|
||||
|
||||
const total = accumulatedMatchSeconds(config, state, "period_2")
|
||||
|
||||
expect(total).toBe((120 - 42) + (120 - 75))
|
||||
})
|
||||
|
||||
it("builds scoreboard payload from canonical state and metadata", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.participantScores = { w1: 6, w2: 2 }
|
||||
state.phaseIndex = 2
|
||||
|
||||
const payload = scoreboardStatePayload(config, state, {
|
||||
tournamentId: 1,
|
||||
boutNumber: 1001,
|
||||
weightLabel: "106",
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
w1Name: "Wrestler 1",
|
||||
w2Name: "Wrestler 2",
|
||||
w1School: "School A",
|
||||
w2School: "School B"
|
||||
})
|
||||
|
||||
expect(payload.participantScores).toEqual({ w1: 6, w2: 2 })
|
||||
expect(payload.phaseIndex).toBe(2)
|
||||
expect(payload.metadata.boutNumber).toBe(1001)
|
||||
expect(payload.metadata.w1Name).toBe("Wrestler 1")
|
||||
expect(payload.matchResult).toEqual({ finished: false })
|
||||
})
|
||||
|
||||
it("records progressive penalty with linked awarded points", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.nextEventId = 1
|
||||
state.nextEventGroupId = 1
|
||||
|
||||
const buildControllerStyleEvent = (participantKey, actionKey, options = {}) => ({
|
||||
id: state.nextEventId++,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 120,
|
||||
participantKey,
|
||||
actionKey,
|
||||
actionGroupId: options.actionGroupId
|
||||
})
|
||||
|
||||
recordProgressiveAction(config, state, "w1", "penalty", buildControllerStyleEvent)
|
||||
recordProgressiveAction(config, state, "w1", "penalty", buildControllerStyleEvent)
|
||||
recordProgressiveAction(config, state, "w1", "penalty", buildControllerStyleEvent)
|
||||
|
||||
expect(state.events.map((eventRecord) => [eventRecord.participantKey, eventRecord.actionKey])).toEqual([
|
||||
["w1", "penalty"],
|
||||
["w2", "plus_1"],
|
||||
["w1", "penalty"],
|
||||
["w2", "plus_1"],
|
||||
["w1", "penalty"],
|
||||
["w2", "plus_2"]
|
||||
])
|
||||
})
|
||||
|
||||
it("applies a normal match action by creating one event", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
const applied = applyMatchAction(config, state, config.phaseSequence[0], 118, "w1", "takedown_3")
|
||||
|
||||
expect(applied).toBe(true)
|
||||
expect(state.events).toHaveLength(1)
|
||||
expect(state.events[0]).toMatchObject({
|
||||
phaseKey: "period_1",
|
||||
clockSeconds: 118,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
})
|
||||
})
|
||||
|
||||
it("applies a progressive action by creating offense and linked award events when earned", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
|
||||
expect(applyMatchAction(config, state, config.phaseSequence[0], 118, "w1", "stalling")).toBe(true)
|
||||
expect(applyMatchAction(config, state, config.phaseSequence[0], 110, "w1", "stalling")).toBe(true)
|
||||
|
||||
expect(state.events.map((eventRecord) => [eventRecord.participantKey, eventRecord.actionKey])).toEqual([
|
||||
["w1", "stalling"],
|
||||
["w1", "stalling"],
|
||||
["w2", "plus_1"]
|
||||
])
|
||||
})
|
||||
|
||||
it("applies a defer choice without storing a final selection", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const choicePhase = config.phaseSequence[1]
|
||||
|
||||
const result = applyChoiceAction(state, choicePhase, 0, "w1", "defer")
|
||||
|
||||
expect(result).toEqual({ applied: true, deferred: true })
|
||||
expect(state.events).toHaveLength(1)
|
||||
expect(state.events[0].actionKey).toBe("choice_defer")
|
||||
expect(state.selections).toEqual({})
|
||||
})
|
||||
|
||||
it("applies a non defer choice and stores the selection", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const choicePhase = config.phaseSequence[1]
|
||||
|
||||
const result = applyChoiceAction(state, choicePhase, 0, "w2", "bottom")
|
||||
|
||||
expect(result).toEqual({ applied: true, deferred: false })
|
||||
expect(state.events[0].actionKey).toBe("choice_bottom")
|
||||
expect(state.selections.choice_1).toEqual({ participantKey: "w2", choiceKey: "bottom" })
|
||||
})
|
||||
|
||||
it("deleting a timer-used event restores timer time", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.timers.w1.injury.remainingSeconds = 60
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
participantKey: "w1",
|
||||
actionKey: "timer_used_injury",
|
||||
elapsedSeconds: 20
|
||||
})
|
||||
]
|
||||
|
||||
const deleted = deleteEventFromState(config, state, 1)
|
||||
|
||||
expect(deleted).toBe(true)
|
||||
expect(state.events).toEqual([])
|
||||
expect(state.timers.w1.injury.remainingSeconds).toBe(80)
|
||||
})
|
||||
|
||||
it("swapping a timer-used event moves the used time to the other wrestler", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.timers.w1.blood.remainingSeconds = 240
|
||||
state.timers.w2.blood.remainingSeconds = 260
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
participantKey: "w1",
|
||||
actionKey: "timer_used_blood",
|
||||
elapsedSeconds: 30
|
||||
})
|
||||
]
|
||||
|
||||
const swapped = swapEventParticipants(config, state, 1)
|
||||
|
||||
expect(swapped).toBe(true)
|
||||
expect(state.events[0].participantKey).toBe("w2")
|
||||
expect(state.timers.w1.blood.remainingSeconds).toBe(270)
|
||||
expect(state.timers.w2.blood.remainingSeconds).toBe(230)
|
||||
})
|
||||
|
||||
it("swapping a whole period flips all participants in that period", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.events = [
|
||||
buildEvent({
|
||||
id: 1,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
}),
|
||||
buildEvent({
|
||||
id: 2,
|
||||
participantKey: "w2",
|
||||
actionKey: "escape_1",
|
||||
clockSeconds: 80
|
||||
}),
|
||||
buildEvent({
|
||||
id: 3,
|
||||
phaseKey: "choice_1",
|
||||
phaseLabel: "Choice 1",
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_defer",
|
||||
clockSeconds: 0
|
||||
})
|
||||
]
|
||||
|
||||
const swapped = swapPhaseParticipants(config, state, "period_1")
|
||||
|
||||
expect(swapped).toBe(true)
|
||||
expect(state.events.slice(0, 2).map((eventRecord) => eventRecord.participantKey)).toEqual(["w2", "w1"])
|
||||
expect(state.events[2].participantKey).toBe("w1")
|
||||
})
|
||||
|
||||
it("starts, stops, and adjusts a running match clock", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const activeClock = state.clocksByPhase.period_1
|
||||
|
||||
expect(startClockState(activeClock, 1_000)).toBe(true)
|
||||
expect(activeClock.running).toBe(true)
|
||||
expect(activeClock.startedAt).toBe(1_000)
|
||||
|
||||
expect(adjustClockState(activeClock, -10, 6_000)).toBe(true)
|
||||
expect(activeClock.remainingSeconds).toBe(105)
|
||||
expect(activeClock.startedAt).toBe(6_000)
|
||||
|
||||
expect(stopClockState(activeClock, 11_000)).toBe(true)
|
||||
expect(activeClock.running).toBe(false)
|
||||
expect(activeClock.remainingSeconds).toBe(100)
|
||||
expect(syncClockSnapshot(activeClock)).toEqual({
|
||||
durationSeconds: 120,
|
||||
remainingSeconds: 100,
|
||||
running: false,
|
||||
startedAt: null
|
||||
})
|
||||
})
|
||||
|
||||
it("starts and stops an auxiliary timer with elapsed time", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const timerState = buildInitialState(config).timers.w1.injury
|
||||
|
||||
expect(startAuxiliaryTimerState(timerState, 2_000)).toBe(true)
|
||||
const result = stopAuxiliaryTimerState(timerState, 17_000)
|
||||
|
||||
expect(result).toEqual({ stopped: true, elapsedSeconds: 15 })
|
||||
expect(timerState.running).toBe(false)
|
||||
expect(timerState.remainingSeconds).toBe(75)
|
||||
})
|
||||
|
||||
it("derives match result defaults from score and overtime context", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.participantScores = { w1: 4, w2: 2 }
|
||||
|
||||
const defaults = matchResultDefaults(state, {
|
||||
w1Id: 11,
|
||||
w2Id: 22,
|
||||
currentPhase: config.phaseSequence.find((phase) => phase.key === "sv_1"),
|
||||
accumulationSeconds: 83
|
||||
})
|
||||
|
||||
expect(defaults).toEqual({
|
||||
winnerId: 11,
|
||||
overtimeType: "SV-1",
|
||||
winnerScore: 4,
|
||||
loserScore: 2,
|
||||
pinMinutes: 1,
|
||||
pinSeconds: 23
|
||||
})
|
||||
})
|
||||
|
||||
it("moves between phases and resets control to the new phase base", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.control = "w1_control"
|
||||
|
||||
expect(moveToNextPhase(config, state)).toBe(true)
|
||||
expect(state.phaseIndex).toBe(1)
|
||||
|
||||
state.selections.choice_1 = { participantKey: "w2", choiceKey: "bottom" }
|
||||
expect(moveToNextPhase(config, state)).toBe(true)
|
||||
expect(state.phaseIndex).toBe(2)
|
||||
expect(state.control).toBe("w1_control")
|
||||
|
||||
expect(moveToPreviousPhase(config, state)).toBe(true)
|
||||
expect(state.phaseIndex).toBe(1)
|
||||
})
|
||||
|
||||
it("finds the active clock for a timed phase and reports running state", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const periodOne = config.phaseSequence[0]
|
||||
const choiceOne = config.phaseSequence[1]
|
||||
|
||||
expect(activeClockForPhase(state, periodOne)).toBe(state.clocksByPhase.period_1)
|
||||
expect(activeClockForPhase(state, choiceOne)).toBe(null)
|
||||
expect(hasRunningClockOrTimer(state)).toBe(false)
|
||||
|
||||
state.clocksByPhase.period_1.running = true
|
||||
expect(hasRunningClockOrTimer(state)).toBe(true)
|
||||
})
|
||||
|
||||
it("stops all running auxiliary timers in place", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.timers.w1.blood.running = true
|
||||
state.timers.w1.blood.startedAt = 1_000
|
||||
state.timers.w1.blood.remainingSeconds = 300
|
||||
state.timers.w2.injury.running = true
|
||||
state.timers.w2.injury.startedAt = 5_000
|
||||
state.timers.w2.injury.remainingSeconds = 90
|
||||
|
||||
stopAllAuxiliaryTimers(state, 11_000)
|
||||
|
||||
expect(state.timers.w1.blood).toMatchObject({ running: false, startedAt: null, remainingSeconds: 290 })
|
||||
expect(state.timers.w2.injury).toMatchObject({ running: false, startedAt: null, remainingSeconds: 84 })
|
||||
})
|
||||
})
|
||||
94
test/javascript/match_state/presenters.test.js
Normal file
94
test/javascript/match_state/presenters.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
import {
|
||||
buttonClassForParticipant,
|
||||
choiceViewModel,
|
||||
displayLabelForParticipant,
|
||||
eventLogSections,
|
||||
humanizeChoice
|
||||
} from "match-state-presenters"
|
||||
|
||||
describe("match state presenters", () => {
|
||||
it("maps assignment to display labels and button classes", () => {
|
||||
const assignment = { w1: "green", w2: "red" }
|
||||
|
||||
expect(displayLabelForParticipant(assignment, "w1")).toBe("Green")
|
||||
expect(displayLabelForParticipant(assignment, "w2")).toBe("Red")
|
||||
expect(buttonClassForParticipant(assignment, "w1")).toBe("btn-success")
|
||||
expect(buttonClassForParticipant(assignment, "w2")).toBe("btn-danger")
|
||||
expect(humanizeChoice("defer")).toBe("Defer")
|
||||
})
|
||||
|
||||
it("builds choice view model with defer blocking another defer", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
const phase = config.phaseSequence[1]
|
||||
state.events = [{
|
||||
id: 1,
|
||||
phaseKey: phase.key,
|
||||
phaseLabel: phase.label,
|
||||
clockSeconds: 0,
|
||||
participantKey: "w1",
|
||||
actionKey: "choice_defer"
|
||||
}]
|
||||
|
||||
const viewModel = choiceViewModel(config, state, phase, {
|
||||
w1: { name: "Wrestler 1" },
|
||||
w2: { name: "Wrestler 2" }
|
||||
})
|
||||
|
||||
expect(viewModel.label).toBe("Choose wrestler and position")
|
||||
expect(viewModel.selectionText).toContain("Green deferred")
|
||||
expect(viewModel.buttons.map((button) => [button.participantKey, button.choiceKey])).toEqual([
|
||||
["w2", "top"],
|
||||
["w2", "bottom"],
|
||||
["w2", "neutral"]
|
||||
])
|
||||
})
|
||||
|
||||
it("builds event log sections with formatted action labels", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.events = [
|
||||
{
|
||||
id: 1,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 100,
|
||||
participantKey: "w1",
|
||||
actionKey: "takedown_3"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
phaseKey: "period_1",
|
||||
phaseLabel: "Period 1",
|
||||
clockSeconds: 80,
|
||||
participantKey: "w2",
|
||||
actionKey: "timer_used_blood",
|
||||
elapsedSeconds: 15
|
||||
}
|
||||
]
|
||||
|
||||
const sections = eventLogSections(config, state, (seconds) => `F-${seconds}`)
|
||||
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0].label).toBe("Period 1")
|
||||
expect(sections[0].items).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
participantKey: "w2",
|
||||
colorLabel: "Red",
|
||||
actionLabel: "Blood Time Used: F-15",
|
||||
clockLabel: "F-80"
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
participantKey: "w1",
|
||||
colorLabel: "Green",
|
||||
actionLabel: "Takedown +3",
|
||||
clockLabel: "F-100"
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
227
test/javascript/match_state/scoreboard_presenters.test.js
Normal file
227
test/javascript/match_state/scoreboard_presenters.test.js
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
import {
|
||||
boardColors,
|
||||
buildRunningTimerSnapshot,
|
||||
emptyBoardViewModel,
|
||||
currentClockText,
|
||||
detectRecentlyStoppedTimer,
|
||||
mainClockRunning,
|
||||
nextTimerBannerState,
|
||||
participantDisplayLabel,
|
||||
participantForColor,
|
||||
populatedBoardViewModel,
|
||||
timerBannerRenderState,
|
||||
timerBannerViewModel,
|
||||
timerIndicatorLabel
|
||||
} from "match-state-scoreboard-presenters"
|
||||
|
||||
describe("match state scoreboard presenters", () => {
|
||||
it("maps colors to participants and labels", () => {
|
||||
const state = {
|
||||
assignment: { w1: "red", w2: "green" },
|
||||
metadata: { w1Name: "Alpha", w2Name: "Bravo" }
|
||||
}
|
||||
|
||||
expect(participantForColor(state, "red")).toBe("w1")
|
||||
expect(participantForColor(state, "green")).toBe("w2")
|
||||
expect(participantDisplayLabel(state, "w1")).toBe("Red Alpha")
|
||||
expect(participantDisplayLabel(state, "w2")).toBe("Green Bravo")
|
||||
})
|
||||
|
||||
it("formats the current clock from running phase state", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.phaseIndex = 0
|
||||
state.clocksByPhase.period_1.running = true
|
||||
state.clocksByPhase.period_1.startedAt = 1_000
|
||||
state.clocksByPhase.period_1.remainingSeconds = 120
|
||||
|
||||
expect(currentClockText(config, state, (seconds) => `F-${seconds}`, 11_000)).toBe("F-110")
|
||||
expect(mainClockRunning(config, state)).toBe(true)
|
||||
})
|
||||
|
||||
it("builds timer indicator and banner view models from running timers", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.metadata = { w1Name: "Alpha", w2Name: "Bravo" }
|
||||
state.timers.w1.blood.running = true
|
||||
state.timers.w1.blood.startedAt = 1_000
|
||||
state.timers.w1.blood.remainingSeconds = 300
|
||||
|
||||
expect(timerIndicatorLabel(config, state, "w1", (seconds) => `F-${seconds}`, 21_000)).toBe("Blood: F-20")
|
||||
|
||||
const banner = timerBannerViewModel(config, state, { participantKey: "w1", timerKey: "blood", expiresAt: null }, (seconds) => `F-${seconds}`, 21_000)
|
||||
expect(banner).toEqual({
|
||||
color: "green",
|
||||
label: "Green Alpha Blood Running",
|
||||
clockText: "F-20"
|
||||
})
|
||||
})
|
||||
|
||||
it("detects recently stopped timers from the snapshot", () => {
|
||||
const state = {
|
||||
timers: {
|
||||
w1: { blood: { running: false } },
|
||||
w2: { injury: { running: true } }
|
||||
}
|
||||
}
|
||||
const snapshot = {
|
||||
"w1:blood": true,
|
||||
"w2:injury": true
|
||||
}
|
||||
|
||||
expect(detectRecentlyStoppedTimer(state, snapshot)).toEqual({ participantKey: "w1", timerKey: "blood" })
|
||||
expect(buildRunningTimerSnapshot(state)).toEqual({
|
||||
"w1:blood": false,
|
||||
"w2:injury": true
|
||||
})
|
||||
})
|
||||
|
||||
it("builds populated and empty board view models", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.metadata = {
|
||||
w1Name: "Alpha",
|
||||
w2Name: "Bravo",
|
||||
w1School: "School A",
|
||||
w2School: "School B",
|
||||
weightLabel: "106"
|
||||
}
|
||||
state.participantScores = { w1: 4, w2: 1 }
|
||||
state.assignment = { w1: "green", w2: "red" }
|
||||
|
||||
const populated = populatedBoardViewModel(
|
||||
config,
|
||||
state,
|
||||
{ w1_stat: "T3", w2_stat: "E1" },
|
||||
1001,
|
||||
(seconds) => `F-${seconds}`
|
||||
)
|
||||
|
||||
expect(populated).toMatchObject({
|
||||
isEmpty: false,
|
||||
redName: "Bravo",
|
||||
redSchool: "School B",
|
||||
redScore: "1",
|
||||
greenName: "Alpha",
|
||||
greenSchool: "School A",
|
||||
greenScore: "4",
|
||||
weightLabel: "Weight 106",
|
||||
boutLabel: "Bout 1001",
|
||||
redStats: "E1",
|
||||
greenStats: "T3"
|
||||
})
|
||||
|
||||
expect(emptyBoardViewModel(1002, "Last result")).toEqual({
|
||||
isEmpty: true,
|
||||
redName: "NO MATCH",
|
||||
redSchool: "",
|
||||
redScore: "0",
|
||||
redTimerIndicator: "",
|
||||
greenName: "NO MATCH",
|
||||
greenSchool: "",
|
||||
greenScore: "0",
|
||||
greenTimerIndicator: "",
|
||||
clockText: "-",
|
||||
phaseLabel: "No Match",
|
||||
weightLabel: "Weight -",
|
||||
boutLabel: "Bout 1002",
|
||||
redStats: "",
|
||||
greenStats: "",
|
||||
lastMatchResult: "Last result"
|
||||
})
|
||||
})
|
||||
|
||||
it("builds next timer banner state for running and recently stopped timers", () => {
|
||||
const runningState = {
|
||||
timers: {
|
||||
w1: { blood: { running: true } },
|
||||
w2: {}
|
||||
}
|
||||
}
|
||||
|
||||
expect(nextTimerBannerState(runningState, {})).toEqual({
|
||||
timerBannerState: {
|
||||
participantKey: "w1",
|
||||
timerKey: "blood",
|
||||
expiresAt: null
|
||||
},
|
||||
previousTimerSnapshot: {
|
||||
"w1:blood": true
|
||||
}
|
||||
})
|
||||
|
||||
const stoppedState = {
|
||||
timers: {
|
||||
w1: { blood: { running: false } },
|
||||
w2: {}
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedResult = nextTimerBannerState(stoppedState, { "w1:blood": true }, 5_000)
|
||||
expect(stoppedResult).toEqual({
|
||||
timerBannerState: {
|
||||
participantKey: "w1",
|
||||
timerKey: "blood",
|
||||
expiresAt: 15_000
|
||||
},
|
||||
previousTimerSnapshot: {
|
||||
"w1:blood": false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("builds board colors and timer banner render decisions", () => {
|
||||
expect(boardColors(true)).toEqual({
|
||||
red: "#000",
|
||||
center: "#000",
|
||||
green: "#000"
|
||||
})
|
||||
expect(boardColors(false)).toEqual({
|
||||
red: "#c91f1f",
|
||||
center: "#050505",
|
||||
green: "#1cab2d"
|
||||
})
|
||||
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.metadata = { w1Name: "Alpha", w2Name: "Bravo" }
|
||||
state.timers.w1.blood.running = true
|
||||
state.timers.w1.blood.startedAt = 1_000
|
||||
state.timers.w1.blood.remainingSeconds = 300
|
||||
|
||||
expect(timerBannerRenderState(
|
||||
config,
|
||||
state,
|
||||
{ participantKey: "w1", timerKey: "blood", expiresAt: null },
|
||||
(seconds) => `F-${seconds}`,
|
||||
11_000
|
||||
)).toEqual({
|
||||
timerBannerState: { participantKey: "w1", timerKey: "blood", expiresAt: null },
|
||||
visible: true,
|
||||
viewModel: {
|
||||
color: "green",
|
||||
label: "Green Alpha Blood Running",
|
||||
clockText: "F-10"
|
||||
}
|
||||
})
|
||||
|
||||
state.clocksByPhase.period_1.running = true
|
||||
state.clocksByPhase.period_1.startedAt = 1_000
|
||||
state.clocksByPhase.period_1.remainingSeconds = 120
|
||||
|
||||
expect(timerBannerRenderState(
|
||||
config,
|
||||
state,
|
||||
{ participantKey: "w1", timerKey: "blood", expiresAt: 20_000 },
|
||||
(seconds) => `F-${seconds}`,
|
||||
11_000
|
||||
)).toEqual({
|
||||
timerBannerState: null,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
})
|
||||
})
|
||||
})
|
||||
307
test/javascript/match_state/scoreboard_state.test.js
Normal file
307
test/javascript/match_state/scoreboard_state.test.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
buildScoreboardContext,
|
||||
connectionPlan,
|
||||
applyMatchPayloadContext,
|
||||
applyMatPayloadContext,
|
||||
applyStatePayloadContext,
|
||||
extractLiveMatchData,
|
||||
matchStorageKey,
|
||||
selectedBoutNumber,
|
||||
storageChangePlan,
|
||||
selectedBoutStorageKey
|
||||
} from "match-state-scoreboard-state"
|
||||
|
||||
describe("match state scoreboard state helpers", () => {
|
||||
it("builds the default scoreboard controller context", () => {
|
||||
expect(buildScoreboardContext({ initialBoutNumber: 1001, matchId: 55 })).toEqual({
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentBoutNumber: 1001,
|
||||
currentMatchId: 55,
|
||||
liveMatchData: {},
|
||||
lastMatchResult: "",
|
||||
state: null,
|
||||
finished: false,
|
||||
timerBannerState: null,
|
||||
previousTimerSnapshot: {}
|
||||
})
|
||||
})
|
||||
|
||||
it("builds tournament-scoped storage keys", () => {
|
||||
expect(selectedBoutStorageKey(4, 2)).toBe("mat-selected-bout:4:2")
|
||||
expect(matchStorageKey(4, 1007)).toBe("match-state:4:1007")
|
||||
expect(matchStorageKey(4, null)).toBe(null)
|
||||
})
|
||||
|
||||
it("builds connection plans by source mode", () => {
|
||||
expect(connectionPlan("localstorage", 11)).toEqual({
|
||||
useStorageListener: true,
|
||||
subscribeMat: true,
|
||||
subscribeMatch: false,
|
||||
matchId: null,
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true
|
||||
})
|
||||
|
||||
expect(connectionPlan("mat_websocket", 11)).toEqual({
|
||||
useStorageListener: false,
|
||||
subscribeMat: true,
|
||||
subscribeMatch: true,
|
||||
matchId: 11,
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false
|
||||
})
|
||||
|
||||
expect(connectionPlan("websocket", 11)).toEqual({
|
||||
useStorageListener: false,
|
||||
subscribeMat: false,
|
||||
subscribeMatch: true,
|
||||
matchId: 11,
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false
|
||||
})
|
||||
})
|
||||
|
||||
it("extracts live match fields from a websocket payload", () => {
|
||||
expect(extractLiveMatchData({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_name: "Alpha",
|
||||
finished: 1,
|
||||
ignored: "value"
|
||||
})).toEqual({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
win_type: "Decision",
|
||||
winner_name: "Alpha",
|
||||
finished: 1
|
||||
})
|
||||
})
|
||||
|
||||
it("applies scoreboard state payload context", () => {
|
||||
const context = applyStatePayloadContext(
|
||||
{ currentBoutNumber: 1001, finished: false, state: null },
|
||||
{
|
||||
metadata: { boutNumber: 1002 },
|
||||
matchResult: { finished: true }
|
||||
}
|
||||
)
|
||||
|
||||
expect(context.currentBoutNumber).toBe(1002)
|
||||
expect(context.finished).toBe(true)
|
||||
expect(context.state).toEqual({
|
||||
metadata: { boutNumber: 1002 },
|
||||
matchResult: { finished: true }
|
||||
})
|
||||
})
|
||||
|
||||
it("merges websocket match payload into current scoreboard context", () => {
|
||||
const currentContext = {
|
||||
currentBoutNumber: 1001,
|
||||
finished: false,
|
||||
liveMatchData: { w1_stat: "Old" },
|
||||
state: { metadata: { boutNumber: 1001 } }
|
||||
}
|
||||
|
||||
const nextContext = applyMatchPayloadContext(currentContext, {
|
||||
scoreboard_state: {
|
||||
metadata: { boutNumber: 1003 },
|
||||
matchResult: { finished: true }
|
||||
},
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
finished: 1
|
||||
})
|
||||
|
||||
expect(nextContext.currentBoutNumber).toBe(1003)
|
||||
expect(nextContext.finished).toBe(true)
|
||||
expect(nextContext.liveMatchData).toEqual({
|
||||
w1_stat: "T3",
|
||||
w2_stat: "E1",
|
||||
score: "3-1",
|
||||
finished: 1
|
||||
})
|
||||
expect(nextContext.state).toEqual({
|
||||
metadata: { boutNumber: 1003 },
|
||||
matchResult: { finished: true }
|
||||
})
|
||||
})
|
||||
|
||||
it("updates localstorage scoreboard context from mat payload", () => {
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "localstorage",
|
||||
currentQueueBoutNumber: null,
|
||||
lastMatchResult: "",
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: null,
|
||||
state: null,
|
||||
liveMatchData: {}
|
||||
},
|
||||
{
|
||||
queue1_bout_number: 1001,
|
||||
last_match_result: "Result text"
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext).toMatchObject({
|
||||
currentQueueBoutNumber: 1001,
|
||||
lastMatchResult: "Result text",
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
})
|
||||
})
|
||||
|
||||
it("uses the selected mat bout as the localstorage scoreboard fallback", () => {
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "localstorage",
|
||||
currentQueueBoutNumber: null,
|
||||
lastMatchResult: "",
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: null,
|
||||
state: null,
|
||||
liveMatchData: {}
|
||||
},
|
||||
{
|
||||
queue1_bout_number: 1001,
|
||||
selected_bout_number: 1003,
|
||||
last_match_result: ""
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext.currentQueueBoutNumber).toBe(1003)
|
||||
expect(nextContext.loadSelectedBout).toBe(true)
|
||||
expect(nextContext.loadLocalState).toBe(true)
|
||||
})
|
||||
|
||||
it("derives storage change instructions for selected bout and match state keys", () => {
|
||||
const context = { currentBoutNumber: 1001 }
|
||||
|
||||
expect(storageChangePlan(context, "mat-selected-bout:4:2", 4, 2)).toEqual({
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
})
|
||||
|
||||
expect(storageChangePlan(context, "match-state:4:1001", 4, 2)).toEqual({
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
})
|
||||
|
||||
expect(storageChangePlan(context, "other-key", 4, 2)).toEqual({
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false,
|
||||
renderNow: false
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers selected bout numbers and falls back to queue bout", () => {
|
||||
expect(selectedBoutNumber({ boutNumber: 1004 }, 1001)).toBe(1004)
|
||||
expect(selectedBoutNumber(null, 1001)).toBe(1001)
|
||||
})
|
||||
|
||||
it("clears websocket scoreboard context when the mat has no active match", () => {
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "mat_websocket",
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentMatchId: 10,
|
||||
currentBoutNumber: 1001,
|
||||
liveMatchData: { w1_stat: "T3" },
|
||||
state: { metadata: { boutNumber: 1001 } },
|
||||
lastMatchResult: ""
|
||||
},
|
||||
{
|
||||
queue1_bout_number: null,
|
||||
queue1_match_id: null,
|
||||
selected_bout_number: null,
|
||||
selected_match_id: null,
|
||||
last_match_result: "Last result"
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext).toMatchObject({
|
||||
currentQueueBoutNumber: null,
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: null,
|
||||
state: null,
|
||||
liveMatchData: {},
|
||||
lastMatchResult: "Last result",
|
||||
resetTimerBanner: true,
|
||||
unsubscribeMatch: true,
|
||||
subscribeMatchId: null,
|
||||
renderNow: true
|
||||
})
|
||||
})
|
||||
|
||||
it("switches websocket scoreboard subscriptions when the selected match changes", () => {
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "mat_websocket",
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentMatchId: 10,
|
||||
currentBoutNumber: 1001,
|
||||
liveMatchData: { w1_stat: "T3" },
|
||||
state: { metadata: { boutNumber: 1001 } },
|
||||
lastMatchResult: ""
|
||||
},
|
||||
{
|
||||
queue1_bout_number: 1001,
|
||||
queue1_match_id: 10,
|
||||
selected_bout_number: 1002,
|
||||
selected_match_id: 11,
|
||||
last_match_result: ""
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext).toMatchObject({
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentMatchId: 11,
|
||||
currentBoutNumber: 1002,
|
||||
state: null,
|
||||
liveMatchData: {},
|
||||
resetTimerBanner: true,
|
||||
subscribeMatchId: 11,
|
||||
renderNow: true
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps current websocket subscription when the selected match is unchanged", () => {
|
||||
const state = { metadata: { boutNumber: 1002 } }
|
||||
const liveMatchData = { w1_stat: "T3" }
|
||||
|
||||
const nextContext = applyMatPayloadContext(
|
||||
{
|
||||
sourceMode: "mat_websocket",
|
||||
currentQueueBoutNumber: 1001,
|
||||
currentMatchId: 11,
|
||||
currentBoutNumber: 1002,
|
||||
liveMatchData,
|
||||
state,
|
||||
lastMatchResult: ""
|
||||
},
|
||||
{
|
||||
queue1_bout_number: 1001,
|
||||
queue1_match_id: 10,
|
||||
selected_bout_number: 1002,
|
||||
selected_match_id: 11,
|
||||
last_match_result: "Result"
|
||||
}
|
||||
)
|
||||
|
||||
expect(nextContext.currentMatchId).toBe(11)
|
||||
expect(nextContext.currentBoutNumber).toBe(1002)
|
||||
expect(nextContext.state).toBe(state)
|
||||
expect(nextContext.liveMatchData).toBe(liveMatchData)
|
||||
expect(nextContext.subscribeMatchId).toBe(null)
|
||||
expect(nextContext.renderNow).toBe(false)
|
||||
expect(nextContext.lastMatchResult).toBe("Result")
|
||||
})
|
||||
})
|
||||
73
test/javascript/match_state/serializers.test.js
Normal file
73
test/javascript/match_state/serializers.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
import {
|
||||
buildMatchMetadata,
|
||||
buildPersistedState,
|
||||
buildStorageKey,
|
||||
restorePersistedState
|
||||
} from "match-state-serializers"
|
||||
|
||||
describe("match state serializers", () => {
|
||||
it("builds a tournament and bout scoped storage key", () => {
|
||||
expect(buildStorageKey(12, 1007)).toBe("match-state:12:1007")
|
||||
})
|
||||
|
||||
it("builds match metadata for persistence and scoreboard payloads", () => {
|
||||
expect(buildMatchMetadata({
|
||||
tournamentId: 1,
|
||||
boutNumber: 1001,
|
||||
weightLabel: "106",
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
w1Name: "W1",
|
||||
w2Name: "W2",
|
||||
w1School: "School 1",
|
||||
w2School: "School 2"
|
||||
})).toEqual({
|
||||
tournamentId: 1,
|
||||
boutNumber: 1001,
|
||||
weightLabel: "106",
|
||||
ruleset: "folkstyle_usa",
|
||||
bracketPosition: "Bracket Round of 64",
|
||||
w1Name: "W1",
|
||||
w2Name: "W2",
|
||||
w1School: "School 1",
|
||||
w2School: "School 2"
|
||||
})
|
||||
})
|
||||
|
||||
it("builds persisted state with metadata", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const state = buildInitialState(config)
|
||||
state.participantScores = { w1: 7, w2: 3 }
|
||||
|
||||
const persisted = buildPersistedState(state, { tournamentId: 1, boutNumber: 1001 })
|
||||
|
||||
expect(persisted.participantScores).toEqual({ w1: 7, w2: 3 })
|
||||
expect(persisted.metadata).toEqual({ tournamentId: 1, boutNumber: 1001 })
|
||||
})
|
||||
|
||||
it("restores persisted state over initial defaults", () => {
|
||||
const config = getMatchStateConfig("folkstyle_usa", "Bracket Round of 64")
|
||||
const restored = restorePersistedState(config, {
|
||||
participantScores: { w1: 4, w2: 1 },
|
||||
assignment: { w1: "red", w2: "green" },
|
||||
clocksByPhase: {
|
||||
period_1: { remainingSeconds: 30 }
|
||||
},
|
||||
timers: {
|
||||
w1: {
|
||||
injury: { remainingSeconds: 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(restored.participantScores).toEqual({ w1: 4, w2: 1 })
|
||||
expect(restored.assignment).toEqual({ w1: "red", w2: "green" })
|
||||
expect(restored.clocksByPhase.period_1.remainingSeconds).toBe(30)
|
||||
expect(restored.clocksByPhase.period_1.durationSeconds).toBe(120)
|
||||
expect(restored.timers.w1.injury.remainingSeconds).toBe(50)
|
||||
expect(restored.timers.w2.injury.remainingSeconds).toBe(90)
|
||||
})
|
||||
})
|
||||
56
test/javascript/match_state/transport.test.js
Normal file
56
test/javascript/match_state/transport.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
loadJson,
|
||||
performIfChanged,
|
||||
removeKey,
|
||||
saveJson
|
||||
} from "match-state-transport"
|
||||
|
||||
describe("match state transport", () => {
|
||||
it("loads saved json from storage", () => {
|
||||
const storage = {
|
||||
getItem: vi.fn(() => '{"score":3}')
|
||||
}
|
||||
|
||||
expect(loadJson(storage, "match-state:1:1001")).toEqual({ score: 3 })
|
||||
expect(storage.getItem).toHaveBeenCalledWith("match-state:1:1001")
|
||||
})
|
||||
|
||||
it("returns null when stored json is invalid", () => {
|
||||
const storage = {
|
||||
getItem: vi.fn(() => "{not-json")
|
||||
}
|
||||
|
||||
expect(loadJson(storage, "bad")).toBe(null)
|
||||
})
|
||||
|
||||
it("saves and removes json values in storage", () => {
|
||||
const storage = {
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
|
||||
expect(saveJson(storage, "key", { score: 5 })).toBe(true)
|
||||
expect(storage.setItem).toHaveBeenCalledWith("key", '{"score":5}')
|
||||
|
||||
expect(removeKey(storage, "key")).toBe(true)
|
||||
expect(storage.removeItem).toHaveBeenCalledWith("key")
|
||||
})
|
||||
|
||||
it("only performs subscription actions when the payload changes", () => {
|
||||
const subscription = {
|
||||
perform: vi.fn()
|
||||
}
|
||||
|
||||
const firstSerialized = performIfChanged(subscription, "send_stat", { new_w1_stat: "T3" }, null)
|
||||
const secondSerialized = performIfChanged(subscription, "send_stat", { new_w1_stat: "T3" }, firstSerialized)
|
||||
const thirdSerialized = performIfChanged(subscription, "send_stat", { new_w1_stat: "T3 E1" }, secondSerialized)
|
||||
|
||||
expect(subscription.perform).toHaveBeenCalledTimes(2)
|
||||
expect(subscription.perform).toHaveBeenNthCalledWith(1, "send_stat", { new_w1_stat: "T3" })
|
||||
expect(subscription.perform).toHaveBeenNthCalledWith(2, "send_stat", { new_w1_stat: "T3 E1" })
|
||||
expect(firstSerialized).toBe('{"new_w1_stat":"T3"}')
|
||||
expect(secondSerialized).toBe(firstSerialized)
|
||||
expect(thirdSerialized).toBe('{"new_w1_stat":"T3 E1"}')
|
||||
})
|
||||
})
|
||||
1
test/javascript/support/stimulus_stub.js
Normal file
1
test/javascript/support/stimulus_stub.js
Normal file
@@ -0,0 +1 @@
|
||||
export class Controller {}
|
||||
51
test/models/mat_scoreboard_broadcast_test.rb
Normal file
51
test/models/mat_scoreboard_broadcast_test.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
require "test_helper"
|
||||
require "json"
|
||||
|
||||
class MatScoreboardBroadcastTest < ActiveSupport::TestCase
|
||||
test "set_selected_scoreboard_match broadcasts updated payload" do
|
||||
mat = mats(:one)
|
||||
queue1_match = matches(:tournament_1_bout_1000)
|
||||
selected_match = matches(:tournament_1_bout_1001)
|
||||
mat.update!(queue1: queue1_match.id, queue2: selected_match.id, queue3: nil, queue4: nil)
|
||||
|
||||
stream = MatScoreboardChannel.broadcasting_for(mat)
|
||||
clear_streams(stream)
|
||||
|
||||
mat.set_selected_scoreboard_match!(selected_match)
|
||||
|
||||
payload = JSON.parse(broadcasts_for(stream).last)
|
||||
assert_equal mat.id, payload["mat_id"]
|
||||
assert_equal queue1_match.id, payload["queue1_match_id"]
|
||||
assert_equal queue1_match.bout_number, payload["queue1_bout_number"]
|
||||
assert_equal selected_match.id, payload["selected_match_id"]
|
||||
assert_equal selected_match.bout_number, payload["selected_bout_number"]
|
||||
end
|
||||
|
||||
test "set_last_match_result broadcasts updated payload" do
|
||||
mat = mats(:one)
|
||||
queue1_match = matches(:tournament_1_bout_1000)
|
||||
mat.update!(queue1: queue1_match.id, queue2: nil, queue3: nil, queue4: nil)
|
||||
|
||||
stream = MatScoreboardChannel.broadcasting_for(mat)
|
||||
clear_streams(stream)
|
||||
|
||||
mat.set_last_match_result!("106 lbs - Winner Decision Loser 3-1")
|
||||
|
||||
payload = JSON.parse(broadcasts_for(stream).last)
|
||||
assert_equal "106 lbs - Winner Decision Loser 3-1", payload["last_match_result"]
|
||||
assert_equal queue1_match.id, payload["queue1_match_id"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcasts_for(stream)
|
||||
ActionCable.server.pubsub.broadcasts(stream)
|
||||
end
|
||||
|
||||
def clear_streams(*streams)
|
||||
ActionCable.server.pubsub.clear
|
||||
streams.each do |stream|
|
||||
broadcasts_for(stream).clear
|
||||
end
|
||||
end
|
||||
end
|
||||
85
test/models/mat_scoreboard_state_test.rb
Normal file
85
test/models/mat_scoreboard_state_test.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
require "test_helper"
|
||||
|
||||
class MatScoreboardStateTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@mat = mats(:one)
|
||||
@queue1_match = matches(:tournament_1_bout_1000)
|
||||
@queue2_match = matches(:tournament_1_bout_1001)
|
||||
Rails.cache.clear
|
||||
@mat.update!(queue1: @queue1_match.id, queue2: @queue2_match.id, queue3: nil, queue4: nil)
|
||||
end
|
||||
|
||||
test "scoreboard_payload falls back to queue1 when no selected match exists" do
|
||||
payload = @mat.scoreboard_payload
|
||||
|
||||
assert_equal @mat.id, payload[:mat_id]
|
||||
assert_equal @queue1_match.id, payload[:queue1_match_id]
|
||||
assert_equal @queue1_match.bout_number, payload[:queue1_bout_number]
|
||||
assert_nil payload[:selected_match_id]
|
||||
assert_nil payload[:selected_bout_number]
|
||||
assert_nil payload[:last_match_result]
|
||||
end
|
||||
|
||||
test "selected_scoreboard_match clears stale cached match ids" do
|
||||
stale_match = matches(:tournament_1_bout_2000)
|
||||
@mat.set_selected_scoreboard_match!(stale_match)
|
||||
|
||||
assert_nil @mat.selected_scoreboard_match
|
||||
assert_nil Rails.cache.read("tournament:#{@mat.tournament_id}:mat:#{@mat.id}:scoreboard_selection")
|
||||
end
|
||||
|
||||
test "scoreboard_payload returns selected match when selection is valid" do
|
||||
@mat.set_selected_scoreboard_match!(@queue2_match)
|
||||
|
||||
payload = @mat.scoreboard_payload
|
||||
|
||||
assert_equal @queue2_match.id, payload[:selected_match_id]
|
||||
assert_equal @queue2_match.bout_number, payload[:selected_bout_number]
|
||||
end
|
||||
|
||||
test "scoreboard_payload includes last match result when present" do
|
||||
@mat.set_last_match_result!("106 lbs - Winner Decision Loser 3-1")
|
||||
|
||||
payload = @mat.scoreboard_payload
|
||||
|
||||
assert_equal "106 lbs - Winner Decision Loser 3-1", payload[:last_match_result]
|
||||
end
|
||||
|
||||
test "scoreboard_payload handles empty queue" do
|
||||
@mat.update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
|
||||
|
||||
payload = @mat.scoreboard_payload
|
||||
|
||||
assert_nil payload[:queue1_match_id]
|
||||
assert_nil payload[:queue1_bout_number]
|
||||
assert_nil payload[:selected_match_id]
|
||||
assert_nil payload[:selected_bout_number]
|
||||
end
|
||||
|
||||
test "scoreboard_payload falls back to new queue1 after selected queue1 leaves the queue" do
|
||||
@mat.set_selected_scoreboard_match!(@queue1_match)
|
||||
@queue1_match.update!(winner_id: @queue1_match.w1, win_type: "Decision", score: "3-1", finished: 1)
|
||||
|
||||
@mat.advance_queue!(@queue1_match)
|
||||
payload = @mat.reload.scoreboard_payload
|
||||
|
||||
assert_equal @queue2_match.id, payload[:queue1_match_id]
|
||||
assert_equal @queue2_match.bout_number, payload[:queue1_bout_number]
|
||||
assert_nil payload[:selected_match_id]
|
||||
assert_nil payload[:selected_bout_number]
|
||||
assert_nil Rails.cache.read("tournament:#{@mat.tournament_id}:mat:#{@mat.id}:scoreboard_selection")
|
||||
end
|
||||
|
||||
test "scoreboard_payload keeps selected match when queue advances and selection remains queued" do
|
||||
@mat.set_selected_scoreboard_match!(@queue2_match)
|
||||
@queue1_match.update!(winner_id: @queue1_match.w1, win_type: "Decision", score: "3-1", finished: 1)
|
||||
|
||||
@mat.advance_queue!(@queue1_match)
|
||||
payload = @mat.reload.scoreboard_payload
|
||||
|
||||
assert_equal @queue2_match.id, payload[:queue1_match_id]
|
||||
assert_equal @queue2_match.bout_number, payload[:queue1_bout_number]
|
||||
assert_equal @queue2_match.id, payload[:selected_match_id]
|
||||
assert_equal @queue2_match.bout_number, payload[:selected_bout_number]
|
||||
end
|
||||
end
|
||||
21
vitest.config.js
Normal file
21
vitest.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/javascript/**/*.test.js"]
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@hotwired/stimulus": path.resolve("test/javascript/support/stimulus_stub.js"),
|
||||
"match-state-config": path.resolve("app/assets/javascripts/lib/match_state/config.js"),
|
||||
"match-state-engine": path.resolve("app/assets/javascripts/lib/match_state/engine.js"),
|
||||
"match-state-serializers": path.resolve("app/assets/javascripts/lib/match_state/serializers.js"),
|
||||
"match-state-presenters": path.resolve("app/assets/javascripts/lib/match_state/presenters.js"),
|
||||
"match-state-transport": path.resolve("app/assets/javascripts/lib/match_state/transport.js"),
|
||||
"match-state-scoreboard-presenters": path.resolve("app/assets/javascripts/lib/match_state/scoreboard_presenters.js"),
|
||||
"match-state-scoreboard-state": path.resolve("app/assets/javascripts/lib/match_state/scoreboard_state.js")
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user