mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 05:02:53 +00:00
Start a session where the watch actually begins
This commit is contained in:
@@ -264,13 +264,14 @@ final class PlayerService {
|
||||
saveProgressAndSync()
|
||||
}
|
||||
|
||||
// Mirror this watch into the active account's server history. Only for
|
||||
// a genuinely new video: an audio-mode toggle or a quality switch
|
||||
// reloads the same one, and starting a session for those would post it
|
||||
// to the server a second time.
|
||||
if isNewVideo {
|
||||
watchHistorySyncService?.beginSession(video: video)
|
||||
}
|
||||
// Mirror this watch into the active account's server history.
|
||||
// Unconditional, and the service decides: `isNewVideo` is already false
|
||||
// for most openings, because openVideo() puts the video into state
|
||||
// before this task runs. The service compares against the session it is
|
||||
// holding instead, which keeps a reload of the same video — an
|
||||
// audio-mode toggle, a quality switch, a stream retry — from posting it
|
||||
// a second time.
|
||||
watchHistorySyncService?.beginSessionIfNeeded(video: video)
|
||||
|
||||
// Reset flag for the new video
|
||||
videoEndedNaturally = false
|
||||
@@ -660,14 +661,30 @@ final class PlayerService {
|
||||
} else if state.playbackState == .ended {
|
||||
// Restart from beginning when video has ended
|
||||
Task {
|
||||
await seek(to: 0)
|
||||
resume()
|
||||
await replayCurrentVideo()
|
||||
}
|
||||
} else {
|
||||
resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the current video again from the beginning, as a new watch.
|
||||
///
|
||||
/// Every way a *finished* video is started again comes through here: the
|
||||
/// replay button, the end-of-queue overlay, repeat-one, play/pause on an
|
||||
/// ended video. Each of those is a second watch of the same video, so the
|
||||
/// mirroring session is started fresh and the video is posted to the
|
||||
/// account's history again — which is what moves it back to the front.
|
||||
/// Seeking to the start mid-video is not this, and does not pass here.
|
||||
func replayCurrentVideo() async {
|
||||
if let video = state.currentVideo {
|
||||
watchHistorySyncService?.beginSession(video: video)
|
||||
}
|
||||
|
||||
await seek(to: 0)
|
||||
resume()
|
||||
}
|
||||
|
||||
/// Stops playback and clears the player.
|
||||
func stop() {
|
||||
sleepPreventionService.allowSleep()
|
||||
@@ -807,9 +824,8 @@ final class PlayerService {
|
||||
// Handle queue mode behavior
|
||||
switch state.queueMode {
|
||||
case .repeatOne:
|
||||
// Restart current video
|
||||
await seek(to: 0)
|
||||
resume()
|
||||
// Restart current video — a second watch of it, not a continuation
|
||||
await replayCurrentVideo()
|
||||
return
|
||||
|
||||
case .repeatAll:
|
||||
|
||||
@@ -56,11 +56,25 @@ final class WatchHistorySyncService {
|
||||
|
||||
// MARK: - Session lifecycle
|
||||
|
||||
/// Starts a mirroring session for a video that is about to play.
|
||||
/// Starts a mirroring session unless this video is already the one being
|
||||
/// mirrored.
|
||||
///
|
||||
/// Called only for a genuinely new video. Reloads of the same one — the
|
||||
/// audio-mode toggle, a quality switch, a stream retry — must not reach
|
||||
/// here, or each of them would post again.
|
||||
/// What playback calls on every load, because a load is not by itself a new
|
||||
/// watch: an audio-mode toggle, a quality switch or a stream retry all come
|
||||
/// through the same path with the same video, and starting a session for
|
||||
/// each of them would post the video again every time. A session already
|
||||
/// standing for this video is left exactly as it is — including its
|
||||
/// `posted` flag, which is what makes the reload free.
|
||||
func beginSessionIfNeeded(video: Video) {
|
||||
if let currentSession, currentSession.videoID == video.id.videoID { return }
|
||||
beginSession(video: video)
|
||||
}
|
||||
|
||||
/// Starts a mirroring session for a video, replacing whatever was running.
|
||||
///
|
||||
/// For the moments that are a new watch of a video already on screen —
|
||||
/// replaying one that finished, repeat-one coming round again — where the
|
||||
/// point is precisely to post it a second time.
|
||||
func beginSession(video: Video) {
|
||||
currentSession = nil
|
||||
|
||||
@@ -130,6 +144,7 @@ final class WatchHistorySyncService {
|
||||
|
||||
do {
|
||||
try await accountService.markWatched(videoID: videoID, for: account)
|
||||
announceUpdate(videoID: videoID, accountID: accountID)
|
||||
return
|
||||
} catch is CancellationError {
|
||||
// The account was discarded or the app is going away. Not a
|
||||
@@ -144,7 +159,13 @@ final class WatchHistorySyncService {
|
||||
)
|
||||
return
|
||||
} catch {
|
||||
guard attempt == 0 else {
|
||||
// Only what a second attempt could plausibly answer differently:
|
||||
// a timeout, a dropped connection, a rate limit, a server that
|
||||
// fell over. A 400 for an ID the server will never accept is
|
||||
// just as wrong the second time.
|
||||
let isRetryable = (error as? APIError)?.isRetryable == true
|
||||
|
||||
guard attempt == 0, isRetryable else {
|
||||
LoggingService.shared.debug(
|
||||
"Watch history mirror dropped for \(videoID): \(error)",
|
||||
category: .api
|
||||
@@ -155,6 +176,23 @@ final class WatchHistorySyncService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells whoever is showing this account's server history that a video just
|
||||
/// reached the front of it.
|
||||
///
|
||||
/// The view has no other way to know: the local write it also does is not
|
||||
/// news about the server, and the account's history is only ever read by
|
||||
/// asking for it.
|
||||
private func announceUpdate(videoID: String, accountID: UUID) {
|
||||
NotificationCenter.default.post(
|
||||
name: .serverWatchHistoryDidUpdate,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
ServerWatchHistoryUpdate.accountIDKey: accountID,
|
||||
ServerWatchHistoryUpdate.videoIDKey: videoID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Draining
|
||||
|
||||
/// Waits for every post still in flight.
|
||||
@@ -189,3 +227,20 @@ final class WatchHistorySyncService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Announcement
|
||||
|
||||
extension Notification.Name {
|
||||
/// A video was recorded in an account's server-side history. The userInfo
|
||||
/// carries which account and which video, so a list showing another
|
||||
/// account's history can ignore it.
|
||||
static let serverWatchHistoryDidUpdate = Notification.Name("stream.yattee.serverWatchHistoryDidUpdate")
|
||||
}
|
||||
|
||||
/// The userInfo keys of ``Notification/Name/serverWatchHistoryDidUpdate``.
|
||||
enum ServerWatchHistoryUpdate {
|
||||
/// `UUID` — the account whose history gained the video.
|
||||
static let accountIDKey = "accountID"
|
||||
/// `String` — the video that was recorded.
|
||||
static let videoIDKey = "videoID"
|
||||
}
|
||||
|
||||
@@ -49,8 +49,13 @@ struct HistoryListView: View {
|
||||
/// looking at any more.
|
||||
@State private var loadGeneration = 0
|
||||
@State private var loadTask: Task<Void, Never>?
|
||||
/// The page to ask for next; 1-based, like the server's own numbering.
|
||||
@State private var nextPage = 1
|
||||
/// The lowest page still worth asking for; 1-based, like the server's own
|
||||
/// numbering.
|
||||
///
|
||||
/// Normally the page number comes from the rows themselves. This is the
|
||||
/// floor under it, raised past any page that turned out to hold nothing new,
|
||||
/// so a page that cannot move the list on is never asked for twice.
|
||||
@State private var pageFloor = 1
|
||||
@State private var hasMorePages = false
|
||||
@State private var isLoadingPage = false
|
||||
@State private var serverErrorMessage: String?
|
||||
@@ -62,6 +67,19 @@ struct HistoryListView: View {
|
||||
/// How many IDs one page of server history asks for.
|
||||
private static let serverPageSize = 60
|
||||
|
||||
/// The page to ask the server for next.
|
||||
///
|
||||
/// Read from the rows that are loaded rather than counted up from the last
|
||||
/// request, so a deletion — which shifts every server offset after it —
|
||||
/// cannot make the next request skip the item that moved back across the
|
||||
/// page boundary.
|
||||
private var nextServerPage: Int {
|
||||
max(
|
||||
ServerHistoryPaging.nextPage(loadedRowCount: rows.count, pageSize: Self.serverPageSize),
|
||||
pageFloor
|
||||
)
|
||||
}
|
||||
|
||||
// View options (persisted)
|
||||
@AppStorage("history.layout") private var layout: VideoListLayout = .list
|
||||
@AppStorage("history.rowStyle") private var rowStyle: VideoRowStyle = .regular
|
||||
@@ -347,6 +365,17 @@ struct HistoryListView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .instancesDidChange)) { _ in
|
||||
reloadIfEffectiveContextMoved()
|
||||
}
|
||||
// Something was just watched and mirrored to the account whose history
|
||||
// this is. The server has moved it to the front; so does the list,
|
||||
// rather than waiting to be left and come back to.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .serverWatchHistoryDidUpdate)) { notification in
|
||||
applyServerUpdate(notification)
|
||||
}
|
||||
// Searching reads titles, and a server row starts with only an ID.
|
||||
.onChange(of: searchText) { _, newValue in
|
||||
guard !newValue.isEmpty, case let .server(account) = displayMode else { return }
|
||||
hydrateAllLoaded(account: account)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
@@ -454,7 +483,7 @@ struct HistoryListView: View {
|
||||
private var loadMoreFooter: some View {
|
||||
if case let .server(account) = displayMode {
|
||||
LoadMoreTrigger(isLoading: isLoadingPage, hasMore: hasMorePages) {
|
||||
beginPageLoad(nextPage, account: account)
|
||||
beginPageLoad(nextServerPage, account: account)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,7 +615,7 @@ struct HistoryListView: View {
|
||||
serverErrorMessage = nil
|
||||
isLoadingPage = false
|
||||
hydrationAttempted.removeAll()
|
||||
nextPage = 1
|
||||
pageFloor = 1
|
||||
|
||||
guard case let .server(account) = displayMode else {
|
||||
hasMorePages = false
|
||||
@@ -631,11 +660,24 @@ struct HistoryListView: View {
|
||||
|
||||
guard generation == loadGeneration else { return }
|
||||
|
||||
append(ids)
|
||||
nextPage = page + 1
|
||||
let appended = append(ids)
|
||||
|
||||
// A page that held nothing this list did not already have cannot be
|
||||
// what moves it on, however many times it is asked for.
|
||||
if appended == 0 {
|
||||
pageFloor = page + 1
|
||||
}
|
||||
|
||||
// A page the server could not fill is the last one there is; so is
|
||||
// an empty one, which is what asking past the end returns.
|
||||
hasMorePages = ids.count >= Self.serverPageSize
|
||||
|
||||
// A search is matching on titles, and rows that arrived without one
|
||||
// would sit out of the results until somebody scrolled them into
|
||||
// view — which, being filtered out, they never are.
|
||||
if !searchText.isEmpty {
|
||||
hydrateAllLoaded(account: account)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// Another load is taking over — it owns the state now.
|
||||
} catch let error as APIError where error == .cancelled {
|
||||
@@ -652,7 +694,9 @@ struct HistoryListView: View {
|
||||
/// Never re-sorted: the server's order *is* the history, newest first, and
|
||||
/// the local entries some of these rows have were written at times that say
|
||||
/// nothing about when the account watched them.
|
||||
private func append(_ ids: [String]) {
|
||||
/// - Returns: how many of the IDs were rows this list did not already have.
|
||||
@discardableResult
|
||||
private func append(_ ids: [String]) -> Int {
|
||||
var known = Set(rows.map(\.videoID))
|
||||
var appended: [HistoryRow] = []
|
||||
|
||||
@@ -661,10 +705,11 @@ struct HistoryListView: View {
|
||||
appended.append(makeRow(id: id))
|
||||
}
|
||||
|
||||
guard !appended.isEmpty else { return }
|
||||
guard !appended.isEmpty else { return 0 }
|
||||
|
||||
rows.append(contentsOf: appended)
|
||||
rowVideos.append(contentsOf: appended.map(\.video))
|
||||
return appended.count
|
||||
}
|
||||
|
||||
/// A row for one server-side ID, from local data where there is some.
|
||||
@@ -710,6 +755,19 @@ struct HistoryListView: View {
|
||||
hydrate(row, account: account)
|
||||
}
|
||||
|
||||
/// Fetches metadata for every loaded row that has none, whether or not it is
|
||||
/// on screen.
|
||||
///
|
||||
/// For searching, which reads titles: a row still standing as its own video
|
||||
/// ID matches nothing anybody would type, and the usual trigger — the row
|
||||
/// appearing — cannot fire for a row the search has filtered out. Bounded by
|
||||
/// what is loaded, and each row is still only ever asked for once.
|
||||
private func hydrateAllLoaded(account: Account) {
|
||||
for row in rows where !row.isHydrated {
|
||||
hydrate(row, account: account)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches one row's metadata, as the row appears.
|
||||
///
|
||||
/// Through the account's own instance: every ID the server returns is a
|
||||
@@ -750,6 +808,44 @@ struct HistoryListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live updates
|
||||
|
||||
/// Moves a video that was just recorded on the server to the front of the
|
||||
/// list.
|
||||
///
|
||||
/// Only for the account the rows on screen were loaded for — `loadedContext`
|
||||
/// rather than the live one, which is the same guard the generation gives an
|
||||
/// awaited result: a post that lands as the context moves belongs to the
|
||||
/// history that is on its way out, not the one arriving.
|
||||
private func applyServerUpdate(_ notification: Notification) {
|
||||
guard case let .server(account) = displayMode,
|
||||
let accountID = notification.userInfo?[ServerWatchHistoryUpdate.accountIDKey] as? UUID,
|
||||
accountID == account.id,
|
||||
loadedContext == .account(accountID),
|
||||
let videoID = notification.userInfo?[ServerWatchHistoryUpdate.videoIDKey] as? String
|
||||
else { return }
|
||||
|
||||
bumpToFront(videoID)
|
||||
}
|
||||
|
||||
/// Puts a video at the top of the rows, wherever it was — or was not.
|
||||
///
|
||||
/// What the server does with a repeated post: the video moves to the front
|
||||
/// of the history rather than appearing twice in it. A video not among the
|
||||
/// loaded rows is new to the front of the list, and arrives with whatever
|
||||
/// the device already knows about it, which for something just watched here
|
||||
/// is everything.
|
||||
private func bumpToFront(_ videoID: String) {
|
||||
if let index = rows.firstIndex(where: { $0.videoID == videoID }) {
|
||||
guard index != 0 else { return }
|
||||
rows.insert(rows.remove(at: index), at: 0)
|
||||
} else {
|
||||
rows.insert(makeRow(id: videoID), at: 0)
|
||||
}
|
||||
|
||||
rowVideos = rows.map(\.video)
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
private func loadHistory() {
|
||||
|
||||
34
Yattee/Views/Home/ServerHistoryPaging.swift
Normal file
34
Yattee/Views/Home/ServerHistoryPaging.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// ServerHistoryPaging.swift
|
||||
// Yattee
|
||||
//
|
||||
// Which page of an account's server history to ask for next.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Works out the next page number of a server history from what is on screen.
|
||||
///
|
||||
/// Its own type so the arithmetic can be reasoned about — and tested — apart
|
||||
/// from the view that scrolls.
|
||||
enum ServerHistoryPaging {
|
||||
/// The page to request next, given how many rows are loaded.
|
||||
///
|
||||
/// Derived from the rows rather than counted up from the last request,
|
||||
/// because the server pages by offset and a deletion shifts every offset
|
||||
/// after it. Counting up would ask for the page after the one just read and
|
||||
/// step straight over the item that moved back across the boundary — gone
|
||||
/// for the rest of the session, one more for every further deletion. Asking
|
||||
/// for the page the loaded rows actually end in re-reads that boundary
|
||||
/// instead, and the caller's de-duplication drops what is already there.
|
||||
///
|
||||
/// With nothing deleted this is the same number counting up would give.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - loadedRowCount: how many rows of this account's history are held.
|
||||
/// - pageSize: how many IDs one request asks for.
|
||||
static func nextPage(loadedRowCount: Int, pageSize: Int) -> Int {
|
||||
guard pageSize > 0 else { return 1 }
|
||||
return max(0, loadedRowCount) / pageSize + 1
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,14 @@ extension ExpandedPlayerSheet {
|
||||
}
|
||||
|
||||
/// Restarts playback from the beginning.
|
||||
///
|
||||
/// The video this offers has finished, so playing it again is a new watch —
|
||||
/// which is the player's to know about, not the overlay's.
|
||||
func restartPlayback() {
|
||||
guard let playerService else { return }
|
||||
|
||||
Task {
|
||||
await playerService.seek(to: 0)
|
||||
playerService.resume()
|
||||
await playerService.replayCurrentVideo()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
59
YatteeTests/ServerHistoryPagingTests.swift
Normal file
59
YatteeTests/ServerHistoryPagingTests.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// ServerHistoryPagingTests.swift
|
||||
// YatteeTests
|
||||
//
|
||||
// Which page of a server history is asked for next, once rows have been
|
||||
// deleted out from under the offsets.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Yattee
|
||||
|
||||
@Suite("Server History Paging")
|
||||
struct ServerHistoryPagingTests {
|
||||
private static let pageSize = 60
|
||||
|
||||
private static func nextPage(_ loadedRowCount: Int) -> Int {
|
||||
ServerHistoryPaging.nextPage(loadedRowCount: loadedRowCount, pageSize: pageSize)
|
||||
}
|
||||
|
||||
@Test("Nothing loaded asks for the first page")
|
||||
func nothingLoadedAsksForTheFirstPage() {
|
||||
#expect(Self.nextPage(0) == 1)
|
||||
}
|
||||
|
||||
@Test("A full page loaded asks for the second")
|
||||
func aFullPageAsksForTheSecond() {
|
||||
#expect(Self.nextPage(Self.pageSize) == 2)
|
||||
}
|
||||
|
||||
@Test("Two full pages loaded ask for the third")
|
||||
func twoFullPagesAskForTheThird() {
|
||||
#expect(Self.nextPage(Self.pageSize * 2) == 3)
|
||||
}
|
||||
|
||||
@Test("A row deleted out of the first page puts the boundary back in reach")
|
||||
func aDeletionRereadsTheBoundary() {
|
||||
// The server closed the gap: what was the first item of page 2 is now
|
||||
// the last of page 1, and asking for page 2 would step over it.
|
||||
#expect(Self.nextPage(Self.pageSize - 1) == 1)
|
||||
}
|
||||
|
||||
@Test("Deletions across two pages come back to the page they left short")
|
||||
func deletionsAcrossPagesRereadThatPage() {
|
||||
#expect(Self.nextPage(Self.pageSize * 2 - 1) == 2)
|
||||
}
|
||||
|
||||
@Test("A partly filled page still asks for the page it is in")
|
||||
func aPartialPageAsksForItself() {
|
||||
#expect(Self.nextPage(1) == 1)
|
||||
#expect(Self.nextPage(Self.pageSize + 1) == 2)
|
||||
}
|
||||
|
||||
@Test("Nonsense counts still name a page that exists")
|
||||
func degenerateInputsStayValid() {
|
||||
#expect(ServerHistoryPaging.nextPage(loadedRowCount: -5, pageSize: Self.pageSize) == 1)
|
||||
#expect(ServerHistoryPaging.nextPage(loadedRowCount: 10, pageSize: 0) == 1)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,36 @@ private final class Flag {
|
||||
var isSet = false
|
||||
}
|
||||
|
||||
/// Collects what ``Notification/Name/serverWatchHistoryDidUpdate`` announced.
|
||||
@MainActor
|
||||
private final class UpdateAnnouncements {
|
||||
private(set) var received: [(accountID: UUID, videoID: String)] = []
|
||||
private var observer: NSObjectProtocol?
|
||||
|
||||
init() {
|
||||
// No queue: delivered synchronously, on the main actor the post is made
|
||||
// from, so a test can look at it the moment the flush returns.
|
||||
observer = NotificationCenter.default.addObserver(
|
||||
forName: .serverWatchHistoryDidUpdate,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { [weak self] notification in
|
||||
guard let accountID = notification.userInfo?[ServerWatchHistoryUpdate.accountIDKey] as? UUID,
|
||||
let videoID = notification.userInfo?[ServerWatchHistoryUpdate.videoIDKey] as? String
|
||||
else { return }
|
||||
|
||||
MainActor.assumeIsolated {
|
||||
self?.received.append((accountID: accountID, videoID: videoID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let observer { NotificationCenter.default.removeObserver(observer) }
|
||||
observer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeVideo(_ id: String) -> Video {
|
||||
Video(
|
||||
id: .global(id),
|
||||
@@ -209,6 +239,85 @@ struct WatchHistorySyncTests {
|
||||
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
|
||||
}
|
||||
|
||||
@Test("A finished video played again is a new watch and posts again")
|
||||
func aFreshSessionForTheSameVideoPostsAgain() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.service.beginSession(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
|
||||
// The replay button, or repeat-one coming round: the same video, still
|
||||
// loaded, watched a second time.
|
||||
harness.service.beginSession(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
await harness.service.flushPending()
|
||||
|
||||
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
|
||||
}
|
||||
|
||||
// MARK: - Reloads of the video already playing
|
||||
|
||||
@Test("Reloading the video already playing does not post it again")
|
||||
func reloadingTheSameVideoKeepsTheSession() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
|
||||
// An audio-mode toggle, a quality switch, a stream retry: the same
|
||||
// video loaded again, and the same watch.
|
||||
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
await harness.service.flushPending()
|
||||
|
||||
#expect(await harness.transport.markedVideoIDs == ["aaa"])
|
||||
}
|
||||
|
||||
@Test("Moving to another video starts that video's session")
|
||||
func anotherVideoReplacesTheSession() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
|
||||
harness.service.beginSessionIfNeeded(video: makeVideo("bbb"))
|
||||
harness.service.recordProgress(videoID: "bbb")
|
||||
await harness.service.flushPending()
|
||||
|
||||
#expect(await harness.transport.markedVideoIDs == ["aaa", "bbb"])
|
||||
}
|
||||
|
||||
@Test("The first video of a playback starts a session with nothing to compare to")
|
||||
func theFirstVideoStartsASession() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
await harness.service.flushPending()
|
||||
|
||||
#expect(await harness.transport.markedVideoIDs == ["aaa"])
|
||||
}
|
||||
|
||||
@Test("A video opened again after the player stopped is watched again")
|
||||
func theSameVideoAfterAnEndedSessionPostsAgain() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
harness.service.endSession()
|
||||
|
||||
harness.service.beginSessionIfNeeded(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
await harness.service.flushPending()
|
||||
|
||||
#expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"])
|
||||
}
|
||||
|
||||
@Test("Progress for a video the session is not about is ignored")
|
||||
func progressForAnotherVideoIsIgnored() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
@@ -305,6 +414,37 @@ struct WatchHistorySyncTests {
|
||||
#expect(await harness.transport.callCount == 2)
|
||||
}
|
||||
|
||||
@Test("A post the server refused on its merits is not tried again")
|
||||
func aNonRetryableFailureIsNotRetried() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
|
||||
// An ID this server will not take is not going to take it the second
|
||||
// time either.
|
||||
await harness.transport.setError(APIError.httpError(statusCode: 400, message: "Invalid video id."))
|
||||
|
||||
harness.service.beginSession(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
await harness.service.flushPending()
|
||||
try await harness.transport.settle()
|
||||
|
||||
#expect(await harness.transport.callCount == 1)
|
||||
}
|
||||
|
||||
@Test("A post the transport itself refused is not tried again")
|
||||
func aServiceFailureFromTheTransportIsNotRetried() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
await harness.transport.setError(AccountServiceError.notSupported)
|
||||
|
||||
harness.service.beginSession(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
await harness.service.flushPending()
|
||||
try await harness.transport.settle()
|
||||
|
||||
#expect(await harness.transport.callCount == 1)
|
||||
}
|
||||
|
||||
@Test("A post that could not be resolved at all is not retried")
|
||||
func aResolutionFailureIsNotRetried() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
@@ -337,6 +477,40 @@ struct WatchHistorySyncTests {
|
||||
#expect(await harness.transport.callCount == 0)
|
||||
}
|
||||
|
||||
// MARK: - Announcing
|
||||
|
||||
@Test("A post that landed says so, naming the account and the video")
|
||||
func aSuccessfulPostIsAnnounced() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
let announcements = UpdateAnnouncements()
|
||||
defer { announcements.stop() }
|
||||
|
||||
harness.service.beginSession(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
await harness.service.flushPending()
|
||||
|
||||
#expect(announcements.received.count == 1)
|
||||
#expect(announcements.received.first?.accountID == harness.account.id)
|
||||
#expect(announcements.received.first?.videoID == "aaa")
|
||||
}
|
||||
|
||||
@Test("A post that never landed announces nothing")
|
||||
func aFailedPostIsNotAnnounced() async throws {
|
||||
let harness = WatchHistorySyncHarness()
|
||||
defer { harness.tearDown() }
|
||||
let announcements = UpdateAnnouncements()
|
||||
defer { announcements.stop() }
|
||||
await harness.transport.setError(APIError.timeout)
|
||||
|
||||
harness.service.beginSession(video: makeVideo("aaa"))
|
||||
harness.service.recordProgress(videoID: "aaa")
|
||||
await harness.service.flushPending()
|
||||
try await harness.transport.settle()
|
||||
|
||||
#expect(announcements.received.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Draining and disowning
|
||||
|
||||
@Test("A flush returns only once the post it is waiting for has landed")
|
||||
|
||||
Reference in New Issue
Block a user