Send the filter names ipx actually understands (#6)
The Pinned tab was not filtering. It sent filter=pinned, and Filter::parse in db.rs knows unread, downloaded, flagged and in_progress and falls through to All for anything else -- so the tab returned every item and looked like it had worked. The column is still named flagged, for what it was before the interface called it pinned, and the page had this right all along. Currently Listening was worse in kind. ipx has filter=in_progress for exactly it: started past the first few seconds, short of the 90% the UI calls finished, measured against the length this person's player reported where there is one. The client asked for everything and trimmed the fifty rows it happened to receive, so the view showed whichever started episodes were near the top of the library, left out the rest, and counted wrong. Both came of writing the filter names from the interface's words instead of reading what the server parses. The tests now assert what each filter means rather than how many rows it returns -- every unread row unread, every downloaded row with a file, every pinned row pinned, every in-progress row started -- because the failure here was a full page of entirely plausible rows, which no count would have caught. Pinned also has to match fewer than everything, which is the shape the bug took. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
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 {
|
enum Sort: String {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ struct ItemListView: View {
|
|||||||
private var header: some View {
|
private var header: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Picker("Show", selection: $store.filter) {
|
Picker("Show", selection: $store.filter) {
|
||||||
ForEach(IPX.Filter.allCases, id: \.self) { f in
|
ForEach(IPX.Filter.tabs, id: \.self) { f in
|
||||||
Text(f.rawValue.capitalized).tag(f)
|
Text(f.label).tag(f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
|||||||
@@ -100,20 +100,19 @@ final class LibraryStore: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
// Currently Listening is not a feed; it is the started-but-unfinished filter, which
|
// Currently Listening is not a feed; it is the started-but-unfinished filter, which
|
||||||
// the page reaches through the same route.
|
// 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(
|
let page = try await api.entries(
|
||||||
feed: place.feedId,
|
feed: place.feedId,
|
||||||
filter: place == .listening ? .all : filter,
|
filter: place == .listening ? .inProgress : filter,
|
||||||
search: search,
|
search: search,
|
||||||
sort: sort,
|
sort: sort,
|
||||||
direction: direction,
|
direction: direction,
|
||||||
offset: offset)
|
offset: offset)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
var rows = offset == 0 ? page.entries : entries + page.entries
|
entries = offset == 0 ? page.entries : entries + page.entries
|
||||||
if place == .listening {
|
total = page.total
|
||||||
rows = rows.filter { $0.position > 0 && !$0.read }
|
|
||||||
}
|
|
||||||
entries = rows
|
|
||||||
total = place == .listening ? rows.count : page.total
|
|
||||||
failure = nil
|
failure = nil
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -84,6 +84,37 @@ final class APITests: XCTestCase {
|
|||||||
XCTAssertEqual(asc.entries.map(\.guid), desc.entries.map(\.guid).reversed())
|
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 {
|
func testSearchMatchesTitles() async throws {
|
||||||
let hit = try await api.entries(feed: "test-show", search: "Second")
|
let hit = try await api.entries(feed: "test-show", search: "Second")
|
||||||
XCTAssertEqual(hit.entries.count, 1)
|
XCTAssertEqual(hit.entries.count, 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user