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
@testable import Grain
import XCTest
/// Relative, not a literal: `StoryStatusCache` expires a story 24 hours after
/// `latestAt`, so a hardcoded date turns these into time bombs. File scope
/// because a default argument can't reference the enclosing type.
private let recently = DateFormatting.nowISO(date: Date().addingTimeInterval(-3600))
/// The strip only exists when someone has posted, so its job is mostly
/// filtering: authors whose stories have all expired must not leave an empty
/// ring behind, and a failed load must leave the strip absent rather than
/// broken.
@MainActor
final class StoryStripViewModelTests: GrainTestCase {
private var client: XRPCClient!
private var vm: StoryStripViewModel!
override func setUp() async throws {
try await super.setUp()
client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
vm = StoryStripViewModel(client: client)
}
override func tearDown() async throws {
MockURLProtocol.handler = nil
try await super.tearDown()
}
private func authorJSON(did: String, storyCount: Int, latestAt: String = recently) -> String {
"""
{
"profile": {"cid": "c-\(did)", "did": "\(did)", "handle": "\(did).test"},
"storyCount": \(storyCount),
"latestAt": "\(latestAt)"
}
"""
}
func testLoadPopulatesTheStrip() async {
MockURLProtocol.respondWithJSON("""
{"authors": [\(authorJSON(did: "did:plc:a", storyCount: 2))]}
""")
await vm.load()
XCTAssertEqual(vm.authors.map(\.id), ["did:plc:a"])
XCTAssertEqual(vm.authors.first?.storyCount, 2)
XCTAssertFalse(vm.isLoading)
}
/// The appview keeps returning an author for a while after their last story
/// goes, with a count of zero. Showing them would put an empty ring in the
/// strip that opens onto nothing.
func testAuthorsWithNoStoriesLeftAreDropped() async {
MockURLProtocol.respondWithJSON("""
{"authors": [
\(authorJSON(did: "did:plc:a", storyCount: 0)),
\(authorJSON(did: "did:plc:b", storyCount: 1))
]}
""")
await vm.load()
XCTAssertEqual(vm.authors.map(\.id), ["did:plc:b"])
}
func testAFailedLoadLeavesTheStripEmptyAndNotLoading() async {
MockURLProtocol.respondWithError(statusCode: 500)
await vm.load()
XCTAssertTrue(vm.authors.isEmpty)
XCTAssertFalse(vm.isLoading, "A failure must clear the loading flag or the skeleton never goes away")
}
/// Loading also primes the shared cache the profile and feed rings read, so
/// a story ring appears without those screens making their own request.
func testLoadingAlsoFillsTheSharedStoryStatusCache() async {
MockURLProtocol.respondWithJSON("""
{"authors": [
\(authorJSON(did: "did:plc:a", storyCount: 2)),
\(authorJSON(did: "did:plc:b", storyCount: 0))
]}
""")
let cache = StoryStatusCache()
await vm.load(storyStatusCache: cache)
XCTAssertTrue(cache.hasStory(for: "did:plc:a"))
XCTAssertFalse(cache.hasStory(for: "did:plc:b"), "A zero-story author shouldn't get a ring anywhere")
}
/// The cache holds authors the strip itself filtered out of view, so a
/// failure must not wipe what an earlier successful load put there.
func testAFailedLoadLeavesThePreviouslyCachedStatusAlone() async {
MockURLProtocol.respondWithJSON("""
{"authors": [\(authorJSON(did: "did:plc:a", storyCount: 2))]}
""")
let cache = StoryStatusCache()
await vm.load(storyStatusCache: cache)
MockURLProtocol.respondWithError(statusCode: 503)
await vm.load(storyStatusCache: cache)
XCTAssertTrue(cache.hasStory(for: "did:plc:a"))
}
/// Sorting depends on viewed state, which changes without the author list
/// changing — so there has to be something else for the view to observe.
func testInvalidateBumpsTheVersionWithoutTouchingTheAuthors() async {
MockURLProtocol.respondWithJSON("""
{"authors": [\(authorJSON(did: "did:plc:a", storyCount: 1))]}
""")
await vm.load()
let before = vm.version
vm.invalidate()
vm.invalidate()
XCTAssertEqual(vm.version, before + 2)
XCTAssertEqual(vm.authors.map(\.id), ["did:plc:a"])
}
func testASecondLoadReplacesRatherThanAppends() async {
MockURLProtocol.respondWithJSON("""
{"authors": [\(authorJSON(did: "did:plc:a", storyCount: 1))]}
""")
await vm.load()
MockURLProtocol.respondWithJSON("""
{"authors": [\(authorJSON(did: "did:plc:b", storyCount: 1))]}
""")
await vm.load()
XCTAssertEqual(vm.authors.map(\.id), ["did:plc:b"])
}
}