From ce32f4cae5e04a0e3ac918ce18da546952ac0e9f Mon Sep 17 00:00:00 2001 From: systemBlue Date: Mon, 1 Jun 2026 12:24:28 -0400 Subject: [PATCH] Add open-to-clear UI regression test for notifications (#32) Covers the in-app path: open an unread notification, then confirm it leaves the Unread filter after a refresh. Validated against the seeded Docker Forgejo via just mutating_a_classes=NotificationsUITests test-mutating-a; it fails on the pre-fix code and passes with the fix. The merged-instance and system-notification open paths apply the same fix but aren't reachable from this single-instance UI harness. Co-Authored-By: Claude Opus 4.8 (1M context) --- Forji/ForjiUITests/NotificationsUITests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Forji/ForjiUITests/NotificationsUITests.swift b/Forji/ForjiUITests/NotificationsUITests.swift index 3ef71a3..185f4c0 100644 --- a/Forji/ForjiUITests/NotificationsUITests.swift +++ b/Forji/ForjiUITests/NotificationsUITests.swift @@ -49,4 +49,67 @@ final class NotificationsUITests: ForgejoUITestBase { } } } + + // MARK: - Open-to-clear (#32) + + /// Opening an unread notification marks it read on the server, so it leaves + /// the Unread filter on the next refresh. + /// + /// Covers the in-app NotificationsOverviewView path: the tap gesture on the + /// row calls markReadOnOpen, which marks the thread read with + /// removeFromUnread: false so the row stays put during navigation and only + /// drops out of Unread on the next server fetch. The multi-instance and + /// system-notification open paths apply the same fix but aren't reachable + /// from this single-instance harness. + /// + /// Sorts after testNotifications and opens whichever unread row is first + /// rather than a fixed one, so it's unaffected by the two notifications that + /// test consumes (the seed leaves several more). + @MainActor + func testOpeningNotificationMarksItRead() throws { + loginAndWaitForHome() + app.tabBars.buttons["Notifications"].tap() + + let notificationsList = app.collectionViews.firstMatch + XCTAssertTrue(notificationsList.waitForExistence(timeout: 10)) + + // Make sure the Unread filter is active. + let unreadButton = app.buttons["Unread"] + XCTAssertTrue(unreadButton.waitForExistence(timeout: 5)) + unreadButton.tap() + XCTAssertTrue(notificationsList.waitForExistence(timeout: 10)) + + // Remember the first unread notification's title (the row's first text) + // so we can assert that specific one leaves the Unread list. + let firstCell = notificationsList.cells.firstMatch + XCTAssertTrue(firstCell.waitForExistence(timeout: 10), "Expected at least one unread notification") + let openedTitle = firstCell.staticTexts.firstMatch.label + XCTAssertFalse(openedTitle.isEmpty, "Could not read the unread notification's title") + + // Open it. Navigating into the detail proves the row was tappable; the + // nav bar title changes away from "Notifications" once it's on screen. + firstCell.tap() + let detailNavBar = app.navigationBars.element(boundBy: 0) + XCTAssertTrue(detailNavBar.waitForExistence(timeout: 10), "Detail view did not open") + XCTAssertNotEqual(detailNavBar.identifier, "Notifications", "Did not navigate into the notification detail") + + // Back to the list. + app.navigationBars.buttons.element(boundBy: 0).tap() + XCTAssertTrue(notificationsList.waitForExistence(timeout: 10)) + + // Force a server refresh of Unread by toggling the filter. Opening marks + // the thread read but keeps the row visible until the next fetch, so this + // round-trip is what surfaces the open-to-clear behaviour. + app.buttons["Read"].tap() + XCTAssertTrue(notificationsList.waitForExistence(timeout: 10)) + unreadButton.tap() + XCTAssertTrue(notificationsList.waitForExistence(timeout: 10)) + + // The opened notification should no longer be under Unread. + let openedRow = notificationsList.staticTexts[openedTitle] + XCTAssertFalse( + openedRow.waitForExistence(timeout: 5), + "Opened notification \"\(openedTitle)\" should have left the Unread filter after refresh", + ) + } }