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
@testable import Grain
import SwiftUI
import XCTest
/// The sign-in screen once a handle is being typed. Most of it — the
/// suggestions list, the highlighted row, the error line, the re-auth notice —
/// only exists past a keystroke, so an empty render never reaches it.
@MainActor
final class LoginRenderTests: 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()
MockURLProtocol.respondByPath(Fixtures.routes)
}
override func tearDown() async throws {
MockURLProtocol.handler = nil
account.restore()
try await super.tearDown()
}
private func suggestions(_ count: Int) -> [ActorSuggestion] {
let names = ["alice", "alastair", "alba", "aled", "alex", "alfie"]
return (0 ..< count).map { index in
ActorSuggestion(
handle: "\(names[index % names.count]).grain.social",
displayName: index.isMultiple(of: 2) ? names[index % names.count].capitalized : nil,
avatar: index.isMultiple(of: 3) ? nil : "https://test.local/\(index).jpg"
)
}
}
private func render(handle: String = "", suggestions: [ActorSuggestion] = [], settle: TimeInterval = 0.3) {
let env = TestEnvironment(authenticated: false)
ViewRender.render(
LoginView(handle: handle, suggestions: suggestions).withTestEnvironment(env),
settle: settle
)
}
/// The screen as it opens — marketing copy, no field content, no results.
func testRendersTheSignInScreenBeforeAnyTyping() {
render()
}
/// A partial handle with matches behind it: the suggestions list replaces
/// the copy above the field.
func testRendersTheSuggestionsList() {
render(handle: "al", suggestions: suggestions(5))
}
/// One result and a full list are laid out differently — the last row has
/// no separator under it.
func testRendersTheSuggestionsListAtEachSize() {
for count in [1, 2, 6] {
render(handle: "al", suggestions: suggestions(count))
}
}
/// A suggestion with no display name falls back to the handle, and one with
/// no avatar falls back to the placeholder.
func testRendersSuggestionsMissingTheirDetails() {
render(handle: "al", suggestions: [
ActorSuggestion(handle: "bare.grain.social", displayName: nil, avatar: nil),
ActorSuggestion(handle: "named.grain.social", displayName: "Named", avatar: nil),
])
}
/// Typed something that matched nothing — the field is filled but the list
/// stays empty, which is a third state again.
func testRendersATypedHandleWithNoMatches() {
render(handle: "nobodyhasthishandle", suggestions: [])
}
/// A full handle is what the Go key submits.
func testRendersACompleteHandle() {
render(handle: "tester.grain.social", suggestions: [])
}
/// After a scope bump the screen carries a notice explaining why everyone
/// got signed out.
func testRendersTheReauthNotice() {
let env = TestEnvironment(authenticated: false)
env.auth.reauthReason = "Grain has been updated. Please sign in again to enable new features."
ViewRender.render(LoginView().withTestEnvironment(env), settle: 0.3)
}
/// The notice is suppressed while suggestions are up, so the list isn't
/// pushed off screen by it.
func testTheReauthNoticeStepsAsideForSuggestions() {
let env = TestEnvironment(authenticated: false)
env.auth.reauthReason = "Grain has been updated."
ViewRender.render(
LoginView(handle: "al", suggestions: suggestions(3)).withTestEnvironment(env),
settle: 0.3
)
}
// MARK: - Suggestion model
/// Rows are diffed on the handle, so two accounts must never collide.
func testSuggestionsAreIdentifiedByHandle() {
let alice = ActorSuggestion(handle: "alice.grain.social", displayName: "Alice", avatar: nil)
XCTAssertEqual(alice.id, "alice.grain.social")
XCTAssertEqual(alice, ActorSuggestion(handle: "alice.grain.social", displayName: "Alice", avatar: nil))
XCTAssertNotEqual(alice, ActorSuggestion(handle: "bob.grain.social", displayName: "Alice", avatar: nil))
}
// MARK: - Legal copy
/// The three links are a compliance requirement, so they're pinned
/// alongside the rendering.
func testTheLegalLineCarriesAllThreeLinks() throws {
let attributed = try AttributedString(markdown: LoginView.legalMarkdown)
let urls = attributed.runs.compactMap { $0.link?.absoluteString }
XCTAssertEqual(Set(urls).count, 3)
}
}