1
0
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:
2026-04-10 21:08:03 -04:00
parent 7526148ba5
commit 970f38ed14
60 changed files with 9650 additions and 148 deletions

6
.gitignore vendored
View File

@@ -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
View 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.

View File

@@ -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);

View 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
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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")}`
}
}

View File

@@ -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'
}
}
}
}

View 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
})
}
}

View 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
}
}

View 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_")
)
}

View 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
}

View 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
}
}

View 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
}

View 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] || {})
}
])
)
}
}

View 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
}

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View 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>

View File

@@ -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 -->

View 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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,7 @@
<%= render "matches/scoreboard",
source_mode: "localstorage",
display_mode: "fullscreen",
show_mat_banner: true,
mat: @mat,
match: @match,
tournament: @tournament %>

View 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 %>

View 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>

View 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 %>

View File

@@ -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 %>

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View 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"
}
}

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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("")
})
})

View 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")
})
})

View 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)
})
})

View 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 }
})
})
})

View 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)
})
})

View 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 })
})
})

View 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 })
})
})

View 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"
}
])
})
})

View 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
})
})
})

View 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")
})
})

View 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)
})
})

View 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"}')
})
})

View File

@@ -0,0 +1 @@
export class Controller {}

View 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

View 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
View 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")
}
}
})