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
@testable import Grain
import SwiftUI
import XCTest
/// The navigation these cover used to be unreachable from a test.
///
/// Each destination lived in a `navigationDestination(item:)` closure that only
/// runs once SwiftUI presents it, so the only way in was a tap. As a value plus
/// a `Route -> View` function, both halves can be exercised directly.
@MainActor
final class RoutingTests: GrainTestCase {
// MARK: - Router
func testPushingAppendsToThePath() {
let router = Router()
router.push(.profile(did: "did:plc:a"))
router.push(.hashtag("portra"))
XCTAssertEqual(router.path, [.profile(did: "did:plc:a"), .hashtag("portra")])
}
func testPoppingRemovesOnlyTheTop() {
let router = Router(path: [.profile(did: "did:plc:a"), .hashtag("portra")])
router.pop()
XCTAssertEqual(router.path, [.profile(did: "did:plc:a")])
}
func testPoppingAnEmptyPathIsANoOp() {
let router = Router()
router.pop()
XCTAssertTrue(router.path.isEmpty)
}
func testPopToRootClearsEverything() {
let router = Router(path: [.profile(did: "did:plc:a"), .gallery(uri: "at://g")])
router.popToRoot()
XCTAssertTrue(router.path.isEmpty)
}
/// A second deep link arriving while the first is on screen. This is the
/// case that needed a pop plus a 0.35s wait when each destination had its
/// own optional, because `navigationDestination(item:)` won't re-push when
/// its value goes straight from one non-nil to another.
func testReplacingSwapsTheStackInOneStep() {
let router = Router(path: [.gallery(uri: "at://first")])
router.replace(with: [.gallery(uri: "at://second")])
XCTAssertEqual(router.path, [.gallery(uri: "at://second")])
}
// MARK: - Routes as values
/// Routes carry identifiers only, so they survive being written down. That's
/// what makes state restoration and logging possible later.
func testEveryRouteRoundTripsThroughJSON() throws {
let routes: [Route] = [
.gallery(uri: "at://did:plc:a/social.grain.gallery/1"),
.profile(did: "did:plc:a"),
.hashtag("portra"),
.location(h3Index: "8a2a1072b59ffff", name: "Lisbon"),
.galleryFavorites(uri: "at://did:plc:a/social.grain.gallery/1"),
.camera("Leica M6"),
.cameras,
.locations(),
.locations(cell: "8a2a1072b59ffff", name: "Lisbon", galleryCount: 12),
]
let data = try JSONEncoder().encode(routes)
XCTAssertEqual(try JSONDecoder().decode([Route].self, from: data), routes)
}
func testRoutesDistinguishTheirPayloads() {
XCTAssertNotEqual(Route.profile(did: "did:plc:a"), .profile(did: "did:plc:b"))
XCTAssertNotEqual(Route.gallery(uri: "at://g"), .galleryFavorites(uri: "at://g"))
// The index and a place on it are the same screen opened differently.
XCTAssertNotEqual(Route.locations(), .locations(cell: "8a2a", name: "Lisbon"))
}
// MARK: - Deep links and taps agree
func testAProfileLinkBecomesAProfileRoute() throws {
let link = try XCTUnwrap(try DeepLink.from(url: XCTUnwrap(URL(string: "grain://profile/did:plc:a"))))
XCTAssertEqual(link.route, .profile(did: "did:plc:a"))
}
func testAGalleryLinkBecomesTheSameUriTheCardPushes() throws {
let link = try XCTUnwrap(try DeepLink.from(url: XCTUnwrap(URL(string: "grain://profile/did:plc:a/gallery/xyz"))))
XCTAssertEqual(link.route, .gallery(uri: "at://did:plc:a/social.grain.gallery/xyz"))
// The card builds its route from the gallery's own uri; both must agree
// or the same destination would push two different stack entries.
XCTAssertEqual(link.route, .gallery(uri: link.galleryUri ?? ""))
}
/// Stories present over the stack rather than joining it, so they have no route.
func testAStoryLinkHasNoRoute() throws {
let link = try XCTUnwrap(try DeepLink.from(url: XCTUnwrap(URL(string: "grain://profile/did:plc:a/story/xyz"))))
XCTAssertNil(link.route)
}
// MARK: - Route -> View
/// Each of these bodies previously required a tap to reach.
func testEveryRouteBuildsItsDestination() {
let env = TestEnvironment()
MockURLProtocol.respondByPath(Fixtures.routes, fallback: Fixtures.galleryResponse)
defer { MockURLProtocol.handler = nil }
for route in [
Route.gallery(uri: "at://did:plc:test/social.grain.gallery/1"),
.profile(did: "did:plc:test"),
.hashtag("portra"),
.location(h3Index: "8a2a1072b59ffff", name: "Lisbon"),
.galleryFavorites(uri: "at://did:plc:test/social.grain.gallery/1"),
.camera("Leica M6"),
.cameras,
.locations(),
.locations(cell: "8a2a1072b59ffff", name: "Lisbon", galleryCount: 12),
] {
ViewRender.render(
NavigationStack {
RouteDestination(route: route, client: env.client)
}
.withTestEnvironment(env),
settle: 0.2
)
}
}
}