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