1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
@testable import Grain
import XCTest
/// What the server answers a read of the set with, and a write: a write
/// reads the set first, so it is answered for both.
private let noPreferences = #"{"preferences": []}"#
/// File scope, not a static member: `MockURLProtocol.handler` runs outside the
/// main actor, so anything it reads has to live outside this @MainActor class.
private let serverFeeds = """
{"preferences": [
{"$type": "social.grain.actor.defs#pinnedFeedsPref", "items": [
{"id": "following", "label": "Following", "type": "feed", "path": "/feeds/following"},
{"id": "camera:Leica M6", "label": "Leica M6", "type": "camera", "path": "/c/leica"}
]},
{"$type": "social.grain.actor.defs#photoMetadataPref", "includeExif": false}
]}
"""
/// Pinned feeds drive the feed switcher, and every mutation is applied
/// optimistically then written to the server. The rollback on failure is the
/// part worth holding still: without it a tab bar shows a feed the account
/// doesn't actually have pinned.
@MainActor
final class FeedPreferencesViewModelTests: GrainTestCase {
private var client: XRPCClient!
private var vm: FeedPreferencesViewModel!
override func setUp() async throws {
try await super.setUp()
client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
vm = FeedPreferencesViewModel(client: client)
}
override func tearDown() async throws {
MockURLProtocol.handler = nil
try await super.tearDown()
}
private let hashtagFeed = PinnedFeed(id: "hashtag:film", label: "#film", type: "hashtag", path: "/t/film")
// MARK: - Defaults
func testItStartsOnTheDefaultFeedsBeforeAnythingLoads() {
XCTAssertEqual(vm.pinnedFeeds, PinnedFeed.defaults)
XCTAssertEqual(vm.selectedFeedId, "recent")
XCTAssertEqual(vm.selectedFeedLabel, "Recent")
XCTAssertTrue(vm.includeExif)
}
/// The label is what the switcher shows, and a feed that was unpinned out
/// from under the selection must not leave a blank button.
func testTheLabelFallsBackWhenTheSelectionIsNotPinned() {
vm.selectedFeedId = "hashtag:nothing"
XCTAssertEqual(vm.selectedFeedLabel, "Feed")
}
// MARK: - refresh
func testRefreshAdoptsTheServersFeedsAndExifSetting() async {
MockURLProtocol.respondWithJSON(serverFeeds)
await vm.refresh(auth: nil)
XCTAssertEqual(vm.pinnedFeeds.map(\.id), ["following", "camera:Leica M6"])
XCTAssertFalse(vm.includeExif)
}
/// "recent" is the default selection but isn't in the account's list, so it
/// has to move to something that is.
func testRefreshMovesASelectionThatIsNoLongerPinned() async {
MockURLProtocol.respondWithJSON(serverFeeds)
await vm.refresh(auth: nil)
XCTAssertEqual(vm.selectedFeedId, "following")
}
func testRefreshKeepsASelectionTheServerStillHas() async {
MockURLProtocol.respondWithJSON(serverFeeds)
vm.selectedFeedId = "camera:Leica M6"
await vm.refresh(auth: nil)
XCTAssertEqual(vm.selectedFeedId, "camera:Leica M6")
}
/// An account that has never set preferences comes back empty, and wiping
/// the switcher down to nothing would leave no feed to show.
func testAnEmptyFeedListLeavesTheDefaultsInPlace() async {
MockURLProtocol.respondWithJSON(#"{"preferences": [{"$type": "social.grain.actor.defs#pinnedFeedsPref", "items": []}]}"#)
await vm.refresh(auth: nil)
XCTAssertEqual(vm.pinnedFeeds, PinnedFeed.defaults)
}
func testAFailedRefreshLeavesTheDefaultsInPlace() async {
MockURLProtocol.respondWithError(statusCode: 500)
await vm.refresh(auth: nil)
XCTAssertEqual(vm.pinnedFeeds, PinnedFeed.defaults)
XCTAssertTrue(vm.includeExif)
}
/// Called from `.task` on a view that can re-appear repeatedly.
func testLoadIfNeededOnlyFetchesOnce() async {
var requests = 0
MockURLProtocol.handler = { request in
requests += 1
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(serverFeeds.utf8), response)
}
await vm.loadIfNeeded(auth: nil)
await vm.loadIfNeeded(auth: nil)
XCTAssertEqual(requests, 1)
}
// MARK: - isPinned
func testIsPinnedReadsTheCurrentList() {
XCTAssertTrue(vm.isPinned("recent"))
XCTAssertFalse(vm.isPinned("hashtag:film"))
}
// MARK: - pinFeed
func testPinningAppendsToTheEndOfTheList() async {
MockURLProtocol.respondWithJSON(noPreferences)
await vm.pinFeed(hashtagFeed, auth: nil)
XCTAssertEqual(vm.pinnedFeeds.last?.id, "hashtag:film")
XCTAssertTrue(vm.isPinned("hashtag:film"))
}
func testPinningSomethingAlreadyPinnedIsANoOp() async {
var requestMade = false
MockURLProtocol.handler = { request in
requestMade = true
return (Data("{}".utf8), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
await vm.pinFeed(PinnedFeed.defaults[0], auth: nil)
XCTAssertFalse(requestMade)
XCTAssertEqual(vm.pinnedFeeds, PinnedFeed.defaults)
}
func testAFailedPinIsRolledBack() async {
MockURLProtocol.respondWithError(statusCode: 500)
await vm.pinFeed(hashtagFeed, auth: nil)
XCTAssertEqual(vm.pinnedFeeds, PinnedFeed.defaults)
}
// MARK: - unpinFeed
func testUnpinningRemovesTheFeed() async {
MockURLProtocol.respondWithJSON(noPreferences)
await vm.unpinFeed("following", auth: nil)
XCTAssertEqual(vm.pinnedFeeds.map(\.id), ["recent", "foryou"])
}
/// Unpinning the feed you're looking at has to move the selection with it.
func testUnpinningTheSelectedFeedMovesTheSelection() async {
MockURLProtocol.respondWithJSON(noPreferences)
vm.selectedFeedId = "recent"
await vm.unpinFeed("recent", auth: nil)
XCTAssertEqual(vm.selectedFeedId, "following")
}
func testUnpinningTheLastFeedFallsBackToRecent() async {
MockURLProtocol.respondWithJSON(noPreferences)
vm.pinnedFeeds = [PinnedFeed.defaults[1]]
vm.selectedFeedId = "following"
await vm.unpinFeed("following", auth: nil)
XCTAssertEqual(vm.selectedFeedId, "recent")
}
func testAFailedUnpinRestoresBothTheListAndTheSelection() async {
MockURLProtocol.respondWithError(statusCode: 500)
vm.selectedFeedId = "recent"
await vm.unpinFeed("recent", auth: nil)
XCTAssertEqual(vm.pinnedFeeds, PinnedFeed.defaults)
XCTAssertEqual(vm.selectedFeedId, "recent")
}
// MARK: - reorderFeeds
func testReorderingKeepsTheNewOrder() async {
MockURLProtocol.respondWithJSON(noPreferences)
let reversed = Array(PinnedFeed.defaults.reversed())
await vm.reorderFeeds(reversed, auth: nil)
XCTAssertEqual(vm.pinnedFeeds, reversed)
}
func testAFailedReorderSnapsBack() async {
MockURLProtocol.respondWithError(statusCode: 500)
await vm.reorderFeeds(Array(PinnedFeed.defaults.reversed()), auth: nil)
XCTAssertEqual(vm.pinnedFeeds, PinnedFeed.defaults)
}
// MARK: - includeExif
func testTurningExifOffSticks() async {
MockURLProtocol.respondWithJSON(noPreferences)
await vm.setIncludeExif(false, auth: nil)
XCTAssertFalse(vm.includeExif)
}
/// A silently-reverted privacy toggle is worse than a visibly failed one,
/// so the switch has to snap back rather than lie.
func testAFailedExifToggleSnapsBack() async {
MockURLProtocol.respondWithError(statusCode: 500)
await vm.setIncludeExif(false, auth: nil)
XCTAssertTrue(vm.includeExif)
}
}