Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions iosApp/DownloadsActivity/DownloadsLiveActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ struct DownloadsLiveActivity: Widget {
ActivityConfiguration(for: DownloadActivityAttributes.self) { context in
DownloadsLockScreenView(state: context.state, isStale: context.isStale)
.padding(16)
// The extension has no accent asset, so `.tint` would fall
// back to system blue; follow the lock screen's text color.
.tint(.primary)
.widgetURL(Self.deepLink)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: context.state.phase.symbolName)
.font(.title2)
.foregroundStyle(.tint)
.foregroundStyle(.white)
.padding(.leading, 4)
}
DynamicIslandExpandedRegion(.trailing) {
Expand All @@ -52,6 +55,7 @@ struct DownloadsLiveActivity: Widget {
VStack(alignment: .leading, spacing: 4) {
ProgressView(value: context.state.fraction)
.progressViewStyle(.linear)
.tint(.white)
Text(context.state.statusText(isStale: context.isStale))
.font(.caption)
.foregroundStyle(.secondary)
Expand All @@ -61,15 +65,15 @@ struct DownloadsLiveActivity: Widget {
}
} compactLeading: {
Image(systemName: context.state.phase.symbolName)
.foregroundStyle(.tint)
.foregroundStyle(.white)
} compactTrailing: {
ProgressView(value: context.state.fraction)
.progressViewStyle(.circular)
.tint(.blue)
.tint(.white)
} minimal: {
ProgressView(value: context.state.fraction)
.progressViewStyle(.circular)
.tint(.blue)
.tint(.white)
}
.widgetURL(Self.deepLink)
}
Expand Down
12 changes: 12 additions & 0 deletions iosApp/Tests/CalendarFilterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import XCTest
@testable import Prairie

/// An empty calendar week links to the other two views, never the one on
/// screen (issue #513, matching the Web empty state).
final class CalendarFilterTests: XCTestCase {
func testEmptyStateLinksToTheOtherTwoViews() {
XCTAssertEqual(CalendarFilter.following.emptyStateLinks, [.trending, .everything])
XCTAssertEqual(CalendarFilter.trending.emptyStateLinks, [.following, .everything])
XCTAssertEqual(CalendarFilter.everything.emptyStateLinks, [.following, .trending])
}
}
41 changes: 41 additions & 0 deletions iosApp/Tests/DetailVersionSelectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,47 @@ final class DetailVersionSelectionTests: XCTestCase {
)
}

#if !os(tvOS)
func testAutoDownloadPinsTheDisplayedLastPlayedVersion() {
// Auto displays the last-played 720p file (the one with the sidecar
// subtitles); sending nil let the server pick the 1080p file instead.
let versions = [
version(fileId: 1, resolution: "720p"),
version(fileId: 2, resolution: "1080p")
]

XCTAssertEqual(DownloadRequestOptions.fileId(
versions: versions,
selectedFileId: nil,
lastFileId: 1,
preferredQualityId: nil
), 1)
}

func testDownloadPinsAnExplicitVersionOverLastPlayed() {
let versions = [
version(fileId: 1, resolution: "720p"),
version(fileId: 2, resolution: "1080p")
]

XCTAssertEqual(DownloadRequestOptions.fileId(
versions: versions,
selectedFileId: 2,
lastFileId: 1,
preferredQualityId: nil
), 2)
}

func testDownloadLeavesTheFileToTheServerWithoutVersions() {
XCTAssertNil(DownloadRequestOptions.fileId(
versions: [],
selectedFileId: nil,
lastFileId: 1,
preferredQualityId: nil
))
}
#endif

func testVersionSelectorUsesRichPrePlaySummary() {
let version = decodedVersions("""
[
Expand Down
254 changes: 254 additions & 0 deletions iosApp/Tests/DownloadPipelineReliabilityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import Foundation
import XCTest
@testable import Prairie

/// Capping pipelines and transfers, starting each transfer exactly once,
/// retrying requests that never got an answer, and a transfer speed that
/// reflects what is moving now.
final class DownloadPipelineReliabilityTests: XCTestCase {
private typealias Plan = DownloadManager.ReconnectPlan

private func plan(
_ status: LocalDownloadStatus,
task: Int? = nil,
taskBeforeRead: Int? = nil,
live: Set<Int> = [],
pausing: Bool = false,
restartOwned: Bool = false
) -> Plan {
DownloadManager.reconnectPlan(
status: status,
taskIdentifier: task,
taskIdentifierBeforeRead: taskBeforeRead,
liveTaskIds: live,
pausing: pausing,
restartOwned: restartOwned
)
}

// MARK: Queue

func testOnlyRunningPipelinesHoldQueueSlots() {
var owners = DownloadRestartOwners()
_ = owners.claimPipeline("a")
_ = owners.claimPipeline("b")
// A retry waiting out its back-off doesn't keep the queue from moving.
owners.retryScheduled("c", firesAt: Date())
XCTAssertEqual(owners.pipelineCount, 2)
XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: owners.pipelineCount, transferring: 0, limit: 6).pipelines, 1)
XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: 5, transferring: 0, limit: 6).pipelines, 0)
}

func testTransfersStayWithinTheSimultaneousDownloadsLimit() {
// Two transferring and two about to: the next waits for one to finish.
XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: 2, transferring: 2, limit: 4).transfers, 0)
XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: 0, transferring: 1, limit: 4).transfers, 3)
// A whole series handed over at once is what this prevents.
XCTAssertEqual(DownloadManager.queueSlots(runningPipelines: 0, transferring: 53, limit: 4).transfers, 0)
}

private func record(
_ status: LocalDownloadStatus, task: Int? = nil, id: String = "d1", bytes: Int64 = 0, age: TimeInterval = 0
) -> DownloadRecord {
DownloadRecord(
id: id, contentId: "c1", episodeId: nil, batchId: nil, mediaFileId: "0", format: "original",
serverStatus: "ready", localStatus: status, fileSize: 1_000, bytesDownloaded: bytes,
mediaFilename: nil, manifestFilename: nil, posterFilename: nil, backdropFilename: nil,
logoFilename: nil, subtitleFilenames: [:], title: "Title", subtitle: nil, type: "movie",
seriesId: nil, seriesTitle: nil, posterThumbhash: nil, container: nil, stableIdentity: nil,
registeredAt: Date(timeIntervalSinceReferenceDate: 1_000 - age), downloadedAt: nil, lastError: nil,
retryCount: 0, taskIdentifier: task
)
}

func testInProgressListLeadsWithWhatIsTransferring() {
let sorted = DownloadManager.sortedByActivity([
record(.queued, id: "queued-old", age: 50),
record(.paused, id: "paused", age: 40),
record(.queued, id: "queued-new", age: 1),
record(.downloading, task: 2, id: "handed-off", age: 5),
record(.preparing, id: "preparing", age: 30),
record(.downloading, task: 1, id: "moving", bytes: 500, age: 10),
record(.fetchingAssets, id: "fetching", age: 20),
])
XCTAssertEqual(sorted.map(\.id),
["moving", "fetching", "handed-off", "preparing", "queued-old", "queued-new", "paused"])
}

func testRecordsLeftMidPipelineGoBackInTheQueueWhenTheStoreLoads() {
XCTAssertTrue(DownloadManager.isOrphaned(record(.fetchingAssets)))
// Was waiting to restart; the retry didn't survive the process.
XCTAssertTrue(DownloadManager.isOrphaned(record(.downloading)))
// Reconnect decides whether this task still runs.
XCTAssertFalse(DownloadManager.isOrphaned(record(.downloading, task: 4)))
XCTAssertFalse(DownloadManager.isOrphaned(record(.queued)))
XCTAssertFalse(DownloadManager.isOrphaned(record(.paused)))
}

// MARK: Transfer failures

func testClosingPrairieFromTheAppSwitcherResumesWithoutUsingARetry() {
XCTAssertEqual(DownloadManager.mediaFailureAction(statusCode: nil, retryCount: 4, message: "cancelled", cause: .forceQuit),
.retry(keepResumeData: true, refreshToken: false))
}

func testFullStorageFailsWithoutRetrying() {
XCTAssertEqual(DownloadManager.mediaFailureAction(statusCode: nil, retryCount: 0, message: "write", cause: .storageFull),
.fail("storage_full"))
XCTAssertTrue(DownloadSessionDelegate.isOutOfSpace(NSError(domain: NSCocoaErrorDomain, code: NSFileWriteOutOfSpaceError)))
XCTAssertTrue(DownloadSessionDelegate.isOutOfSpace(URLError(.cannotWriteToFile,
userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC))])))
// A write failure alone could be anything; it gets the usual retries.
XCTAssertFalse(DownloadSessionDelegate.isOutOfSpace(URLError(.cannotWriteToFile)))
XCTAssertTrue(DownloadSessionDelegate.isOutOfSpace(NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError,
userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC))])))
XCTAssertFalse(DownloadSessionDelegate.isOutOfSpace(URLError(.networkConnectionLost)))
}

// MARK: Restart ownership

func testSupersededPipelineCanNeitherStartNorReleaseTheRecord() {
var owners = DownloadRestartOwners()
let first = owners.claimPipeline("d1")
let second = owners.claimPipeline("d1")
XCTAssertFalse(owners.ownsPipeline("d1", first))
// The first pipeline finishing must not free the record from the second.
owners.releasePipeline("d1", first)
XCTAssertTrue(owners.ownsPipeline("d1", second))
XCTAssertTrue(owners.ownsRestart("d1"))
owners.releasePipeline("d1", second)
XCTAssertFalse(owners.ownsRestart("d1"))
}

func testAbandonedPipelineLosesTheRecord() {
var owners = DownloadRestartOwners()
let token = owners.claimPipeline("d1")
owners.abandonPipeline("d1")
XCTAssertFalse(owners.ownsPipeline("d1", token))
XCTAssertFalse(owners.ownsRestart("d1"))
}

func testScheduledRetryOwnsTheRestartUntilItEnds() {
var owners = DownloadRestartOwners()
owners.retryScheduled("d1", firesAt: Date())
XCTAssertTrue(owners.ownsRestart("d1"))
XCTAssertFalse(owners.hasPipeline("d1"))
owners.retryEnded("d1")
XCTAssertFalse(owners.ownsRestart("d1"))
}

func testBackgroundWakeWaitsForPipelinesAndRetriesDueBeforeItsDeadline() {
let now = Date()
var owners = DownloadRestartOwners()
XCTAssertFalse(owners.handoffPending(by: now.addingTimeInterval(20)))
owners.retryScheduled("d1", firesAt: now.addingTimeInterval(40))
XCTAssertFalse(owners.handoffPending(by: now.addingTimeInterval(20)))
owners.retryScheduled("d2", firesAt: now.addingTimeInterval(10))
XCTAssertTrue(owners.handoffPending(by: now.addingTimeInterval(20)))
owners.retryEnded("d2")
_ = owners.claimPipeline("d3")
XCTAssertTrue(owners.handoffPending(by: now))
}

// MARK: Reconnect

func testPipelineStillRunningInThisProcessIsNotStartedAgain() {
XCTAssertEqual(plan(.fetchingAssets, restartOwned: true), Plan(dropTask: false, requeue: false))
// After a relaunch nothing owns it, so it starts again.
XCTAssertEqual(plan(.fetchingAssets), Plan(dropTask: false, requeue: true))
}

func testTransferStartedDuringTheLiveTaskReadIsKept() {
// The read can't see a task created after it began.
XCTAssertEqual(plan(.downloading, task: 7, taskBeforeRead: nil, live: []), Plan(dropTask: false, requeue: false))
XCTAssertEqual(plan(.downloading, task: 7, taskBeforeRead: 3, live: [3]), Plan(dropTask: false, requeue: false))
}

func testLostTransferIsDroppedAndStartedAgain() {
XCTAssertEqual(plan(.downloading, task: 3, taskBeforeRead: 3, live: [5]), Plan(dropTask: true, requeue: true))
XCTAssertEqual(plan(.downloading, task: 3, taskBeforeRead: 3, live: [3]), Plan(dropTask: false, requeue: false))
}

func testRetryWaitingOutItsBackOffKeepsTheRecord() {
XCTAssertEqual(plan(.downloading, restartOwned: true), Plan(dropTask: false, requeue: false))
XCTAssertEqual(plan(.downloading), Plan(dropTask: false, requeue: true))
}

func testPauseRoundTripKeepsItsTask() {
XCTAssertEqual(plan(.paused, task: 3, taskBeforeRead: 3, live: [], pausing: true),
Plan(dropTask: false, requeue: false))
XCTAssertEqual(plan(.paused, task: 3, taskBeforeRead: 3, live: []), Plan(dropTask: true, requeue: false))
}

// MARK: Pipeline failures

func testRequestWithoutAnAnswerIsRetried() {
XCTAssertTrue(DownloadManager.isTransientPipelineFailure(HTTPError.network(underlying: URLError(.networkConnectionLost))))
XCTAssertTrue(DownloadManager.isTransientPipelineFailure(HTTPError.network(underlying: URLError(.timedOut))))
XCTAssertTrue(DownloadManager.isTransientPipelineFailure(URLError(.notConnectedToInternet)))
XCTAssertFalse(DownloadManager.isTransientPipelineFailure(HTTPError.network(underlying: URLError(.cancelled))))
XCTAssertFalse(DownloadManager.isTransientPipelineFailure(APIv2Error.httpStatus(500)))
XCTAssertFalse(DownloadManager.isTransientPipelineFailure(HTTPError.requestIdentityChanged))
}

// MARK: Transfer rate

private let start = Date(timeIntervalSinceReferenceDate: 1_000)

private func sample(_ bytes: Int64, at seconds: TimeInterval) -> DownloadManager.TransferRateSample {
DownloadManager.TransferRateSample(bytes: bytes, at: start.addingTimeInterval(seconds))
}

func testRateComesFromBytesMovedSinceTheLastSample() throws {
let first = DownloadManager.nextTransferRate(sample: sample(0, at: 0), rate: nil,
bytes: 10_000_000, now: start.addingTimeInterval(1))
XCTAssertEqual(try XCTUnwrap(first.rate), 10_000_000, accuracy: 1)
XCTAssertEqual(first.sample, sample(10_000_000, at: 1))
}

func testRateStartsOverAfterAGap() {
// 60 MB over a 30 s suspension would read as 2 MB/s whatever the
// transfer is doing now.
let next = DownloadManager.nextTransferRate(sample: sample(0, at: 0), rate: 20_000_000,
bytes: 60_000_000, now: start.addingTimeInterval(30))
XCTAssertNil(next.rate)
XCTAssertEqual(next.sample, sample(60_000_000, at: 30))
}

func testRateResetsWhenAResumeReportsFewerBytes() {
let next = DownloadManager.nextTransferRate(sample: sample(50_000_000, at: 0), rate: 8_000_000,
bytes: 1_000_000, now: start.addingTimeInterval(1))
XCTAssertNil(next.rate)
}

func testCallbacksCloserThanTheSampleIntervalKeepTheWindow() {
let next = DownloadManager.nextTransferRate(sample: sample(0, at: 0), rate: 5_000_000,
bytes: 1_000_000, now: start.addingTimeInterval(0.1))
XCTAssertEqual(next.sample, sample(0, at: 0))
XCTAssertEqual(next.rate, 5_000_000)
}

func testRetriesResetOnlyOnceATransferPassesItsFailedPeak() {
let mib: Int64 = 1 << 20
// An attempt failed at 100 MiB; the next one moves 2 MiB per sample.
var state = (retryCount: 4, furthest: 100 * mib)
var written = 100 * mib
for _ in 0..<4 {
written += 2 * mib
state = DownloadManager.recoveryProgress(retryCount: state.retryCount, furthest: state.furthest, written: written)
}
XCTAssertEqual(state.retryCount, 4, "8 MiB past the peak isn't recovery yet")
XCTAssertEqual(state.furthest, 100 * mib)
written += 2 * mib
state = DownloadManager.recoveryProgress(retryCount: state.retryCount, furthest: state.furthest, written: written)
XCTAssertEqual(state.retryCount, 0)
XCTAssertEqual(state.furthest, written)
}

func testRestartFromZeroKeepsItsRetriesUntilItPassesThePeak() {
let mib: Int64 = 1 << 20
let state = DownloadManager.recoveryProgress(retryCount: 2, furthest: 500 * mib, written: 400 * mib)
XCTAssertEqual(state.retryCount, 2)
XCTAssertEqual(state.furthest, 500 * mib)
}
}
12 changes: 6 additions & 6 deletions iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.898",
"green" : "0.533",
"red" : "0.118"
"blue" : "0.929",
"green" : "0.929",
"red" : "0.929"
}
},
"idiom" : "universal"
Expand All @@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.898",
"green" : "0.533",
"red" : "0.118"
"blue" : "0.929",
"green" : "0.929",
"red" : "0.929"
}
},
"idiom" : "universal"
Expand Down
Loading
Loading