mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(ios): port the Android client to SwiftUI
The iOS client is now a screen-for-screen port of the Android app over the same pstr-android UniFFI surface, with no Rust changes: Library, title page, History, Shares (public links and Drive folders), Downloads and Settings, with the same strings, undo snackbars and two-pane layout on wide screens. - Theme from the engine's palette (Material role names, Inter, the same type ramp and corners), repainted live from the appearance settings. - Account sign-in with two-factor code, mailbox password and the Proton CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and watch-history sync every five minutes, after sign-in, on player close and when the app leaves the screen. - Player over libmpv: resume, progress saved every 5 s, autoplay with the up-next card, skip and auto-skip from the chapter plan, track, speed, volume and episode pickers, landscape lock, mini transport, and lock-screen controls with interruption and route-change handling. - Offline downloads with the Android record format, free-space floor, retries, transfer rate and the Wi-Fi-only policy; interrupted transfers resume at next launch. - Config, data and download state are excluded from backup, as Android's allowBackup=false: a restore would bring shares back without their Keychain secrets and give two devices one sync identity. Picture-in-picture, the share sheet and background downloads are not possible the same way on iOS; docs/IOS.md lists why and what replaces them. Adds XCTest ports of the Android unit tests, a `test` command to scripts/build-ios.sh, the app icon and its render script, and an iOS workflow on macos-15 that runs the tests and builds the unsigned .ipa.
This commit is contained in:
1 parent
da16ab0575
commit
ed05a1e723
38 files changed
+7546
-208
No files matched your search
@@ -0,0 +1,187 @@
|
||||
@testable import ProtonStream
|
||||
import XCTest
|
||||
|
||||
final class DownloadStateStoreTests: XCTestCase {
|
||||
private var file: URL!
|
||||
private var store: DownloadStateStore!
|
||||
|
||||
override func setUp() {
|
||||
file = FileManager.default.temporaryDirectory.appendingPathComponent("downloads-\(UUID().uuidString).json")
|
||||
store = DownloadStateStore(file: file)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(at: file)
|
||||
}
|
||||
|
||||
private func record(
|
||||
shareId: String = "share-1",
|
||||
linkId: String = "link-1",
|
||||
label: String = "S01E01.mkv",
|
||||
status: String = RetainedDownload.statusRunning,
|
||||
error: String? = nil
|
||||
) -> RetainedDownload {
|
||||
RetainedDownload(
|
||||
shareId: shareId,
|
||||
volumeId: "volume-1",
|
||||
linkId: linkId,
|
||||
label: label,
|
||||
downloaded: 4 * 1024 * 1024,
|
||||
total: 761 * 1024 * 1024,
|
||||
status: status,
|
||||
error: error
|
||||
)
|
||||
}
|
||||
|
||||
private func baseJson() -> [String: Any] {
|
||||
[
|
||||
"share": "share-1",
|
||||
"volume": "volume-1",
|
||||
"link": "link-1",
|
||||
"label": "S01E01.mkv",
|
||||
"downloaded": 0,
|
||||
"total": 0,
|
||||
"status": RetainedDownload.statusQueued,
|
||||
]
|
||||
}
|
||||
|
||||
/// A row as an older or another writer left it, read by a fresh store.
|
||||
private func writeRaw(_ rows: [String: String]) throws {
|
||||
try JSONEncoder().encode(rows).write(to: file)
|
||||
store = DownloadStateStore(file: file)
|
||||
}
|
||||
|
||||
private func encoded(_ json: [String: Any]) throws -> String {
|
||||
String(decoding: try JSONSerialization.data(withJSONObject: json), as: UTF8.self)
|
||||
}
|
||||
|
||||
func testARecordSurvivesARoundTripUnchanged() {
|
||||
let original = record()
|
||||
store.put(original)
|
||||
XCTAssertEqual(store.get("share-1", "link-1"), original)
|
||||
}
|
||||
|
||||
func testARecordSurvivesARestartUnchanged() {
|
||||
let original = record()
|
||||
store.put(original)
|
||||
XCTAssertEqual(DownloadStateStore(file: file).get("share-1", "link-1"), original)
|
||||
}
|
||||
|
||||
func testAnAbsentRecordReadsAsNilRatherThanADefault() {
|
||||
XCTAssertNil(store.get("share-1", "link-1"))
|
||||
}
|
||||
|
||||
func testAnErrorMessageRoundTripsAndANilOneStaysNil() {
|
||||
store.put(record(error: "no space left on device"))
|
||||
XCTAssertEqual(store.get("share-1", "link-1")?.error, "no space left on device")
|
||||
store.put(record(error: nil))
|
||||
XCTAssertNil(store.get("share-1", "link-1")?.error)
|
||||
}
|
||||
|
||||
func testABlankErrorIsNotReadAsAFailureMessage() throws {
|
||||
var json = baseJson()
|
||||
json["error"] = ""
|
||||
try writeRaw([downloadKey("share-1", "link-1"): encoded(json)])
|
||||
XCTAssertNil(store.get("share-1", "link-1")?.error)
|
||||
}
|
||||
|
||||
func testARecordWithNoLabelIsCaptionedWithItsLinkId() throws {
|
||||
var json = baseJson()
|
||||
json["label"] = nil
|
||||
try writeRaw([downloadKey("share-1", "link-1"): encoded(json)])
|
||||
XCTAssertEqual(store.get("share-1", "link-1")?.label, "link-1")
|
||||
}
|
||||
|
||||
func testACorruptRowIsSkippedRatherThanFailingTheWholeList() throws {
|
||||
try writeRaw([
|
||||
downloadKey("share-1", "link-1"): DownloadStateStore.encode(record(label: "good.mkv")),
|
||||
downloadKey("share-1", "link-broken"): "{ not json",
|
||||
])
|
||||
XCTAssertEqual(store.records().map(\.label), ["good.mkv"])
|
||||
}
|
||||
|
||||
func testRecordsAreOrderedByLabelWithoutRegardToCase() {
|
||||
store.put(record(linkId: "a", label: "beta.mkv"))
|
||||
store.put(record(linkId: "b", label: "Alpha.mkv"))
|
||||
XCTAssertEqual(store.records().map(\.label), ["Alpha.mkv", "beta.mkv"])
|
||||
}
|
||||
|
||||
func testRemovingOneShareLeavesTheOthersAlone() {
|
||||
store.put(record(shareId: "share-1", linkId: "a"))
|
||||
store.put(record(shareId: "share-2", linkId: "b"))
|
||||
store.removeShare("share-1")
|
||||
XCTAssertEqual(store.records().map(\.shareId), ["share-2"])
|
||||
}
|
||||
|
||||
func testClearForgetsEverything() {
|
||||
store.put(record(linkId: "a"))
|
||||
store.put(record(linkId: "b"))
|
||||
store.clear()
|
||||
XCTAssertTrue(store.records().isEmpty)
|
||||
}
|
||||
|
||||
func testTheKeySeparatesShareFromLinkSoIdsCannotRunTogether() {
|
||||
store.put(record(shareId: "a", linkId: "b-c"))
|
||||
store.put(record(shareId: "a-b", linkId: "c"))
|
||||
XCTAssertEqual(store.records().count, 2)
|
||||
}
|
||||
|
||||
func testAnUpdateThatReturnsNilLeavesTheRecordAlone() {
|
||||
store.put(record(status: RetainedDownload.statusPaused))
|
||||
store.update("share-1", "link-1") { _ in nil }
|
||||
XCTAssertEqual(store.get("share-1", "link-1")?.status, RetainedDownload.statusPaused)
|
||||
}
|
||||
}
|
||||
|
||||
final class TransferRateTests: XCTestCase {
|
||||
func testTheFirstSampleHasNoRateToReport() {
|
||||
var rate = TransferRate()
|
||||
XCTAssertEqual(rate.sample(4 * 1024 * 1024, now: 1000), 0)
|
||||
}
|
||||
|
||||
func testASteadyTransferReportsItsRate() {
|
||||
var rate = TransferRate()
|
||||
_ = rate.sample(0, now: 0)
|
||||
XCTAssertEqual(rate.sample(1_000_000, now: 1000), 1_000_000)
|
||||
XCTAssertEqual(rate.sample(2_000_000, now: 2000), 1_000_000)
|
||||
}
|
||||
|
||||
func testOneSlowBlockMovesTheRateWithoutHalvingIt() {
|
||||
var rate = TransferRate()
|
||||
_ = rate.sample(0, now: 0)
|
||||
_ = rate.sample(1_000_000, now: 1000)
|
||||
let after = rate.sample(2_000_000, now: 3000)
|
||||
XCTAssertTrue((600_000 ... 999_999).contains(after))
|
||||
}
|
||||
|
||||
func testAResumedDownloadStartingBelowTheLastSampleStartsOver() {
|
||||
var rate = TransferRate()
|
||||
_ = rate.sample(0, now: 0)
|
||||
_ = rate.sample(1_000_000, now: 1000)
|
||||
XCTAssertEqual(rate.sample(0, now: 2000), 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
final class DownloadStatusLineTests: XCTestCase {
|
||||
func testARunningDownloadSaysHowFarAndHowFast() {
|
||||
let download = RetainedDownload(
|
||||
shareId: "s", volumeId: "v", linkId: "l", label: "E01",
|
||||
downloaded: 120 * 1024 * 1024, total: 1288 * 1024 * 1024,
|
||||
status: RetainedDownload.statusRunning, bytesPerSecond: 2_516_582
|
||||
)
|
||||
XCTAssertEqual(downloadStatusLine(download), "Running · 120.0 MiB of 1.3 GiB · 2.4 MiB/s")
|
||||
}
|
||||
|
||||
func testAQueuedDownloadSaysOnlyThat() {
|
||||
let download = RetainedDownload(shareId: "s", volumeId: "v", linkId: "l", label: "E01")
|
||||
XCTAssertEqual(downloadStatusLine(download), "Queued")
|
||||
}
|
||||
|
||||
func testAPausedDownloadGivesNoRate() {
|
||||
let download = RetainedDownload(
|
||||
shareId: "s", volumeId: "v", linkId: "l", label: "E01",
|
||||
downloaded: 1024, total: 2048, status: RetainedDownload.statusPaused, bytesPerSecond: 500
|
||||
)
|
||||
XCTAssertEqual(downloadStatusLine(download), "Paused · 1.0 KiB of 2.0 KiB")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
@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: []
|
||||
)
|
||||
}
|
||||
|
||||
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), "Watch history syncs with 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),
|
||||
"Watch history 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]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user