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}