mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Adds background notifications for the "the thing you were waiting for is
done" moments, alongside the existing incoming-approval-request one:
- a receive finished downloading (receive -> done)
- a receive failed / was interrupted (receive -> failed)
- a share you own failed (send -> failed)
- a receiver finished downloading your share (receiver status completed)
A new TransferNotificationCoordinator observes core state + signals and
publishes these; the decision of which moments notify is a pure function
(plannedTransferNotifications / plannedReceiverNotifications), unit-tested
independently. The first state snapshot only primes existing history as seen
so launch doesn't spam.
Notification permission is now the single source of truth. The in-app
notifications toggle and its decoupled UserDefaults preference are gone;
the Settings section shows an "Allow notifications" button that requests the
OS permission (or deep-links to Settings once decided), and notifications
gate purely on `permission == .granted`.
macOS delivery fixes:
- add a UNUserNotificationCenterDelegate so banners present even while the
app is active (the app window is usually open on macOS)
- present-when-active on macOS, suppress-when-foregrounded on iOS
- reserve the notification id before awaiting publish: the CombineLatest
fired several times and re-added the same identifier, which macOS
coalesces into a silent update with no banner
- LocalNotificationService seeds its permission at init so gating can't
race a not-yet-refreshed .notDetermined
Eight localized title/body strings added (apple-only); the shared
notifications_description copy is generalized from "receive requests" to
"transfer activity".
69 lines
2.7 KiB
Swift
69 lines
2.7 KiB
Swift
import XCTest
|
|
import Combine
|
|
@testable import VniDrop
|
|
|
|
/// Ports `feature/approvals/ApprovalCoordinatorTest.kt` (the gateway-observable
|
|
/// parts; notification assertions require a notification-service seam we don't
|
|
/// have on Apple yet).
|
|
@MainActor
|
|
final class ApprovalCoordinatorTests: XCTestCase {
|
|
|
|
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
|
|
ApprovalCoordinator(
|
|
repository: core,
|
|
notifications: LocalNotificationService(),
|
|
visibility: AppVisibility(),
|
|
messages: UiMessageController()
|
|
)
|
|
}
|
|
|
|
func testOrdersPendingRequestsByRequestedAt() async {
|
|
let core = FakeCoreGateway()
|
|
core.requests[1] = [Fixtures.request(id: "new", requestedAt: 20),
|
|
Fixtures.request(id: "old", requestedAt: 10)]
|
|
let coordinator = makeCoordinator(core)
|
|
|
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
|
|
core.emit(.approvalChanged(transferId: 1))
|
|
|
|
await waitUntil { !coordinator.state.pending.isEmpty }
|
|
XCTAssertEqual(coordinator.state.pending.map(\.id), ["old", "new"])
|
|
XCTAssertEqual(coordinator.state.current?.id, "old")
|
|
}
|
|
|
|
func testFailedResponseKeepsRequestVisibleAndClearsResponding() async {
|
|
let core = FakeCoreGateway()
|
|
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
|
|
core.responseResult = .failure(TestError.unimplemented)
|
|
let coordinator = makeCoordinator(core)
|
|
|
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
|
|
core.emit(.approvalChanged(transferId: 1))
|
|
await waitUntil { coordinator.state.pending.contains { $0.id == "request" } }
|
|
|
|
coordinator.accept("request")
|
|
await waitUntil { coordinator.state.respondingIds.isEmpty && core.responses.count == 1 }
|
|
|
|
XCTAssertTrue(coordinator.state.pending.contains { $0.id == "request" })
|
|
XCTAssertTrue(coordinator.state.respondingIds.isEmpty)
|
|
XCTAssertEqual(core.responses.first?.accepted, true)
|
|
}
|
|
|
|
func testAcceptRespondsPositivelyAndSingleFlights() async {
|
|
let core = FakeCoreGateway()
|
|
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
|
|
let coordinator = makeCoordinator(core)
|
|
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
|
|
core.emit(.approvalChanged(transferId: 1))
|
|
await waitUntil { coordinator.state.current != nil }
|
|
|
|
coordinator.accept("request")
|
|
coordinator.accept("request") // second call must be ignored (single-flight)
|
|
await waitUntil { core.responses.count >= 1 }
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
XCTAssertEqual(core.responses.count, 1)
|
|
XCTAssertEqual(core.responses.first?.id, "request")
|
|
}
|
|
}
|