crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

ReplayControllerTests.swift (3210B)


      1 import Foundation
      2 import Testing
      3 
      4 @testable import Crossmate
      5 
      6 /// Derived scrub state on `ReplayController` — the glue between a loaded
      7 /// `ReplayTimeline` and the grid override the finish banner renders.
      8 @Suite("Replay controller")
      9 @MainActor
     10 struct ReplayControllerTests {
     11 
     12     private func entry(seq: Int64, at seconds: TimeInterval, row: Int, col: Int, letter: String) -> JournalValue {
     13         JournalValue(
     14             seq: seq,
     15             timestamp: Date(timeIntervalSince1970: seconds),
     16             position: GridPosition(row: row, col: col),
     17             state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "a"),
     18             actingAuthorID: "a",
     19             kind: .input,
     20             targetSeq: nil,
     21             batchID: nil,
     22             prevSeqAtCell: nil,
     23             direction: nil
     24         )
     25     }
     26 
     27     private func timeline() -> ReplayTimeline {
     28         ReplayTimeline(merging: [[
     29             entry(seq: 0, at: 10, row: 0, col: 0, letter: "A"),
     30             entry(seq: 1, at: 20, row: 0, col: 1, letter: "B"),
     31         ]])
     32     }
     33 
     34     @Test("a ready load parks the head at the end with no override")
     35     func readyParksAtEnd() async {
     36         let controller = ReplayController()
     37         let tl = timeline()
     38         await controller.load { .ready(tl) }
     39 
     40         #expect(controller.isScrubbable)
     41         #expect(controller.position == 2)         // hard-right = finished grid
     42         #expect(controller.gridOverride == nil)   // at rest, render the live grid
     43         #expect(controller.cursor == nil)
     44     }
     45 
     46     @Test("scrubbing left reconstructs the grid and the playhead")
     47     func scrubbingRewinds() async {
     48         let controller = ReplayController()
     49         let tl = timeline()
     50         await controller.load { .ready(tl) }
     51 
     52         controller.position = 1
     53         #expect(controller.gridOverride?[GridPosition(row: 0, col: 0)]?.letter == "A")
     54         #expect(controller.gridOverride?[GridPosition(row: 0, col: 1)] == nil)
     55         #expect(controller.cursor == GridPosition(row: 0, col: 0))
     56 
     57         controller.position = 0
     58         #expect(controller.gridOverride?.isEmpty == true)
     59         #expect(controller.cursor == nil)
     60     }
     61 
     62     @Test("a waiting load is not scrubbable")
     63     func waitingNotScrubbable() async {
     64         let controller = ReplayController()
     65         await controller.load { .waiting(missing: 2) }
     66 
     67         #expect(!controller.isScrubbable)
     68         #expect(controller.gridOverride == nil)
     69         if case .waiting(let missing) = controller.status {
     70             #expect(missing == 2)
     71         } else {
     72             Issue.record("expected .waiting, got \(controller.status)")
     73         }
     74     }
     75 
     76     @Test("load is idempotent once ready, but retry re-opens it")
     77     func retryReloads() async {
     78         let controller = ReplayController()
     79         await controller.load { .ready(timeline()) }
     80 
     81         // A second load while ready is a no-op (loader must not run).
     82         await controller.load {
     83             Issue.record("loader ran while already ready")
     84             return .unavailable
     85         }
     86         #expect(controller.isScrubbable)
     87 
     88         controller.retry()
     89         await controller.load { .waiting(missing: 1) }
     90         #expect(!controller.isScrubbable)
     91     }
     92 }