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
@testable import Grain
import SwiftUI
import XCTest
/// The notifications screen as a whole: the list assembled from grouped rows,
/// against each reason the appview can send.
///
/// `NotificationRowRenderTests` covers the rows in isolation. This covers the
/// list that builds them — including the grouping applied on the way in, which
/// is what decides whether four favorites are four rows or one.
@MainActor
final class NotificationsListRenderTests: GrainTestCase {
private var account: TestAccount!
/// Async overrides: the synchronous `setUp`/`tearDown` are nonisolated, so
/// touching main-actor state from them warns.
override func setUp() async throws {
try await super.setUp()
account = TestAccount()
}
override func tearDown() async throws {
MockURLProtocol.handler = nil
account.restore()
try await super.tearDown()
}
// MARK: - Builders
private static func notification(
_ index: Int,
reason: String,
did: String = "did:plc:someone",
extras: String = ""
) -> String {
"""
{
"uri": "at://did:plc:test/notification/\(index)",
"reason": "\(reason)",
"createdAt": "\(Fixtures.freshTimestamp)",
"author": {"cid": "c\(index)", "did": "\(did)", "handle": "\(did).test", "displayName": "User \(index)"}
\(extras.isEmpty ? "" : ",\(extras)")
}
"""
}
private static let galleryExtras = """
"galleryUri": "at://did:plc:test/social.grain.gallery/1",
"galleryTitle": "A test gallery",
"galleryThumb": "https://test.local/thumb.jpg"
"""
private static let storyExtras = """
"storyUri": "at://did:plc:test/social.grain.story/1",
"storyThumb": "https://test.local/story.jpg"
"""
private static let commentExtras = """
"galleryUri": "at://did:plc:test/social.grain.gallery/1",
"commentText": "A comment long enough to wrap onto a second line in the row.",
"replyToText": "The comment it is replying to."
"""
private func renderList(_ items: [String], settle: TimeInterval = 0.35) {
MockURLProtocol.respondByPath([
"listNotifications": "{\"notifications\": [\(items.joined(separator: ","))], \"cursor\": null}",
"getUnreadCount": #"{"count": 4}"#,
"actor.getProfile": Fixtures.profileDetailed,
], fallback: "{}")
let env = TestEnvironment()
ViewRender.render(
NotificationsView(client: env.client, viewModel: NotificationsViewModel(client: env.client))
.withTestEnvironment(env),
settle: settle
)
}
// MARK: - Reasons
/// Every reason the appview sends, including one the app doesn't recognise
/// — a list has to draw a row for that rather than dropping it silently.
func testTheListRendersARowForEveryReason() {
let items = [
Self.notification(1, reason: "gallery-favorite", extras: Self.galleryExtras),
Self.notification(2, reason: "gallery-comment", extras: Self.commentExtras),
Self.notification(3, reason: "gallery-comment-mention", extras: Self.commentExtras),
Self.notification(4, reason: "gallery-mention", extras: Self.galleryExtras),
Self.notification(5, reason: "comment-favorite", extras: Self.commentExtras),
Self.notification(6, reason: "story-favorite", extras: Self.storyExtras),
Self.notification(7, reason: "story-comment", extras: Self.storyExtras),
Self.notification(8, reason: "reply", extras: Self.commentExtras),
Self.notification(9, reason: "follow"),
Self.notification(10, reason: "gallery-reposted", extras: Self.galleryExtras),
]
renderList(items)
}
// MARK: - Grouping
/// Four people favoriting one gallery collapse into a single row with a
/// facepile, which is a different row layout from four separate ones.
func testFavoritesOfOneGalleryCollapseIntoOneRow() {
let items = (1 ... 4).map {
Self.notification($0, reason: "gallery-favorite", did: "did:plc:fan\($0)", extras: Self.galleryExtras)
}
renderList(items)
}
/// A story notification carries no gallery thumbnail, so the row falls back
/// to the story's and draws it in portrait.
func testAStoryNotificationFallsBackToItsOwnThumbnail() {
renderList([Self.notification(1, reason: "story-favorite", extras: Self.storyExtras)], settle: 0.25)
}
// MARK: - Nothing to show
func testTheListRendersWhenItFailsToLoad() {
MockURLProtocol.respondWithError(statusCode: 500)
let env = TestEnvironment()
ViewRender.render(
NotificationsView(client: env.client, viewModel: NotificationsViewModel(client: env.client))
.withTestEnvironment(env),
settle: 0.3
)
}
func testTheListRendersWhenThereAreNoNotifications() {
renderList([], settle: 0.25)
}
}