Files
proton-stream/ios/ProtonStreamTests/UiLogicTests.swift
T
nvrl a306d02cf1 feat(core,app,android,ios): keep a show in Continue watching after an episode ends
Continue watching listed only part-watched episodes, so finishing one -
the most ordinary moment to want the next - dropped the show from the
shelf. Title::continue_with now reads the episode touched last: a
part-watched one is resumed, a finished one offers the episode after it
("Next: S01E04"), and one reset to the start (what leaving the shelf
does) keeps the title off. A few seconds of an episode are passed over.

The phones computed their own shelf from episode records. The bridge now
exports the engine's choice as TitleRecord.continue_index, and Play on a
title lands there on every client.

Undo on the phones re-saved a watch state with a fresh time, which both
reordered the shelf and could not express "never played". A new
restore_watch_state puts the state back as it was read.
2026-10-12 22:05:05 +02:00

270 lines
12 KiB
Swift

@testable import ProtonStream
import SwiftUI
import XCTest
private func episode(_ link: String, watched: Bool = false, progress: Double? = nil, lastPlayed: Int64 = 0, number: UInt32? = nil) -> EpisodeRecord {
EpisodeRecord(
shareId: "s", volumeId: "v", linkId: link, name: "\(link).mkv", label: link, detail: link,
season: 1, number: number, size: nil, progress: progress, resumeAt: progress.map { $0 * 1000 },
watched: watched, offline: false, providerName: nil, providerOverview: nil, stillUrl: nil,
airDate: nil, lastPlayed: lastPlayed
)
}
private func title(_ key: String, _ episodes: [EpisodeRecord]) -> TitleRecord {
TitleRecord(
key: key, name: key, year: nil, kind: .series, watchedCount: 0,
episodeCount: UInt64(episodes.count), canonicalName: nil, originalName: nil, overview: nil,
metadataProvider: nil, metadataId: nil, metadataYear: nil, metadataKind: nil, posterUrl: nil,
backdropUrl: nil, rating: nil, genres: [], providerEpisodeCount: nil, externalUrl: nil,
manualMatch: false, seasons: [SeasonRecord(number: 1, label: "Season 1", episodes: episodes)],
displayName: key, wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
directors: [], tags: [], airing: false, nextEpisode: nil, nextAiringAt: nil,
franchise: [], continueIndex: nil
)
}
final class IncomingShareLinkTests: XCTestCase {
private let link = "https://drive.proton.me/urls/ABC123#s3cr3t"
func testTheSchemeSwappedLinkKeepsItsFragment() {
XCTAssertEqual(shareLink(from: URL(string: "protonstream://drive.proton.me/urls/ABC123#s3cr3t")!), link)
}
func testTheAddFormCarriesTheWholeLink() {
let encoded = link.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
XCTAssertEqual(shareLink(from: URL(string: "protonstream://add?url=\(encoded)")!), link)
}
func testAnythingButAShareLinkIsIgnored() {
XCTAssertNil(shareLink(from: URL(string: "https://drive.proton.me/urls/ABC123#s3cr3t")!))
XCTAssertNil(shareLink(from: URL(string: "protonstream://example.com/urls/ABC")!))
XCTAssertNil(shareLink(from: URL(string: "protonstream://add?url=http%3A%2F%2Fdrive.proton.me%2Furls%2FA")!))
}
func testALinkIsFoundInsideTextAndStopsAtItsQuotes() {
XCTAssertEqual(shareLink(in: "Watch this: \"\(link)\" — enjoy"), link)
XCTAssertNil(shareLink(in: "no link here"))
}
}
final class AccountUiTests: XCTestCase {
func testTheSyncLineSaysHowLongAgoAndWhatCameIn() {
let zero = Date(timeIntervalSince1970: 0)
XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Library, settings and watch history sync through your Drive")
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(30)), "Synced just now")
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(300)), "Synced 5 min ago")
XCTAssertEqual(
syncLine(AccountUiState(syncedAt: zero, applied: 3), now: zero.addingTimeInterval(7200)),
"Synced 2 h ago · 3 from other devices"
)
}
func testAFailedSyncIsSaidInPlaceOfWhenItLastWorked() {
let zero = Date(timeIntervalSince1970: 0)
XCTAssertEqual(
syncLine(AccountUiState(syncedAt: zero, syncError: "offline"), now: zero),
"Did not sync: offline"
)
}
func testTheVerificationPageMayOnlyNavigateWithinProtonOverHttps() {
XCTAssertTrue(isProtonPage(scheme: "https", host: "verify.proton.me"))
XCTAssertTrue(isProtonPage(scheme: "https", host: "proton.me"))
XCTAssertFalse(isProtonPage(scheme: "http", host: "verify.proton.me"))
XCTAssertFalse(isProtonPage(scheme: "https", host: "proton.me.example.com"))
XCTAssertFalse(isProtonPage(scheme: "https", host: "notproton.me"))
XCTAssertFalse(isProtonPage(scheme: "https", host: nil))
}
}
final class ArtworkTests: XCTestCase {
func testInitialsSkipTheLowerCaseParticlesOfARomanisedName() {
XCTAssertEqual(initials("Oshi no Ko"), "OK")
XCTAssertEqual(initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."), "AH")
}
func testAOneWordNameHasOneInitial() {
XCTAssertEqual(initials("Akira"), "A")
}
func testANameWithNoCapitalsFallsBackToItsFirstTwoWords() {
XCTAssertEqual(initials("blue world order"), "BW")
}
func testPunctuationAndSeparatorsAreNotWords() {
XCTAssertEqual(initials("Steins;Gate - 2"), "S2")
XCTAssertEqual(initials(" - "), "")
}
/// The placeholder's colour comes from the name's hash, so a title is the
/// same colour here as on Android only if the hash is the JVM's.
func testTheNameHashIsTheJvmStringHash() {
XCTAssertEqual(javaHash(""), 0)
XCTAssertEqual(javaHash("Akira"), 63_321_038)
XCTAssertEqual(javaHash("Oshi no Ko"), 539_783_784)
}
}
final class HistoryTests: XCTestCase {
func testHistoryIsEveryStartedOrFinishedEpisodeNewestFirstAcrossTitles() {
let titles = [
title("a", [
episode("a1", watched: true, progress: 1, lastPlayed: 30),
// Marked unwatched again: a row at zero, which is not a play.
episode("a2", progress: 0, lastPlayed: 40),
episode("a3"),
]),
title("b", [episode("b1", progress: 0.4, lastPlayed: 50)]),
]
let played = history(titles)
XCTAssertEqual(played.map(\.episode.linkId), ["b1", "a1"])
XCTAssertEqual(played.last?.index, 0)
}
func testDaysAreHeadedRelativeToTodayThenByDate() {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
let english = Locale(identifier: "en_GB")
func day(_ year: Int, _ month: Int, _ day: Int) -> Date {
calendar.date(from: DateComponents(year: year, month: month, day: day))!
}
let today = day(2026, 10, 3) // a Saturday
XCTAssertEqual(dayHeading(today, today: today, calendar: calendar, locale: english), "Today")
XCTAssertEqual(dayHeading(day(2026, 10, 2), today: today, calendar: calendar, locale: english), "Yesterday")
XCTAssertEqual(dayHeading(day(2026, 9, 28), today: today, calendar: calendar, locale: english), "Monday")
XCTAssertEqual(dayHeading(day(2026, 9, 26), today: today, calendar: calendar, locale: english), "26 September")
XCTAssertEqual(dayHeading(day(2025, 10, 3), today: today, calendar: calendar, locale: english), "3 October 2025")
}
}
final class CommonTests: XCTestCase {
func testPlayLandsOnTheLastPartWatchedEpisodeThenTheFirstUnwatched() {
XCTAssertEqual(nextUpIndex([episode("1", watched: true), episode("2", progress: 0.5, lastPlayed: 10), episode("3", progress: 0.2, lastPlayed: 20)]), 2)
XCTAssertEqual(nextUpIndex([episode("1", watched: true), episode("2"), episode("3")]), 1)
XCTAssertEqual(nextUpIndex([episode("1", watched: true)]), 0)
XCTAssertEqual(nextUpIndex([]), 0)
}
func testBytesAreWrittenInBinaryUnitsToOnePlace() {
XCTAssertEqual(formatBytes(512), "512 bytes")
XCTAssertEqual(formatBytes(1536), "1.5 KiB")
XCTAssertEqual(formatBytes(4 * 1024 * 1024), "4.0 MiB")
XCTAssertEqual(formatBytes(3 * 1024 * 1024 * 1024 / 2), "1.5 GiB")
}
func testAnEpisodeIsHeadedByItsNameAndNumberBeforeItsFilename() {
var named = episode("e", number: 3)
named.providerName = "Mother and Children"
XCTAssertEqual(named.heading(0), "3. Mother and Children")
XCTAssertEqual(episode("e", number: 7).heading(0), "7. e")
var bare = episode("e")
bare.detail = bare.name
XCTAssertEqual(bare.heading(0), "e")
bare.number = nil
bare.name = ".mkv"
bare.detail = bare.name
XCTAssertEqual(bare.heading(2), "Episode 3")
bare.name = "e.mkv"
bare.detail = bare.name
bare.number = 4
XCTAssertEqual(bare.heading(0), "Episode 4")
}
func testEpisodesAreNumberedBySeasonThenByPlaceInTheList() {
var numbered = episode("e", number: 38)
numbered.season = 3
XCTAssertEqual(numbered.numbering(nil, 0), "S03E38")
numbered.season = nil
XCTAssertEqual(numbered.numbering(nil, 0), "E38")
XCTAssertEqual(numbered.numbering(2, 0), "S02E38")
numbered.number = nil
XCTAssertEqual(numbered.numbering(nil, 3), "#4")
}
func testClockReadsMinutesThenHours() {
XCTAssertEqual(clock(0), "0:00")
XCTAssertEqual(clock(724), "12:04")
XCTAssertEqual(clock(3725), "1:02:05")
}
}
final class LanguagePickerTests: XCTestCase {
func testAStoredTagReadsAsItsLanguageInEitherIsoForm() {
XCTAssertEqual(languageLabel("jpn"), "Japanese")
XCTAssertEqual(languageLabel("ja"), "Japanese")
XCTAssertEqual(languageLabel("deu"), "German")
XCTAssertEqual(languageLabel("ger"), "German")
}
func testARegionSuffixAndCapitalsDoNotHideTheLanguage() {
XCTAssertEqual(languageLabel("PT-br"), "Portuguese")
}
func testATagNotOnTheListIsShownAsTypedAndBlankIsNoPreference() {
XCTAssertEqual(languageLabel("tgl"), "tgl")
XCTAssertEqual(languageLabel(nil), "No preference")
XCTAssertEqual(languageLabel(" "), "No preference")
}
func testEveryListedLanguageHasATwoLetterForm() {
for language in languages {
XCTAssertEqual(languageLabel(twoLetter[language.code]), language.name, language.code)
}
}
}
final class ThemeTests: XCTestCase {
/// The same mapping as Android's `schemeOf`: a role that drifted to a
/// different palette colour on one client is the two clients disagreeing.
func testEveryRoleIsTheSamePaletteColourAndroidUses() {
let palette = protonFallback
let scheme = Scheme(palette)
XCTAssertEqual(scheme.primary, argb(palette.accent))
XCTAssertEqual(scheme.onPrimary, argb(palette.onAccent))
XCTAssertEqual(scheme.secondary, argb(palette.accentAlt))
XCTAssertEqual(scheme.secondaryContainer, argb(palette.accentDim))
XCTAssertEqual(scheme.tertiary, argb(palette.accentAlt))
XCTAssertEqual(scheme.background, argb(palette.background))
XCTAssertEqual(scheme.surface, argb(palette.surface))
XCTAssertEqual(scheme.onSurface, argb(palette.text))
XCTAssertEqual(scheme.onSurfaceVariant, argb(palette.muted))
XCTAssertEqual(scheme.inverseSurface, argb(palette.text))
XCTAssertEqual(scheme.inverseOnSurface, argb(palette.background))
XCTAssertEqual(scheme.error, argb(palette.danger))
XCTAssertEqual(scheme.errorContainer, argb(palette.dangerDim))
XCTAssertEqual(scheme.outline, argb(palette.muted))
XCTAssertEqual(scheme.outlineVariant, argb(palette.border))
XCTAssertEqual(scheme.surfaceContainerLowest, argb(palette.sunken))
XCTAssertEqual(scheme.surfaceContainer, argb(palette.card))
XCTAssertEqual(scheme.surfaceContainerHigh, argb(palette.cardHover))
XCTAssertEqual(scheme.surfaceContainerHighest, argb(palette.elevated))
XCTAssertFalse(scheme.light)
}
func testArgbUnpacksEveryChannel() {
let colour = UIColor(argb(0x80FF_4000))
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
colour.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
XCTAssertEqual(red, 1, accuracy: 0.001)
XCTAssertEqual(green, 64 / 255, accuracy: 0.001)
XCTAssertEqual(blue, 0, accuracy: 0.001)
XCTAssertEqual(alpha, 128 / 255, accuracy: 0.001)
}
/// Every style on one of the nine rungs, every rung used, and leading
/// derived from the size — Android's `TypeRampTest`.
func testEveryStyleIsOnTheDesktopRamp() {
let rungs: Set<CGFloat> = [26, 20, 18, 17, 15, 14, 13, 12, 11]
let used = Set(TypeRole.allCases.map(\.size))
XCTAssertEqual(used, rungs)
}
func testOnlyHeadingsAreSemibold() {
XCTAssertEqual(
Set(TypeRole.allCases.filter(\.semibold)),
[.displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge]
)
}
}