Files
proton-stream/ios/ProtonStreamTests/UiLogicTests.swift
T
nvrl bb5a6638c4 feat(core,meta,app,android,ios): title logos and runtimes
ani.zip sent each series' TVDB clear logo and every episode's running
time from the start, and neither was kept. Enrichment now stores the logo
in title_metadata.logo_url (kept across a refresh of the same entry, like
the fanart) and episode runtimes in episode_metadata.runtime, TVDB's
first and AniDB's length after it. AniList's duration lands in
TitleDetails::runtime; TMDB episode runtimes are kept too.

Schema v13 adds the two columns and marks AniList titles un-enriched
without deleting anything. TitleDetails::revision lets details written
before runtime existed refetch by id while the old ones stay on screen,
instead of blanking them in the migration as v10 did.

Clear logos are padded to a square, so each client crops them to their
visible pixels before drawing. The logo replaces the name on the
library's banner and the title page (not on a light theme's title page,
where lettering made for dark art can vanish). Episode rows show how long
they run, in place of the file size on Android and iOS; a film's runtime
joins its line of facts.
2026-10-29 16:11:24 +01:00

298 lines
13 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 testThePlayerCaptionsAnEpisodeWithItsNameAndNeverItsFilename() {
var named = episode("e")
named.providerName = "Dog & Chainsaw"
XCTAssertEqual(named.shownName, "Dog & Chainsaw")
var bare = episode("e")
bare.name = "[Group] Show - S01E01.mkv"
bare.detail = bare.name
XCTAssertNil(bare.shownName)
bare.detail = ""
XCTAssertNil(bare.shownName)
}
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]
)
}
func testAPaddedLogoIsBoundedByItsLetteringAndNotItsHaze() {
let width = 10
var alpha = [UInt8](repeating: 0, count: width * 6)
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
for y in 1 ... 3 { for x in 2 ... 6 { alpha[y * width + x] = 255 } }
alpha[5 * width + 9] = 3
XCTAssertEqual(opaqueBounds(alpha: alpha, width: width), PixelBounds(left: 2, top: 1, right: 6, bottom: 3))
XCTAssertNil(opaqueBounds(alpha: [UInt8](repeating: 0, count: 16), width: 4))
}
func testARunningTimeReadsInHoursAndMinutes() {
XCTAssertEqual(formatRuntime(24), "24 min")
XCTAssertEqual(formatRuntime(120), "2 h")
XCTAssertEqual(formatRuntime(124), "2 h 4 min")
}
}