diff --git a/ios/Sources/APIModels.swift b/ios/Sources/APIModels.swift index d0a74d6..aca2aae 100644 --- a/ios/Sources/APIModels.swift +++ b/ios/Sources/APIModels.swift @@ -137,9 +137,32 @@ extension IPX { } } - /// Which items to ask for. The server does the work; these go on the query. + /// Which items to ask for. The server does the work; these go on the query, and the raw + /// values are what `Filter::parse` in db.rs understands -- anything it does not know falls + /// through to All, so a wrong name here is a filter that quietly does nothing. enum Filter: String, CaseIterable { - case all, unread, downloaded, pinned + case all + case unread + case downloaded + /// `flagged` on the wire: the column is named for what it was before the interface + /// called it pinned. + case pinned = "flagged" + /// Started past the first few seconds and short of the 90% the UI calls finished. Not + /// the same as unread: opening an item marks it read. + case inProgress = "in_progress" + + /// The tabs, which are not every filter: Currently Listening is a place of its own. + static var tabs: [Filter] { [.all, .unread, .downloaded, .pinned] } + + var label: String { + switch self { + case .all: return "All" + case .unread: return "Unread" + case .downloaded: return "Downloaded" + case .pinned: return "Pinned" + case .inProgress: return "Listening" + } + } } enum Sort: String { diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift index f1fe491..87ce151 100644 --- a/ios/Sources/ItemListView.swift +++ b/ios/Sources/ItemListView.swift @@ -26,8 +26,8 @@ struct ItemListView: View { private var header: some View { VStack(spacing: 8) { Picker("Show", selection: $store.filter) { - ForEach(IPX.Filter.allCases, id: \.self) { f in - Text(f.rawValue.capitalized).tag(f) + ForEach(IPX.Filter.tabs, id: \.self) { f in + Text(f.label).tag(f) } } .pickerStyle(.segmented) diff --git a/ios/Sources/LibraryStore.swift b/ios/Sources/LibraryStore.swift index b8c9404..dc1be25 100644 --- a/ios/Sources/LibraryStore.swift +++ b/ios/Sources/LibraryStore.swift @@ -100,20 +100,19 @@ final class LibraryStore: ObservableObject { do { // Currently Listening is not a feed; it is the started-but-unfinished filter, which // the page reaches through the same route. + // Currently Listening is the server's in_progress filter, not a pass over the page + // we happened to receive: trimming fifty rows here showed whichever started episodes + // were near the top of the library and quietly left out the rest. let page = try await api.entries( feed: place.feedId, - filter: place == .listening ? .all : filter, + filter: place == .listening ? .inProgress : filter, search: search, sort: sort, direction: direction, offset: offset) guard !Task.isCancelled else { return } - var rows = offset == 0 ? page.entries : entries + page.entries - if place == .listening { - rows = rows.filter { $0.position > 0 && !$0.read } - } - entries = rows - total = place == .listening ? rows.count : page.total + entries = offset == 0 ? page.entries : entries + page.entries + total = page.total failure = nil } catch is CancellationError { } catch { diff --git a/ios/Tests/APITests.swift b/ios/Tests/APITests.swift index a7a7af3..86f8859 100644 --- a/ios/Tests/APITests.swift +++ b/ios/Tests/APITests.swift @@ -84,6 +84,37 @@ final class APITests: XCTestCase { XCTAssertEqual(asc.entries.map(\.guid), desc.entries.map(\.guid).reversed()) } + /// Every filter, checked against what it means rather than against a count. The bug this + /// replaces was a filter name the server did not know: it fell through to All and returned + /// a full page of plausible rows, which no count-based assertion would have noticed. + func testEachFilterReturnsOnlyWhatItMeans() async throws { + let all = try await api.entries(filter: .all, limit: 200) + + let unread = try await api.entries(filter: .unread, limit: 200) + XCTAssertTrue(unread.entries.allSatisfy { !$0.read }, "unread returned a read item") + + let downloaded = try await api.entries(filter: .downloaded, limit: 200) + XCTAssertTrue(downloaded.entries.allSatisfy { $0.enclosures.contains(where: \.isDownloaded) }, + "downloaded returned an item with no file") + + let pinned = try await api.entries(filter: .pinned, limit: 200) + XCTAssertTrue(pinned.entries.allSatisfy(\.flagged), "pinned returned an unpinned item") + XCTAssertLessThan(pinned.total, all.total, + "pinned matched everything, which is what a filter name the server " + + "does not understand looks like") + + let started = try await api.entries(filter: .inProgress, limit: 200) + XCTAssertTrue(started.entries.allSatisfy { $0.position > 0 }, + "in_progress returned an item nobody has started") + } + + /// The raw values are the wire format, and getting one wrong fails silently. + func testFilterNamesAreTheOnesTheServerKnows() { + XCTAssertEqual(IPX.Filter.pinned.rawValue, "flagged") + XCTAssertEqual(IPX.Filter.inProgress.rawValue, "in_progress") + XCTAssertEqual(IPX.Filter.tabs.map(\.rawValue), ["all", "unread", "downloaded", "flagged"]) + } + func testSearchMatchesTitles() async throws { let hit = try await api.entries(feed: "test-show", search: "Second") XCTAssertEqual(hit.entries.count, 1)