1
0
mirror of https://github.com/jcwimer/wrestlingApp synced 2026-04-14 17:06:46 +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

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