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
import CryptoKit
@testable import Grain
import XCTest
/// What the publish overlay tells someone when a gallery doesn't go out.
///
/// This copy is the entire failure experience — the overlay covers the sheet
/// and offers "try again" or "post later" against it — so a message that says
/// the wrong thing sends people to the wrong remedy. Every branch here has to
/// say what happened and reassure that the work is still on the device.
@MainActor
final class PublishFailureCopyTests: GrainTestCase {
private func message(_ error: Error) -> String {
GalleryUploadCenter.message(for: error)
}
// MARK: - Connection problems
/// Offline is the one case where trying again immediately is pointless, so
/// it gets its own wording.
func testBeingOfflineSaysSoAndPromisesTheWorkIsSaved() {
let copy = message(URLError(.notConnectedToInternet))
XCTAssertTrue(copy.contains("offline"))
XCTAssertTrue(copy.lowercased().contains("saved"), "The reassurance is the point: \(copy)")
}
/// A connection that dropped mid-upload resumes from where it stopped, so
/// the copy says as much rather than implying a restart.
func testADroppedConnectionSaysNothingWasLost() {
for code in [URLError.Code.timedOut, .networkConnectionLost, .cannotConnectToHost, .dnsLookupFailed] {
let copy = message(URLError(code))
XCTAssertTrue(copy.contains("Nothing was lost"), "\(code) said: \(copy)")
}
}
/// A URL error the app doesn't have wording for still has to say something
/// rather than fall through to an empty string.
func testAnUnrecognisedURLErrorStillGetsAMessage() {
let copy = message(URLError(.badURL))
XCTAssertFalse(copy.isEmpty)
XCTAssertTrue(copy.hasPrefix("Couldn't post"))
}
// MARK: - Server responses
/// An expired session is the one failure with a specific remedy, and
/// retrying isn't it.
func testAnExpiredSessionSaysToSignInAgain() {
let copy = message(XRPCError.unauthorized)
XCTAssertTrue(copy.lowercased().contains("sign in"))
}
func testRateLimitingSaysToWait() {
let copy = message(XRPCError.httpError(statusCode: 429, body: nil))
XCTAssertTrue(copy.lowercased().contains("rate limit"))
}
/// A server error is worth retrying, so it reads like the dropped
/// connection rather than like a rejection.
func testAServerErrorIsPresentedAsWorthRetrying() {
for status in [500, 503, 599] {
let copy = message(XRPCError.httpError(statusCode: status, body: nil))
XCTAssertTrue(copy.contains("Nothing was lost"), "HTTP \(status) said: \(copy)")
}
}
/// A rejection the app has no wording for surfaces the server's own
/// explanation — that is more use than a generic apology.
func testAnUnrecognisedRejectionQuotesTheServer() {
let copy = message(XRPCError.httpError(statusCode: 422, body: Data("blob too large".utf8)))
XCTAssertTrue(copy.contains("422"))
XCTAssertTrue(copy.contains("blob too large"))
}
func testARejectionWithNoBodyStillNamesTheStatus() {
let copy = message(XRPCError.httpError(statusCode: 422, body: nil))
XCTAssertTrue(copy.contains("422"))
XCTAssertFalse(copy.hasSuffix(": "))
}
/// A decode failure isn't something the user can act on, but it still has
/// to read as a sentence.
func testAnUnrecognisedXRPCErrorStillGetsAMessage() {
let copy = message(XRPCError.decodingError(URLError(.badServerResponse)))
XCTAssertTrue(copy.hasPrefix("Couldn't post"))
}
// MARK: - Problems with the photos
/// A photo the library won't hand over names itself, because the remedy is
/// to remove that specific one.
func testAnUnreadablePhotoNamesWhichOneToRemove() {
let copy = message(GalleryDraftError.photoUnavailable(index: 2))
XCTAssertTrue(copy.contains("Photo 3"), "Counted from one, as the picker shows them: \(copy)")
XCTAssertTrue(copy.lowercased().contains("remove"))
}
// MARK: - Anything else
func testAnyOtherErrorFallsBackToItsOwnDescription() {
struct Odd: LocalizedError {
var errorDescription: String? {
"something specific went wrong"
}
}
let copy = message(Odd())
XCTAssertTrue(copy.contains("something specific went wrong"))
}
/// Every branch has to produce something a person can read — no empty
/// strings, no debug formatting or type names leaking through.
///
/// Deliberately not asserting punctuation: several of these end in text the
/// server supplied, and that is not ours to tidy.
func testEveryFailureProducesReadableCopy() {
let errors: [Error] = [
URLError(.notConnectedToInternet),
URLError(.timedOut),
URLError(.badURL),
XRPCError.unauthorized,
XRPCError.httpError(statusCode: 429, body: nil),
XRPCError.httpError(statusCode: 500, body: nil),
XRPCError.httpError(statusCode: 400, body: Data("nope".utf8)),
XRPCError.decodingError(URLError(.badServerResponse)),
XRPCError.invalidURL,
GalleryDraftError.photoUnavailable(index: 0),
]
for error in errors {
let copy = message(error)
XCTAssertFalse(copy.isEmpty, "\(error) produced no message")
XCTAssertGreaterThan(copy.count, 10, "Too terse to be useful: \(copy)")
XCTAssertFalse(copy.contains("Optional("), "Debug formatting leaked: \(copy)")
XCTAssertFalse(copy.contains("Grain."), "A type name leaked: \(copy)")
}
}
}