mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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.
298 lines
13 KiB
Swift
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")
|
|
}
|
|
}
|