987de19ffe51
fix feed pill hit area, name wallhaven failures by cause, drop duplicate launch searches (v0.6.2)
- time
- 2026-09-19 13:49 UTC · 3 weeks ago
- machine
- koh2
- agent
- deepseek/deepseek-flash
- session
- fix pill clicks and error banner
- parent
- 300c20aa6f13
↓ haven-987de19f.tar.gzatlas at this save
files changed6 files
Info.plist+2 −2Sources/Haven/API/WallhavenAPI.swift+39 −5Sources/Haven/UI/AppState.swift+8 −1Sources/Haven/UI/Views/MainView.swift+5 −0Tests/HavenTests/AppStateTests.swift+23 −0Tests/HavenTests/WallhavenAPITests.swift+60 −1Info.plist+2 −2
@@ -9,9 +9,9 @@99 <key>CFBundleName</key>1010 <string>Haven</string>1111 <key>CFBundleShortVersionString</key>12- <string>0.6.1</string>12+ <string>0.6.2</string>1313 <key>CFBundleVersion</key>14- <string>7</string>14+ <string>8</string>1515 <key>LSMinimumSystemVersion</key>1616 <string>13.0</string>1717 <key>LSUIElement</key>Sources/Haven/API/WallhavenAPI.swift+39 −5
@@ -1,11 +1,29 @@11 import Foundation22 3-enum WallhavenError: Error {3+enum WallhavenError: Error, LocalizedError {44 case invalidURL55 case networkError(Error)66 case decodingError(Error)77 case noData88 case apiError(String)9+10+ /// These strings reach the user verbatim through the error banner. Without11+ /// this, Swift bridges the enum to the opaque "Haven.WallhavenError error N"12+ /// that says nothing about what actually failed.13+ var errorDescription: String? {14+ switch self {15+ case .invalidURL:16+ return "That isn't a valid Wallhaven URL."17+ case .networkError(let error):18+ return "Couldn't reach Wallhaven: \(error.localizedDescription)"19+ case .decodingError:20+ return "Wallhaven sent a response Haven couldn't read."21+ case .noData:22+ return "Wallhaven returned no data."23+ case .apiError(let message):24+ return message25+ }26+ }927 }1028 1129 /// Sort order for Wallhaven search results. Maps to the `sorting` query@@ -62,6 +80,20 @@6280 return try await performSearch(queryItems: queryItems)6381 }6482 83+ /// Wallhaven is fronted by Cloudflare, so when a search fails the status code84+ /// is the only thing that says whether the site is down (5xx), Haven has hit85+ /// its rate limit (429), or the request itself was rejected.86+ static func message(forStatus status: Int) -> String {87+ switch status {88+ case 429:89+ return "Wallhaven rate limit reached (HTTP 429). Try again in a minute."90+ case 500...:91+ return "Wallhaven is unavailable right now (HTTP \(status))."92+ default:93+ return "Wallhaven rejected the request (HTTP \(status))."94+ }95+ }96+6597 private func performSearch(queryItems: [URLQueryItem]) async throws -> ([Wallpaper], Meta?) {6698 var urlComponents = URLComponents(6799 url: Configuration.baseURL.appendingPathComponent("search"), resolvingAgainstBaseURL: true)!@@ -71,10 +103,12 @@71103 72104 let (data, response) = try await session.data(from: url)73105 74- guard let httpResponse = response as? HTTPURLResponse,75- (200...299).contains(httpResponse.statusCode)76- else {77- throw WallhavenError.apiError("Server returned an error")106+ guard let httpResponse = response as? HTTPURLResponse else {107+ throw WallhavenError.apiError("Wallhaven returned an unexpected response.")108+ }109+110+ guard (200...299).contains(httpResponse.statusCode) else {111+ throw WallhavenError.apiError(Self.message(forStatus: httpResponse.statusCode))78112 }79113 80114 do {Sources/Haven/UI/AppState.swift+8 −1
@@ -153,20 +153,27 @@153153 154154 guard observeChanges else { return }155155 156- // Debounce search156+ // `@Published` replays its current value to every new subscriber, so157+ // without `dropFirst` these three pipelines each launch a search as soon158+ // as an AppState exists — three identical requests at startup, before the159+ // popover has ever been opened. The initial load comes from the views'160+ // `onAppear` instead.157161 $searchText162+ .dropFirst()158163 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)159164 .sink { [weak self] _ in self?.scheduleSearch() }160165 .store(in: &cancellables)161166 162167 // Observe filters163168 $categories169+ .dropFirst()164170 .map { _ in () }165171 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)166172 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }167173 .store(in: &cancellables)168174 169175 $purity176+ .dropFirst()170177 .map { _ in () }171178 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)172179 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }Sources/Haven/UI/Views/MainView.swift+5 −0
@@ -245,6 +245,11 @@245245 RoundedRectangle(cornerRadius: 10)246246 .stroke(Color.secondary.opacity(isActive ? 0 : 0.45), lineWidth: 1)247247 )248+ // A `.plain` button hit-tests only its label's content (icon and text),249+ // not the background or flexible frame around them, so without an250+ // explicit shape the padded interior of the pill swallows clicks that251+ // look like they land on it.252+ .contentShape(Rectangle())248253 }249254 .buttonStyle(.plain)250255 }Tests/HavenTests/AppStateTests.swift+23 −0
@@ -371,6 +371,29 @@371371 #expect(search.requests.count >= 5)372372 }373373 374+ @Test("Initialization alone launches no search")375+ func initializationLaunchesNoSearch() async {376+ let search = SearchStub()377+ search.results = Array(repeating: .success(([], nil)), count: 8)378+ let state = AppState(379+ api: search, chooseFolder: { _, completion in completion(nil) },380+ terminateApplication: {}, observeChanges: true)381+382+ // `@Published` replays its current value to every new subscriber, so the383+ // debounced pipelines would otherwise turn that replay into searches that384+ // fire before the popover is ever opened.385+ try? await Task.sleep(nanoseconds: 1_200_000_000)386+ #expect(search.requests.isEmpty)387+388+ // The instance is still alive and its pipelines still work, so the389+ // assertion above is not just observing a deallocated state object.390+ state.scheduleSearch()391+ for _ in 0..<100 where search.requests.isEmpty {392+ try? await Task.sleep(nanoseconds: 20_000_000)393+ }394+ #expect(search.requests.count == 1)395+ }396+374397 @Test("Resolution and ratio changes are debounced like other filters")375398 func debouncedResolutionAndRatio() async {376399 let search = SearchStub()Tests/HavenTests/WallhavenAPITests.swift+60 −1
@@ -4,7 +4,7 @@44 @testable import Haven55 66 final class MockURLProtocol: URLProtocol, @unchecked Sendable {7- nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?7+ nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (URLResponse, Data))?88 99 override class func canInit(with request: URLRequest) -> Bool { true }1010 @@ -130,6 +130,65 @@130130 } catch {131131 Issue.record("Unexpected error: \(error)")132132 }133+ }134+135+ @Test("Failures name the cause instead of an opaque error code")136+ func failuresDescribeTheirCause() async {137+ let cases: [(Int, String)] = [138+ (521, "Wallhaven is unavailable right now (HTTP 521)."),139+ (429, "Wallhaven rate limit reached (HTTP 429). Try again in a minute."),140+ (404, "Wallhaven rejected the request (HTTP 404)."),141+ ]142+143+ for (status, expected) in cases {144+ MockURLProtocol.requestHandler = { request in145+ (response(for: request, statusCode: status), Data())146+ }147+148+ do {149+ _ = try await makeAPI().searchWallpapers(query: "")150+ Issue.record("Expected HTTP \(status) to throw")151+ } catch WallhavenError.apiError(let message) {152+ #expect(message == expected)153+ } catch {154+ Issue.record("Unexpected error: \(error)")155+ }156+ }157+ }158+159+ @Test("A body-less response reports an unexpected response")160+ func nonHTTPResponseDescribesItself() async {161+ MockURLProtocol.requestHandler = { request in162+ (163+ URLResponse(164+ url: request.url!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil),165+ Data()166+ )167+ }168+169+ do {170+ _ = try await makeAPI().searchWallpapers(query: "")171+ Issue.record("Expected a non-HTTP response to throw")172+ } catch WallhavenError.apiError(let message) {173+ #expect(message == "Wallhaven returned an unexpected response.")174+ } catch {175+ Issue.record("Unexpected error: \(error)")176+ }177+ }178+179+ @Test("Every error case carries a human-readable description")180+ func errorCasesLocalizeTheirDescriptions() {181+ #expect(WallhavenError.invalidURL.localizedDescription == "That isn't a valid Wallhaven URL.")182+ #expect(WallhavenError.noData.localizedDescription == "Wallhaven returned no data.")183+ #expect(184+ WallhavenError.apiError("Wallhaven is unavailable right now (HTTP 521).").localizedDescription185+ == "Wallhaven is unavailable right now (HTTP 521).")186+ #expect(187+ WallhavenError.networkError(URLError(.notConnectedToInternet)).localizedDescription188+ .hasPrefix("Couldn't reach Wallhaven: "))189+ #expect(190+ WallhavenError.decodingError(URLError(.cannotParseResponse)).localizedDescription191+ == "Wallhaven sent a response Haven couldn't read.")133192 }134193 135194 @Test("Malformed success payloads become decoding errors")