Phase 5: Nachladen, Startseiten-Layout
Nachladen - DownloadRefiller wählt aus, WELCHE Folgen fehlen; wie viele, rechnet die Regel-Engine. Getrennt, weil das Rechnen ohne Server auskommt und damit prüfbar ist. - In Ausstrahlungsreihenfolge, Specials in Staffel 0 bleiben außen vor. - Läuft nach dem Ansehen und beim Start, nicht auf einem Zeitgeber: eine App, die im Hintergrund unaufgefordert Gigabytes lädt, wäre eine unangenehme Überraschung auf der Mobilfunkrechnung. Startseiten-Layout - Reihen ein-/ausblenden und per Bearbeiten-Modus umsortieren. - Die Anordnungslogik liegt als HubArrangement in PlexKit statt im App-Target — dort ist sie ohne Simulator testbar, und dort sitzen die unangenehmen Fälle: neue Reihen vom Server landen am Ende statt zu verschwinden, verschwundene aus der gespeicherten Liste stören nicht, ein doppelter Eintrag stürzt nicht ab. 11 Tests. 126 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ struct FlixyApp: App {
|
|||||||
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
|
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
|
||||||
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
|
@State private var homeLayout = HomeLayoutSettings()
|
||||||
@State private var downloads: DownloadCoordinator
|
@State private var downloads: DownloadCoordinator
|
||||||
|
|
||||||
private let modelContainer: ModelContainer
|
private let modelContainer: ModelContainer
|
||||||
@@ -57,6 +58,7 @@ struct FlixyApp: App {
|
|||||||
.environment(playbackSettings)
|
.environment(playbackSettings)
|
||||||
.environment(network)
|
.environment(network)
|
||||||
.environment(downloads)
|
.environment(downloads)
|
||||||
|
.environment(homeLayout)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
.flixyAccent(theme)
|
.flixyAccent(theme)
|
||||||
// Der eigentliche Schalter für „nur dunkel" steht in der
|
// Der eigentliche Schalter für „nur dunkel" steht in der
|
||||||
@@ -67,6 +69,12 @@ struct FlixyApp: App {
|
|||||||
.task {
|
.task {
|
||||||
appDelegate.downloads = downloads
|
appDelegate.downloads = downloads
|
||||||
await downloads.restore()
|
await downloads.restore()
|
||||||
|
// Beim Start einmal prüfen: während die App weg war,
|
||||||
|
// können Folgen gesehen oder gelöscht worden sein.
|
||||||
|
await DownloadRefiller(
|
||||||
|
server: session.server,
|
||||||
|
coordinator: downloads
|
||||||
|
).refill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(modelContainer)
|
.modelContainer(modelContainer)
|
||||||
|
|||||||
@@ -2962,6 +2962,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"homeLayout.title": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Startseite"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Home screen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"homeLayout.explanation": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Schalte Reihen aus, die du nicht brauchst, und sortiere sie über „Bearbeiten“ um. Neue Reihen vom Server erscheinen am Ende."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Turn off rows you don't need and reorder them via Edit. New rows from the server appear at the end."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"homeLayout.empty": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Server liefert derzeit keine Reihen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The server isn't providing any rows right now."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.1"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
import FlixyDownloads
|
||||||
|
import PlexKit
|
||||||
|
|
||||||
|
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
|
||||||
|
///
|
||||||
|
/// Die Regel-Engine rechnet aus, *wie viele* Folgen je Serie fehlen; hier wird
|
||||||
|
/// entschieden, *welche*. Die Trennung ist Absicht: das Rechnen braucht keinen
|
||||||
|
/// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig.
|
||||||
|
@MainActor
|
||||||
|
struct DownloadRefiller {
|
||||||
|
let server: PlexServer?
|
||||||
|
let coordinator: DownloadCoordinator
|
||||||
|
|
||||||
|
/// Lädt nach, was den Regeln nach fehlt.
|
||||||
|
///
|
||||||
|
/// Läuft nach dem Ansehen einer Folge und beim Start — nicht auf einem
|
||||||
|
/// Zeitgeber. Eine App, die im Hintergrund unaufgefordert Gigabytes lädt,
|
||||||
|
/// wäre eine unangenehme Überraschung auf der Mobilfunkrechnung.
|
||||||
|
@discardableResult
|
||||||
|
func refill() async -> Int {
|
||||||
|
guard let server, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 }
|
||||||
|
|
||||||
|
let needed = coordinator.rules.seriesNeedingRefill(in: coordinator.items)
|
||||||
|
guard !needed.isEmpty else { return 0 }
|
||||||
|
|
||||||
|
var added = 0
|
||||||
|
let service = DownloadService(server: server, coordinator: coordinator)
|
||||||
|
|
||||||
|
for (seriesKey, count) in needed {
|
||||||
|
let candidates = await nextUnwatchedEpisodes(
|
||||||
|
inSeries: seriesKey,
|
||||||
|
count: count,
|
||||||
|
server: server
|
||||||
|
)
|
||||||
|
|
||||||
|
for episode in candidates {
|
||||||
|
// Die Kinderliste bringt keine Dateiangaben mit — ohne sie
|
||||||
|
// ließe sich weder das Speicherlimit prüfen noch ein
|
||||||
|
// Fortschritt anzeigen.
|
||||||
|
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
|
||||||
|
guard let plan = service.plan(for: detailed) else { continue }
|
||||||
|
if (try? coordinator.enqueue(plan)) != nil { added += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die nächsten ungesehenen Folgen einer Serie, die noch nicht offline sind.
|
||||||
|
///
|
||||||
|
/// In Ausstrahlungsreihenfolge, nicht nach Beliebtheit oder Zufall: wer
|
||||||
|
/// drei Folgen vorhalten lässt, will die drei, die als Nächstes dran sind.
|
||||||
|
private func nextUnwatchedEpisodes(
|
||||||
|
inSeries seriesKey: String,
|
||||||
|
count: Int,
|
||||||
|
server: PlexServer
|
||||||
|
) async -> [PlexItem] {
|
||||||
|
guard let seasons = try? await server.children(of: seriesKey) else { return [] }
|
||||||
|
|
||||||
|
let offlineKeys = Set(coordinator.items.map(\.ratingKey))
|
||||||
|
var result: [PlexItem] = []
|
||||||
|
|
||||||
|
for season in seasons.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
|
||||||
|
// Specials führt Plex als Staffel 0. Sie gehören nicht in den
|
||||||
|
// Vorrat für „was kommt als Nächstes".
|
||||||
|
guard (season.index ?? 0) > 0 else { continue }
|
||||||
|
guard let episodes = try? await server.children(of: season.ratingKey) else { continue }
|
||||||
|
|
||||||
|
for episode in episodes.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
|
||||||
|
guard !episode.isWatched, !offlineKeys.contains(episode.ratingKey) else { continue }
|
||||||
|
result.append(episode)
|
||||||
|
if result.count >= count { return result }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import PlexKit
|
||||||
|
|
||||||
|
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
|
||||||
|
///
|
||||||
|
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
|
||||||
|
/// `HubArrangement` in PlexKit — dort ist es reine Rechnung und ohne Simulator
|
||||||
|
/// prüfbar, und genau dort liegen die kniffligen Fälle: neue Reihen vom
|
||||||
|
/// Server, verschwundene aus der gespeicherten Liste.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class HomeLayoutSettings {
|
||||||
|
private enum Key {
|
||||||
|
static let hidden = "flixy.home.hiddenHubs"
|
||||||
|
static let order = "flixy.home.hubOrder"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private(set) var arrangement: HubArrangement
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.arrangement = HubArrangement(
|
||||||
|
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
|
||||||
|
order: defaults.stringArray(forKey: Key.order) ?? []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHidden(_ identifier: String) -> Bool {
|
||||||
|
arrangement.hidden.contains(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setHidden(_ hidden: Bool, for identifier: String) {
|
||||||
|
if hidden {
|
||||||
|
arrangement.hidden.insert(identifier)
|
||||||
|
} else {
|
||||||
|
arrangement.hidden.remove(identifier)
|
||||||
|
}
|
||||||
|
defaults.set(Array(arrangement.hidden), forKey: Key.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setOrder(_ identifiers: [String]) {
|
||||||
|
arrangement.order = identifiers
|
||||||
|
defaults.set(identifiers, forKey: Key.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(to hubs: [PlexHub]) -> [PlexHub] {
|
||||||
|
arrangement.apply(to: hubs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Zeilen für den Einstellungsbildschirm.
|
||||||
|
func rows(from hubs: [PlexHub]) -> [HomeRow] {
|
||||||
|
arrangement.arrangedIdentifiers(from: hubs).compactMap { identifier in
|
||||||
|
guard let hub = hubs.first(where: { $0.hubIdentifier == identifier }) else { return nil }
|
||||||
|
return HomeRow(id: identifier, title: hub.title, isHidden: isHidden(identifier))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Reihe im Einstellungsbildschirm.
|
||||||
|
struct HomeRow: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
var isHidden: Bool
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import PlexKit
|
|||||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
@State private var state: LoadState<[PlexHub]> = .loading
|
@State private var state: LoadState<[PlexHub]> = .loading
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -22,7 +23,8 @@ struct HomeView: View {
|
|||||||
Button("action.retry") { Task { await load() } }
|
Button("action.retry") { Task { await load() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
case .loaded(let hubs):
|
case .loaded(let allHubs):
|
||||||
|
let hubs = layout.apply(to: allHubs)
|
||||||
if hubs.isEmpty {
|
if hubs.isEmpty {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("home.empty.title", systemImage: "film.stack")
|
Label("home.empty.title", systemImage: "film.stack")
|
||||||
|
|||||||
@@ -381,8 +381,13 @@ final class PlayerModel {
|
|||||||
case .reachedEnd:
|
case .reachedEnd:
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
hasReachedEnd = true
|
hasReachedEnd = true
|
||||||
// Grundlage fürs automatische Löschen nach dem Ansehen.
|
// Grundlage fürs automatische Löschen nach dem Ansehen —
|
||||||
|
// und der Auslöser fürs Nachladen: der Vorrat ist jetzt
|
||||||
|
// um eine Folge kleiner.
|
||||||
downloads.markWatched(ratingKey: request.ratingKey)
|
downloads.markWatched(ratingKey: request.ratingKey)
|
||||||
|
Task { [server, downloads] in
|
||||||
|
await DownloadRefiller(server: server, coordinator: downloads).refill()
|
||||||
|
}
|
||||||
case .tracksResolved(let audio, let subtitle):
|
case .tracksResolved(let audio, let subtitle):
|
||||||
audioTracks = audio
|
audioTracks = audio
|
||||||
subtitleTracks = subtitle
|
subtitleTracks = subtitle
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlixyDesign
|
||||||
|
import PlexKit
|
||||||
|
|
||||||
|
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
||||||
|
struct HomeLayoutView: View {
|
||||||
|
@Environment(FlixySession.self) private var session
|
||||||
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
|
||||||
|
@State private var rows: [HomeRow] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if rows.isEmpty {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("home.empty.title", systemImage: "rectangle.stack")
|
||||||
|
} description: {
|
||||||
|
Text("homeLayout.empty")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.flixyCanvas)
|
||||||
|
.navigationTitle(Text("homeLayout.title"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { EditButton() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var list: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach($rows) { $row in
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { !row.isHidden },
|
||||||
|
set: {
|
||||||
|
row.isHidden = !$0
|
||||||
|
layout.setHidden(!$0, for: row.id)
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text(row.title)
|
||||||
|
.foregroundStyle(Color.flixyTextPrimary)
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.flixySurface)
|
||||||
|
}
|
||||||
|
.onMove { indices, destination in
|
||||||
|
rows.move(fromOffsets: indices, toOffset: destination)
|
||||||
|
layout.setOrder(rows.map(\.id))
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("homeLayout.explanation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
defer { isLoading = false }
|
||||||
|
guard let server = session.server else { return }
|
||||||
|
// Die Reihen kommen vom Server — es gibt keine feste Liste, die sich
|
||||||
|
// vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche
|
||||||
|
// Mediatheken und Sammlungen angelegt sind.
|
||||||
|
let hubs = (try? await server.homeHubs()) ?? []
|
||||||
|
rows = layout.rows(from: hubs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,12 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("appearance.title", systemImage: "paintpalette")
|
Label("appearance.title", systemImage: "paintpalette")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
HomeLayoutView()
|
||||||
|
} label: {
|
||||||
|
Label("homeLayout.title", systemImage: "rectangle.stack")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadSection
|
downloadSection
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wie die Reihen der Startseite angeordnet werden.
|
||||||
|
///
|
||||||
|
/// Reine Rechnung ohne Speicher und ohne Oberfläche — die Stelle, an der die
|
||||||
|
/// zwei unangenehmen Fälle entschieden werden: was mit Reihen geschieht, die
|
||||||
|
/// der Server neu liefert, und was mit gespeicherten, die es nicht mehr gibt.
|
||||||
|
public struct HubArrangement: Sendable, Equatable {
|
||||||
|
/// Ausdrücklich ausgeblendete Reihen.
|
||||||
|
public var hidden: Set<String>
|
||||||
|
/// Die gewünschte Reihenfolge. Reihen, die hier fehlen, landen am Ende.
|
||||||
|
public var order: [String]
|
||||||
|
|
||||||
|
public init(hidden: Set<String> = [], order: [String] = []) {
|
||||||
|
self.hidden = hidden
|
||||||
|
self.order = order
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wendet Auswahl und Reihenfolge an.
|
||||||
|
///
|
||||||
|
/// Unbekannte Reihen landen am Ende statt zu verschwinden. Sie zu
|
||||||
|
/// verwerfen wäre die einfachere Regel — und der Grund dafür, dass ein
|
||||||
|
/// neu angelegtes Regal auf dem Server in der App nie aufgetaucht wäre.
|
||||||
|
public func apply(to hubs: [PlexHub]) -> [PlexHub] {
|
||||||
|
let visible = hubs.filter { !hidden.contains($0.hubIdentifier) }
|
||||||
|
guard !order.isEmpty else { return visible }
|
||||||
|
|
||||||
|
let positions = Dictionary(
|
||||||
|
order.enumerated().map { ($1, $0) },
|
||||||
|
// Ein doppelter Eintrag in der gespeicherten Reihenfolge — etwa
|
||||||
|
// nach einem fehlgeschlagenen Schreibvorgang — soll nicht
|
||||||
|
// abstürzen. Die erste Position gewinnt.
|
||||||
|
uniquingKeysWith: { first, _ in first }
|
||||||
|
)
|
||||||
|
|
||||||
|
return visible.enumerated().sorted { left, right in
|
||||||
|
let leftPosition = positions[left.element.hubIdentifier] ?? Int.max
|
||||||
|
let rightPosition = positions[right.element.hubIdentifier] ?? Int.max
|
||||||
|
// Bei gleicher Position — beide unbekannt — bleibt die Reihenfolge
|
||||||
|
// des Servers erhalten. Ohne diese Rückfalllinie wäre die
|
||||||
|
// Anordnung neuer Reihen zufällig.
|
||||||
|
if leftPosition == rightPosition { return left.offset < right.offset }
|
||||||
|
return leftPosition < rightPosition
|
||||||
|
}.map(\.element)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Liste für den Einstellungsbildschirm: gespeicherte Reihenfolge
|
||||||
|
/// zuerst, danach alles, was der Server sonst noch liefert.
|
||||||
|
///
|
||||||
|
/// Gespeicherte Reihen, die es nicht mehr gibt, fallen dabei heraus — sie
|
||||||
|
/// anzuzeigen hieße, eine Reihe anzubieten, die nie erscheinen kann.
|
||||||
|
public func arrangedIdentifiers(from hubs: [PlexHub]) -> [String] {
|
||||||
|
var result: [String] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
for identifier in order
|
||||||
|
where !seen.contains(identifier) && hubs.contains(where: { $0.hubIdentifier == identifier }) {
|
||||||
|
result.append(identifier)
|
||||||
|
seen.insert(identifier)
|
||||||
|
}
|
||||||
|
for hub in hubs where !seen.contains(hub.hubIdentifier) {
|
||||||
|
result.append(hub.hubIdentifier)
|
||||||
|
seen.insert(hub.hubIdentifier)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import PlexKit
|
||||||
|
|
||||||
|
private func hub(_ identifier: String) -> PlexHub {
|
||||||
|
PlexHub(hubIdentifier: identifier, title: identifier.capitalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Anordnung der Startseiten-Reihen")
|
||||||
|
struct HubArrangementTests {
|
||||||
|
private let serverHubs = [hub("continue"), hub("recent"), hub("watchlist"), hub("collections")]
|
||||||
|
|
||||||
|
@Test("Ohne Einstellungen bleibt die Reihenfolge des Servers")
|
||||||
|
func defaultKeepsServerOrder() {
|
||||||
|
let result = HubArrangement().apply(to: serverHubs)
|
||||||
|
#expect(result.map(\.hubIdentifier) == ["continue", "recent", "watchlist", "collections"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ausgeblendete Reihen verschwinden")
|
||||||
|
func hiddenRowsAreRemoved() {
|
||||||
|
let arrangement = HubArrangement(hidden: ["watchlist", "collections"])
|
||||||
|
let result = arrangement.apply(to: serverHubs)
|
||||||
|
#expect(result.map(\.hubIdentifier) == ["continue", "recent"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die gespeicherte Reihenfolge wird angewendet")
|
||||||
|
func storedOrderIsApplied() {
|
||||||
|
let arrangement = HubArrangement(order: ["watchlist", "continue", "recent", "collections"])
|
||||||
|
let result = arrangement.apply(to: serverHubs)
|
||||||
|
#expect(result.map(\.hubIdentifier) == ["watchlist", "continue", "recent", "collections"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Neue Reihen vom Server landen am Ende statt zu verschwinden")
|
||||||
|
func unknownRowsAppearAtTheEnd() {
|
||||||
|
// Der Fall, der die einfachere Regel — nur Bekanntes anzeigen — zur
|
||||||
|
// Falle macht: ein neu angelegtes Regal auf dem Server wäre in der App
|
||||||
|
// nie aufgetaucht, ohne dass jemand den Grund gesehen hätte.
|
||||||
|
let arrangement = HubArrangement(order: ["recent", "continue"])
|
||||||
|
let result = arrangement.apply(to: serverHubs)
|
||||||
|
#expect(result.map(\.hubIdentifier) == ["recent", "continue", "watchlist", "collections"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mehrere neue Reihen behalten die Reihenfolge des Servers")
|
||||||
|
func multipleUnknownRowsKeepServerOrder() {
|
||||||
|
// Ohne stabile Rückfalllinie wäre ihre Anordnung zufällig — und
|
||||||
|
// änderte sich bei jedem Start.
|
||||||
|
let arrangement = HubArrangement(order: ["collections"])
|
||||||
|
let result = arrangement.apply(to: serverHubs)
|
||||||
|
#expect(result.map(\.hubIdentifier) == ["collections", "continue", "recent", "watchlist"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Gespeicherte Reihen, die es nicht mehr gibt, stören nicht")
|
||||||
|
func staleOrderEntriesAreIgnored() {
|
||||||
|
// Entsteht beim Löschen einer Mediathek auf dem Server.
|
||||||
|
let arrangement = HubArrangement(order: ["geloescht", "recent", "continue"])
|
||||||
|
let result = arrangement.apply(to: serverHubs)
|
||||||
|
#expect(result.map(\.hubIdentifier) == ["recent", "continue", "watchlist", "collections"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein doppelter Eintrag in der Reihenfolge stürzt nicht ab")
|
||||||
|
func duplicateOrderEntriesAreTolerated() {
|
||||||
|
// Kann nach einem abgebrochenen Schreibvorgang entstehen. Ein
|
||||||
|
// Dictionary aus doppelten Schlüsseln wäre ein harter Absturz.
|
||||||
|
let arrangement = HubArrangement(order: ["recent", "recent", "continue"])
|
||||||
|
let result = arrangement.apply(to: serverHubs)
|
||||||
|
#expect(result.first?.hubIdentifier == "recent")
|
||||||
|
#expect(result.count == 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ausblenden und Sortieren greifen zusammen")
|
||||||
|
func hidingAndOrderingCombine() {
|
||||||
|
let arrangement = HubArrangement(
|
||||||
|
hidden: ["recent"],
|
||||||
|
order: ["collections", "recent", "continue"]
|
||||||
|
)
|
||||||
|
let result = arrangement.apply(to: serverHubs)
|
||||||
|
#expect(result.map(\.hubIdentifier) == ["collections", "continue", "watchlist"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Die Liste für die Einstellungen
|
||||||
|
|
||||||
|
@Test("Der Einstellungsbildschirm zeigt Gespeichertes zuerst")
|
||||||
|
func settingsListPutsStoredFirst() {
|
||||||
|
let arrangement = HubArrangement(order: ["watchlist", "continue"])
|
||||||
|
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
|
||||||
|
#expect(identifiers == ["watchlist", "continue", "recent", "collections"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der Einstellungsbildschirm zeigt auch ausgeblendete Reihen")
|
||||||
|
func settingsListIncludesHidden() {
|
||||||
|
// Sonst ließe sich eine einmal ausgeblendete Reihe nie wieder
|
||||||
|
// einschalten — eine Einbahnstraße.
|
||||||
|
let arrangement = HubArrangement(hidden: ["recent"])
|
||||||
|
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
|
||||||
|
#expect(identifiers.contains("recent"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Verschwundene Reihen erscheinen nicht in den Einstellungen")
|
||||||
|
func settingsListDropsStaleEntries() {
|
||||||
|
let arrangement = HubArrangement(order: ["geloescht", "recent"])
|
||||||
|
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
|
||||||
|
#expect(!identifiers.contains("geloescht"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user