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