krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.0: HutchTests/InboxViewModelTests.swift · raw
1import Foundation
2import Testing
3@testable import Hutch
4
5struct InboxViewModelTests {
6
7 @Test
8 func derivesRepositoryNameFromCommonPatchListSuffixes() {
9 #expect(InboxThreadUtilities.deriveRepositoryName(from: "hut-devel") == "hut")
10 #expect(InboxThreadUtilities.deriveRepositoryName(from: "git.patches") == "git")
11 #expect(InboxThreadUtilities.deriveRepositoryName(from: "discuss") == nil)
12 }
13
14 @Test
15 func parsesMailtoDraft() {
16 let draft = ThreadViewModel.mailComposeDraft(
17 from: "mailto:list@example.com?cc=author@example.com&subject=Re:%20PATCH&body=LGTM"
18 )
19
20 #expect(draft?.recipients == ["list@example.com"])
21 #expect(draft?.ccRecipients == ["author@example.com"])
22 #expect(draft?.subject == "Re: PATCH")
23 #expect(draft?.body == "LGTM")
24 }
25
26 @Test
27 func computesLocalUnreadStateFromLastViewedMarker() {
28 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
29 let defaults = UserDefaults(suiteName: suiteName)!
30 defer { defaults.removePersistentDomain(forName: suiteName) }
31
32 let threadID = "list#message"
33 let lastActivity = Date(timeIntervalSince1970: 2_000)
34
35 #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
36
37 InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: threadID, defaults: defaults)
38 #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
39
40 InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 2_500), for: threadID, defaults: defaults)
41 #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
42 }
43
44 @Test
45 func freshAccountTreatsExistingMailAsRead() {
46 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
47 let defaults = UserDefaults(suiteName: suiteName)!
48 defer { defaults.removePersistentDomain(forName: suiteName) }
49
50 let signIn = Date(timeIntervalSince1970: 5_000)
51 InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults)
52
53 // Years of list history should not land on a new user as unread.
54 #expect(
55 !InboxReadStateStore.isUnread(
56 threadID: "list#old",
57 lastActivityAt: Date(timeIntervalSince1970: 4_000),
58 defaults: defaults
59 )
60 )
61 }
62
63 @Test
64 func mailArrivingAfterSignInIsUnread() {
65 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
66 let defaults = UserDefaults(suiteName: suiteName)!
67 defer { defaults.removePersistentDomain(forName: suiteName) }
68
69 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
70
71 #expect(
72 InboxReadStateStore.isUnread(
73 threadID: "list#new",
74 lastActivityAt: Date(timeIntervalSince1970: 6_000),
75 defaults: defaults
76 )
77 )
78 }
79
80 @Test
81 func mailExactlyAtTheBaselineIsRead() {
82 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
83 let defaults = UserDefaults(suiteName: suiteName)!
84 defer { defaults.removePersistentDomain(forName: suiteName) }
85
86 let signIn = Date(timeIntervalSince1970: 5_000)
87 InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults)
88
89 #expect(!InboxReadStateStore.isUnread(threadID: "list#edge", lastActivityAt: signIn, defaults: defaults))
90 }
91
92 @Test
93 func baselineIsEstablishedOnceAndNotMovedBySubsequentSignIns() {
94 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
95 let defaults = UserDefaults(suiteName: suiteName)!
96 defer { defaults.removePersistentDomain(forName: suiteName) }
97
98 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
99 // A later launch must not silently mark the backlog read.
100 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 9_000), defaults: defaults)
101
102 #expect(
103 InboxReadStateStore.isUnread(
104 threadID: "list#since",
105 lastActivityAt: Date(timeIntervalSince1970: 6_000),
106 defaults: defaults
107 )
108 )
109 }
110
111 @Test
112 func existingAccountsKeepTheirUnreadBacklog() {
113 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
114 let defaults = UserDefaults(suiteName: suiteName)!
115 defer { defaults.removePersistentDomain(forName: suiteName) }
116
117 // An account already carrying read state has been in use, so upgrading
118 // must not retroactively mark everything it had not read as read.
119 InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: "list#seen", defaults: defaults)
120 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
121
122 #expect(
123 InboxReadStateStore.isUnread(
124 threadID: "list#unseen",
125 lastActivityAt: Date(timeIntervalSince1970: 4_000),
126 defaults: defaults
127 )
128 )
129 }
130
131 @Test
132 func markingAnOldThreadUnreadSurvivesTheBaseline() {
133 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
134 let defaults = UserDefaults(suiteName: suiteName)!
135 defer { defaults.removePersistentDomain(forName: suiteName) }
136
137 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
138 let oldActivity = Date(timeIntervalSince1970: 4_000)
139
140 #expect(!InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults))
141
142 // Explicitly marking it unread must stick, rather than falling back to the
143 // baseline rule and reading as read again.
144 InboxReadStateStore.markUnread(for: "list#old", defaults: defaults)
145 #expect(InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults))
146 }
147
148 @Test
149 func normalizesThreadSubjectsForDisplay() {
150 let summary = InboxThreadSummary(
151 rootEmailID: 1,
152 rootMessageID: "message",
153 threadRootEmailIDs: [1],
154 threadRootMessageIDs: ["message"],
155 listID: 2,
156 listRID: "list",
157 listName: "hut-devel",
158 listOwner: Entity(canonicalName: "~owner"),
159 subject: "Re: Fwd: [PATCH] test: add parser ",
160 latestSender: Entity(canonicalName: "~sender"),
161 lastActivityAt: Date(timeIntervalSince1970: 2_000),
162 messageCount: 3,
163 repo: "hut",
164 containsPatch: true,
165 isUnread: true
166 )
167
168 #expect(summary.displaySubject == "[PATCH] test: add parser")
169 #expect(summary.metadataLine.contains("~sender"))
170 #expect(summary.metadataLine.contains("2 replies"))
171 }
172
173 @Test
174 func keepsDistinctThreadsDistinctByRootMessageID() {
175 let baseList = InboxMailingListReference(
176 id: 1,
177 rid: "list",
178 name: "hut-devel",
179 owner: Entity(canonicalName: "~owner")
180 )
181
182 let first = InboxThreadSummary(
183 rootEmailID: 10,
184 rootMessageID: "message-1",
185 threadRootEmailIDs: [10],
186 threadRootMessageIDs: ["message-1"],
187 listID: baseList.id,
188 listRID: baseList.rid,
189 listName: baseList.name,
190 listOwner: baseList.owner,
191 subject: "[PATCH] test",
192 latestSender: Entity(canonicalName: "~a"),
193 lastActivityAt: Date(timeIntervalSince1970: 100),
194 messageCount: 1,
195 repo: "hut",
196 containsPatch: true,
197 isUnread: true
198 )
199 let second = InboxThreadSummary(
200 rootEmailID: 11,
201 rootMessageID: "message-2",
202 threadRootEmailIDs: [11],
203 threadRootMessageIDs: ["message-2"],
204 listID: baseList.id,
205 listRID: baseList.rid,
206 listName: baseList.name,
207 listOwner: baseList.owner,
208 subject: "[PATCH] test",
209 latestSender: Entity(canonicalName: "~b"),
210 lastActivityAt: Date(timeIntervalSince1970: 200),
211 messageCount: 2,
212 repo: "hut",
213 containsPatch: true,
214 isUnread: true
215 )
216
217 #expect(first.id != second.id)
218 }
219
220 @Test
221 func mailingListThreadFilterMatchesSubjectAndSender() {
222 let baseList = InboxMailingListReference(
223 id: 1,
224 rid: "list",
225 name: "hutch-devel",
226 owner: Entity(canonicalName: "~owner")
227 )
228 let threads = [
229 InboxThreadSummary(
230 rootEmailID: 10,
231 rootMessageID: "message-1",
232 threadRootEmailIDs: [10],
233 threadRootMessageIDs: ["message-1"],
234 listID: baseList.id,
235 listRID: baseList.rid,
236 listName: baseList.name,
237 listOwner: baseList.owner,
238 subject: "Re: [PATCH] add search",
239 latestSender: Entity(canonicalName: "~alice"),
240 lastActivityAt: Date(timeIntervalSince1970: 100),
241 messageCount: 1,
242 repo: "hutch",
243 containsPatch: true,
244 isUnread: true
245 ),
246 InboxThreadSummary(
247 rootEmailID: 11,
248 rootMessageID: "message-2",
249 threadRootEmailIDs: [11],
250 threadRootMessageIDs: ["message-2"],
251 listID: baseList.id,
252 listRID: baseList.rid,
253 listName: baseList.name,
254 listOwner: baseList.owner,
255 subject: "Release planning",
256 latestSender: Entity(canonicalName: "~bob"),
257 lastActivityAt: Date(timeIntervalSince1970: 200),
258 messageCount: 2,
259 repo: "hutch",
260 containsPatch: false,
261 isUnread: true
262 )
263 ]
264
265 let subjectMatches = MailingListDetailViewModel.filterThreads(threads, matching: "search")
266 let senderMatches = MailingListDetailViewModel.filterThreads(threads, matching: "~bob")
267
268 #expect(subjectMatches.map(\.rootEmailID) == [10])
269 #expect(senderMatches.map(\.rootEmailID) == [11])
270 }
271
272 @Test
273 func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
274 let body = """
275 From: Christian Cleberg <root@krz.sh>
276
277 ---
278 test-patch.txt | 1 +
279 1 file changed, 1 insertion(+)
280 create mode 100644 test-patch.txt
281
282 diff --git a/test-patch.txt b/test-patch.txt
283 new file mode 100644
284 index 0000000..c7b6eed
285 --- /dev/null
286 +++ b/test-patch.txt
287 @@ -0,0 +1 @@
288 +test Wed Mar 18 23:19:03 CDT 2026
289 --
290 2.50.1 (Apple Git-155)
291 """
292
293 let segments = InboxThreadUtilities.segmentMessageBody(body, isPatch: true)
294
295 #expect(segments.count == 3)
296
297 guard case let .plainText(leadingPlainText) = segments[0] else {
298 Issue.record("Expected first segment to be plain text")
299 return
300 }
301 #expect(leadingPlainText.contains("From: Christian Cleberg <root@krz.sh>"))
302 #expect(leadingPlainText.contains("---"))
303 #expect(leadingPlainText.contains(" test-patch.txt | 1 +"))
304 #expect(leadingPlainText.contains(" 1 file changed, 1 insertion(+)"))
305 #expect(leadingPlainText.contains(" create mode 100644 test-patch.txt"))
306
307 guard case let .diff(diff) = segments[1] else {
308 Issue.record("Expected second segment to be diff")
309 return
310 }
311 #expect(diff.contains("diff --git a/test-patch.txt b/test-patch.txt"))
312 #expect(diff.contains("--- /dev/null"))
313 #expect(diff.contains("+++ b/test-patch.txt"))
314 #expect(diff.contains("+test Wed Mar 18 23:19:03 CDT 2026"))
315 #expect(!diff.contains("-- \n2.50.1 (Apple Git-155)"))
316
317 guard case let .plainText(trailingPlainText) = segments[2] else {
318 Issue.record("Expected third segment to be plain text")
319 return
320 }
321 #expect(trailingPlainText.contains("--"))
322 #expect(trailingPlainText.contains("2.50.1 (Apple Git-155)"))
323 }
324}