Add game center, per-model shuffle, audio focus fixes, README, tests
- README.md with build/architecture overview - Game Center screen with at-bat timeline, pitch sequence, spray chart, and strike zone component views - VideoShuffle service: per-model bucketed random selection with no-back-to-back guarantee; replaces flat shuffle-bag approach - Refresh JWT token for authenticated NSFW feed; add josie-hamming-2 and dani-speegle-2 to the user list - MultiStreamView audio focus: remove redundant isMuted writes during startStream and playNextWerkoutClip so audio stops ducking during clip transitions; gate AVAudioSession.setCategory(.playback) behind a one-shot flag - GamesViewModel.attachPlayer: skip mute recalculation when the same player is re-attached (prevents toggle flicker on item replace) - mlbTVOSTests target wired through project.yml with GENERATE_INFOPLIST_FILE; VideoShuffleTests covers groupByModel, pickRandomFromBuckets, real-distribution no-back-to-back invariant, and uniform model distribution over 6000 picks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
252
mlbTVOSTests/VideoShuffleTests.swift
Normal file
252
mlbTVOSTests/VideoShuffleTests.swift
Normal file
@@ -0,0 +1,252 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import mlbTVOS
|
||||
|
||||
struct VideoShuffleTests {
|
||||
// MARK: - Helpers
|
||||
|
||||
private struct Clip: Equatable, Hashable {
|
||||
let model: String
|
||||
let id: Int
|
||||
}
|
||||
|
||||
private static func makeClips(_ counts: [String: Int]) -> [Clip] {
|
||||
var result: [Clip] = []
|
||||
var idCounter = 0
|
||||
for (model, count) in counts.sorted(by: { $0.key < $1.key }) {
|
||||
for _ in 0..<count {
|
||||
result.append(Clip(model: model, id: idCounter))
|
||||
idCounter += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Returns the length of the longest contiguous run of same-model clips.
|
||||
private static func maxRunLength(_ clips: [Clip]) -> Int {
|
||||
guard !clips.isEmpty else { return 0 }
|
||||
var maxRun = 1
|
||||
var currentRun = 1
|
||||
for i in 1..<clips.count {
|
||||
if clips[i].model == clips[i - 1].model {
|
||||
currentRun += 1
|
||||
maxRun = max(maxRun, currentRun)
|
||||
} else {
|
||||
currentRun = 1
|
||||
}
|
||||
}
|
||||
return maxRun
|
||||
}
|
||||
|
||||
/// A deterministic RNG so tests that depend on randomness are reproducible.
|
||||
private struct SeededRNG: RandomNumberGenerator {
|
||||
private var state: UInt64
|
||||
init(seed: UInt64) { self.state = seed == 0 ? 0xdeadbeef : seed }
|
||||
mutating func next() -> UInt64 {
|
||||
state ^= state << 13
|
||||
state ^= state >> 7
|
||||
state ^= state << 17
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - groupByModel
|
||||
|
||||
@Test func groupByModelBucketsCorrectly() {
|
||||
let clips = Self.makeClips(["a": 3, "b": 2, "c": 1])
|
||||
var rng = SeededRNG(seed: 1)
|
||||
let groups = VideoShuffle.groupByModel(
|
||||
clips,
|
||||
keyFor: { $0.model },
|
||||
using: &rng
|
||||
)
|
||||
#expect(groups["a"]?.count == 3)
|
||||
#expect(groups["b"]?.count == 2)
|
||||
#expect(groups["c"]?.count == 1)
|
||||
#expect(groups.count == 3)
|
||||
}
|
||||
|
||||
@Test func groupByModelHandlesEmptyInput() {
|
||||
var rng = SeededRNG(seed: 1)
|
||||
let groups = VideoShuffle.groupByModel(
|
||||
[Clip](),
|
||||
keyFor: { $0.model },
|
||||
using: &rng
|
||||
)
|
||||
#expect(groups.isEmpty)
|
||||
}
|
||||
|
||||
@Test func groupByModelNilKeysGoToEmptyStringBucket() {
|
||||
struct Item { let key: String?; let id: Int }
|
||||
let items = [
|
||||
Item(key: "a", id: 0),
|
||||
Item(key: nil, id: 1),
|
||||
Item(key: nil, id: 2),
|
||||
]
|
||||
var rng = SeededRNG(seed: 1)
|
||||
let groups = VideoShuffle.groupByModel(
|
||||
items,
|
||||
keyFor: { $0.key },
|
||||
using: &rng
|
||||
)
|
||||
#expect(groups["a"]?.count == 1)
|
||||
#expect(groups[""]?.count == 2)
|
||||
}
|
||||
|
||||
// MARK: - pickRandomFromBuckets
|
||||
|
||||
@Test func pickRandomFromBucketsReturnsNilWhenAllEmpty() {
|
||||
let buckets: [String: [Clip]] = ["a": [], "b": []]
|
||||
var rng = SeededRNG(seed: 1)
|
||||
let result = VideoShuffle.pickRandomFromBuckets(
|
||||
buckets,
|
||||
using: &rng
|
||||
)
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test func pickRandomFromBucketsRemovesPickedItem() {
|
||||
let buckets: [String: [Clip]] = [
|
||||
"a": [Clip(model: "a", id: 0), Clip(model: "a", id: 1)],
|
||||
"b": [Clip(model: "b", id: 2)],
|
||||
]
|
||||
var rng = SeededRNG(seed: 1)
|
||||
let result = VideoShuffle.pickRandomFromBuckets(
|
||||
buckets,
|
||||
using: &rng
|
||||
)
|
||||
#expect(result != nil)
|
||||
guard let pick = result else { return }
|
||||
let originalCount = buckets.values.reduce(0) { $0 + $1.count }
|
||||
let remainingCount = pick.remaining.values.reduce(0) { $0 + $1.count }
|
||||
#expect(remainingCount == originalCount - 1)
|
||||
}
|
||||
|
||||
@Test func pickRandomFromBucketsRespectsExclusion() {
|
||||
// With two non-empty buckets, excluding "a" should always return "b".
|
||||
let buckets: [String: [Clip]] = [
|
||||
"a": [Clip(model: "a", id: 0), Clip(model: "a", id: 1)],
|
||||
"b": [Clip(model: "b", id: 2), Clip(model: "b", id: 3)],
|
||||
]
|
||||
var rng = SeededRNG(seed: 1)
|
||||
for _ in 0..<20 {
|
||||
let result = VideoShuffle.pickRandomFromBuckets(
|
||||
buckets,
|
||||
excludingKey: "a",
|
||||
using: &rng
|
||||
)
|
||||
#expect(result?.key == "b")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func pickRandomFromBucketsFallsBackWhenOnlyExcludedIsNonEmpty() {
|
||||
let buckets: [String: [Clip]] = [
|
||||
"a": [Clip(model: "a", id: 0)],
|
||||
"b": [],
|
||||
]
|
||||
var rng = SeededRNG(seed: 1)
|
||||
let result = VideoShuffle.pickRandomFromBuckets(
|
||||
buckets,
|
||||
excludingKey: "a",
|
||||
using: &rng
|
||||
)
|
||||
#expect(result?.key == "a", "should fall back to 'a' since 'b' is empty")
|
||||
}
|
||||
|
||||
// MARK: - End-to-end: never same model back-to-back
|
||||
|
||||
/// Simulates a full playback session on the real NSFW distribution and
|
||||
/// verifies that no two consecutive picks share a model. This is the
|
||||
/// invariant the user actually cares about.
|
||||
@Test func realNSFWDistributionNeverBackToBack() {
|
||||
let clips = Self.makeClips([
|
||||
"kayla-lauren": 502,
|
||||
"tabatachang": 147,
|
||||
"werkout": 137,
|
||||
"ray-mattos": 108,
|
||||
"dani-speegle-2": 49,
|
||||
"josie-hamming-2": 13,
|
||||
])
|
||||
#expect(clips.count == 956)
|
||||
|
||||
var rng = SeededRNG(seed: 42)
|
||||
var buckets = VideoShuffle.groupByModel(
|
||||
clips,
|
||||
keyFor: { $0.model },
|
||||
using: &rng
|
||||
)
|
||||
|
||||
// Simulate 2000 picks — more than the total clip count so we exercise
|
||||
// the refill logic for the small buckets (josie-hamming-2 has only 13).
|
||||
var sequence: [Clip] = []
|
||||
var lastModel: String? = nil
|
||||
for _ in 0..<2000 {
|
||||
// Refill any empty buckets by reshuffling the original clips for
|
||||
// that model (mirrors the production logic in GamesViewModel).
|
||||
for key in Array(buckets.keys) where buckets[key]?.isEmpty ?? true {
|
||||
let modelClips = clips.filter { $0.model == key }
|
||||
buckets[key] = modelClips.shuffled(using: &rng)
|
||||
}
|
||||
|
||||
guard let pick = VideoShuffle.pickRandomFromBuckets(
|
||||
buckets,
|
||||
excludingKey: lastModel,
|
||||
using: &rng
|
||||
) else {
|
||||
Issue.record("pick returned nil")
|
||||
return
|
||||
}
|
||||
buckets = pick.remaining
|
||||
sequence.append(pick.item)
|
||||
lastModel = pick.key
|
||||
}
|
||||
|
||||
let run = Self.maxRunLength(sequence)
|
||||
#expect(run == 1, "expected max run 1, got \(run)")
|
||||
}
|
||||
|
||||
@Test func modelDistributionIsRoughlyUniform() {
|
||||
// Over a long session, each model should be picked roughly
|
||||
// (totalPicks / modelCount) times — not weighted by bucket size.
|
||||
let clips = Self.makeClips([
|
||||
"kayla-lauren": 502,
|
||||
"tabatachang": 147,
|
||||
"werkout": 137,
|
||||
"ray-mattos": 108,
|
||||
"dani-speegle-2": 49,
|
||||
"josie-hamming-2": 13,
|
||||
])
|
||||
|
||||
var rng = SeededRNG(seed: 99)
|
||||
var buckets = VideoShuffle.groupByModel(
|
||||
clips,
|
||||
keyFor: { $0.model },
|
||||
using: &rng
|
||||
)
|
||||
|
||||
let totalPicks = 6000
|
||||
var modelCounts: [String: Int] = [:]
|
||||
var lastModel: String? = nil
|
||||
for _ in 0..<totalPicks {
|
||||
for key in Array(buckets.keys) where buckets[key]?.isEmpty ?? true {
|
||||
let modelClips = clips.filter { $0.model == key }
|
||||
buckets[key] = modelClips.shuffled(using: &rng)
|
||||
}
|
||||
guard let pick = VideoShuffle.pickRandomFromBuckets(
|
||||
buckets,
|
||||
excludingKey: lastModel,
|
||||
using: &rng
|
||||
) else { continue }
|
||||
buckets = pick.remaining
|
||||
modelCounts[pick.key, default: 0] += 1
|
||||
lastModel = pick.key
|
||||
}
|
||||
|
||||
// With 6 models and 6000 picks, expected per model ≈ 1000.
|
||||
// Allow ±25% (750 to 1250) since the exclusion rule introduces
|
||||
// slight bias against whichever model was just picked.
|
||||
for (model, count) in modelCounts {
|
||||
#expect(count >= 750 && count <= 1250, "\(model) got \(count) picks")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user