ReplayControlsTests.swift (4249B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 /// Derived scrub state on `ReplayControls` — the glue between a loaded 7 /// `ReplayTimeline` and the grid override the finish banner renders. 8 @Suite("Replay controls") 9 @MainActor 10 struct ReplayControlsTests { 11 12 private func entry( 13 seq: Int64, 14 at seconds: TimeInterval, 15 row: Int, 16 col: Int, 17 letter: String, 18 direction: Puzzle.Direction? = nil 19 ) -> JournalValue { 20 JournalValue( 21 seq: seq, 22 timestamp: Date(timeIntervalSince1970: seconds), 23 position: GridPosition(row: row, col: col), 24 state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "a"), 25 actingAuthorID: "a", 26 kind: .input, 27 targetSeq: nil, 28 batchID: nil, 29 prevSeqAtCell: nil, 30 direction: direction 31 ) 32 } 33 34 private func timeline() -> ReplayTimeline { 35 ReplayTimeline(merging: [[ 36 entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", direction: .across), 37 entry(seq: 1, at: 20, row: 0, col: 1, letter: "B", direction: .down), 38 ]]) 39 } 40 41 @Test("a ready load parks the head at the end with no override") 42 func readyParksAtEnd() async { 43 let controls = ReplayControls() 44 let tl = timeline() 45 await controls.load { .ready(tl) } 46 47 #expect(controls.isScrubbable) 48 #expect(controls.position == 2) // hard-right = finished grid 49 #expect(controls.gridOverride == nil) // at rest, render the live grid 50 #expect(controls.cursor == nil) 51 } 52 53 @Test("scrubbing left reconstructs the grid and the playhead") 54 func scrubbingRewinds() async { 55 let controls = ReplayControls() 56 let tl = timeline() 57 await controls.load { .ready(tl) } 58 59 controls.position = 1 60 #expect(controls.gridOverride?[GridPosition(row: 0, col: 0)]?.letter == "A") 61 #expect(controls.gridOverride?[GridPosition(row: 0, col: 1)] == nil) 62 #expect(controls.cursor == GridPosition(row: 0, col: 0)) 63 #expect(controls.cursorDirection == .across) 64 65 controls.position = 0 66 #expect(controls.gridOverride?.isEmpty == true) 67 #expect(controls.cursor == nil) 68 #expect(controls.cursorDirection == nil) 69 } 70 71 @Test("a waiting load is not scrubbable") 72 func waitingNotScrubbable() async { 73 let controls = ReplayControls() 74 await controls.load { .waiting(missing: 2) } 75 76 #expect(!controls.isScrubbable) 77 #expect(controls.gridOverride == nil) 78 if case .waiting(let missing) = controls.status { 79 #expect(missing == 2) 80 } else { 81 Issue.record("expected .waiting, got \(controls.status)") 82 } 83 } 84 85 @Test("load is idempotent once ready, but retry re-opens it") 86 func retryReloads() async { 87 let controls = ReplayControls() 88 await controls.load { .ready(timeline()) } 89 90 // A second load while ready is a no-op (loader must not run). 91 await controls.load { 92 Issue.record("loader ran while already ready") 93 return .unavailable 94 } 95 #expect(controls.isScrubbable) 96 97 controls.retry() 98 await controls.load { .waiting(missing: 1) } 99 #expect(!controls.isScrubbable) 100 } 101 102 @Test("playback toggles without losing the selected speed") 103 func playbackTogglePreservesSelectedSpeed() async { 104 let controls = ReplayControls() 105 #expect(!controls.isPlaybackActive) 106 #expect(controls.selectedPlaybackSpeed == 1) 107 108 controls.togglePlayback() 109 #expect(controls.isPlaybackActive) 110 111 controls.cycleSelectedPlaybackSpeed() 112 controls.cycleSelectedPlaybackSpeed() 113 #expect(controls.selectedPlaybackSpeed == 3) 114 115 controls.pausePlayback() 116 #expect(!controls.isPlaybackActive) 117 #expect(controls.selectedPlaybackSpeed == 3) 118 119 controls.togglePlayback() 120 #expect(controls.isPlaybackActive) 121 #expect(controls.selectedPlaybackSpeed == 3) 122 123 controls.retry() 124 #expect(!controls.isPlaybackActive) 125 #expect(controls.selectedPlaybackSpeed == 1) 126 } 127 }