feat(apple): saved devices and targeted transfers UI

Adds the native SwiftUI Saved Devices experience on top of the production
saved-device core, as a top-level destination in the iOS tab bar and the
macOS sidebar.

Core seam:
- App-facing saved-device domain models mirroring core/SavedDeviceModels.kt,
  with lifecycle helpers (canReceive/canResume/canCancel/canDelete) so views
  never hand-roll state checks.
- 21 gateway methods through CoreGateway/CoreRepository with UniFFI mapping.
  cancelTargetedTransfer, forgetSavedDevice and blockDevice run off the serial
  lane: each must reach the core while a targeted receive is blocking it.
- Payload-free pairingChanged/targetedTransferChanged signals, dispatched
  before the numeric-transferId guard since saved-device events identify
  their subject by peer endpoint or a string transfer id.

Experience:
- Screen lists saved devices and outstanding consent requests only; the
  global targeted-transfer history stays out, reachable per device.
- Details as a sheet with detents on compact layouts and a native inspector
  on macOS, owning Send, label, forget/block and that device's transfers.
- Label editing is transactional: the draft and editor survive a failed
  write, conflicting actions are refused while saving, and the editor closes
  only after the core confirms.
- Pairing and targeted-offer consent hosted at the app root, answerable from
  any tab and suppressed while a transfer approval is up. Dismissing a
  pairing prompt suppresses locally without consuming the single-use
  eligibility; dismissing an offer declines it, since an unanswered offer
  holds a slot in the core's bounded per-sender queue.
- Targeted send reuses the invitation composer's affordances with file,
  folder, rename, replace and cleanup parity. Picker copies are released on
  replace/remove/clear/cancel and after a successful create, but kept after a
  failure so retry does not require re-picking.
- Notifications for pairing requests and offers (withdrawn once answered) and
  for terminal targeted transfers. Wording follows direction: on the sending
  device the peer finished receiving, not us.

Localization:
- Widens 52 saved-device keys from kmp-only to both platforms.
- Five keys carried a literal %1$s with no declared args, which Compose
  renders positionally but the Apple generator emits as a plain constant,
  leaking the placeholder into the UI. They now use named args; Compose
  output is byte-identical.
- Adds targeted_offer_title/body. Reusing the invitation approval copy stated
  the roles backwards, announcing the sender as the receiver.

Also surfaces core startup failures: the startup overlay is drawn above the
snackbar host, so a failed initialize() was indistinguishable from an app
that never finished loading. AppModel now keeps the reason, logs it, and the
overlay shows it with a retry, plus the technical detail in DEBUG builds.

Send and receive between two devices is verified only partially; a missing
endpoint-identity credential currently blocks startup on the test device.
This commit is contained in:
2026-08-13 19:42:37 +02:00
parent bece2af179
commit 8bb1442338
33 changed files with 3837 additions and 247 deletions

View File

@@ -81,6 +81,122 @@ final class FakeCoreGateway: CoreGateway {
return responseResult
}
func refresh() async -> Result<Void, Error> { .success(()) }
// MARK: - Saved devices
// Stubbed results
var savedDevices: [SavedDeviceModel] = []
/// Overrides `savedDevices` when set, so a test can fail one leg of the
/// five-read snapshot without stubbing the rest.
var savedDevicesResult: Result<[SavedDeviceModel], Error>?
var deviceRelationships: [DeviceRelationshipModel] = []
var pairingEligibilities: [PairingEligibilityModel] = []
var blockedDevices: [String] = []
var pendingTargetedOffers: [PendingTargetedOfferModel] = []
var targetedTransfers: [TargetedTransferModel] = []
var setLabelResult: Result<Void, Error> = .success(())
var forgetResult: Result<Void, Error> = .success(())
var blockResult: Result<Void, Error> = .success(())
var requestPairingResult: Result<Bool, Error> = .success(true)
var respondToPairingResult: Result<Bool, Error> = .success(true)
var offerResponseResult: Result<TargetedOfferResponseModel, Error> = .success(.declined)
var createTargetedTransferResult: Result<TargetedTransferModel, Error> = .failure(TestError.unimplemented)
var targetedReceiveResult: Result<Void, Error> = .success(())
var targetedCancelResult: Result<Void, Error> = .success(())
var targetedDeleteResult: Result<Void, Error> = .success(())
// Recorded calls
private(set) var setLabels: [(peerEndpointId: String, label: String?)] = []
private(set) var forgottenDevices: [String] = []
private(set) var blockedCalls: [String] = []
private(set) var unblockedCalls: [String] = []
private(set) var declinedEligibilities: [String] = []
private(set) var requestedPairings: [String] = []
private(set) var pairingResponses: [(peerEndpointId: String, accepted: Bool)] = []
private(set) var offerResponses: [(transferId: String, accepted: Bool)] = []
private(set) var createdTargetedTransfers: [(receiverEndpointId: String, sources: [ShareSource], transferName: String?)] = []
private(set) var targetedReceives: [(transferId: String, outputDirectoryUrl: String)] = []
private(set) var targetedResumes: [(id: String, outputDirectoryUrl: String)] = []
private(set) var cancelledTargetedTransfers: [String] = []
private(set) var deletedTargetedTransfers: [String] = []
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> {
.success(pairingEligibilities)
}
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error> {
declinedEligibilities.append(peerEndpointId)
return .success(())
}
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error> {
requestedPairings.append(peerEndpointId)
return requestPairingResult
}
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error> {
pairingResponses.append((peerEndpointId, accepted))
return respondToPairingResult
}
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> {
.success(deviceRelationships)
}
func listSavedDevices() async -> Result<[SavedDeviceModel], Error> {
savedDevicesResult ?? .success(savedDevices)
}
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error> {
setLabels.append((peerEndpointId, label))
return setLabelResult
}
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error> {
forgottenDevices.append(peerEndpointId)
return forgetResult
}
func blockDevice(peerEndpointId: String) async -> Result<Void, Error> {
blockedCalls.append(peerEndpointId)
return blockResult
}
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error> {
unblockedCalls.append(peerEndpointId)
return .success(())
}
func listBlockedDevices() async -> Result<[String], Error> { .success(blockedDevices) }
// MARK: - Targeted transfers
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> {
.success(pendingTargetedOffers)
}
func respondToTargetedOffer(
transferId: String, accepted: Bool
) async -> Result<TargetedOfferResponseModel, Error> {
offerResponses.append((transferId, accepted))
return offerResponseResult
}
func createTargetedTransfer(
receiverEndpointId: String, sources: [ShareSource], transferName: String?
) async -> Result<TargetedTransferModel, Error> {
createdTargetedTransfers.append((receiverEndpointId, sources, transferName))
return createTargetedTransferResult
}
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> {
.success(targetedTransfers)
}
func receiveTargetedTransfer(
transferId: String, outputDirectoryUrl: String
) async -> Result<Void, Error> {
targetedReceives.append((transferId, outputDirectoryUrl))
return targetedReceiveResult
}
func resumeTargetedTransfer(id: String, outputDirectoryUrl: String) async -> Result<Void, Error> {
targetedResumes.append((id, outputDirectoryUrl))
return targetedReceiveResult
}
func cancelTargetedTransfer(id: String) async -> Result<Void, Error> {
cancelledTargetedTransfers.append(id)
return targetedCancelResult
}
func deleteTargetedTransfer(id: String) async -> Result<Void, Error> {
deletedTargetedTransfers.append(id)
return targetedDeleteResult
}
}
/// Minimal `FileSystemService` fake a writable path receive folder, no reveal.
@@ -103,6 +219,31 @@ final class FakeFileSystemService: FileSystemService {
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
/// Records targeted sends and forwards to the gateway so its recorders and
/// stubbed result drive the assertions.
private(set) var targetedSends: [(files: [PickedShareFile], transferName: String, receiver: String)] = []
func sendPickedFilesToSavedDevice(
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
receiverEndpointId: String
) async -> Result<TargetedTransferModel, Error> {
targetedSends.append((files, transferName, receiverEndpointId))
return await repository.createTargetedTransfer(
receiverEndpointId: receiverEndpointId,
sources: [],
transferName: transferName.isEmpty ? nil : transferName
)
}
/// Picker copies released via `discardPickedFiles`.
private(set) var discardedFiles: [String] = []
func discardPickedFiles(_ files: [PickedShareFile]) async {
discardedFiles.append(contentsOf: files.map(\.value))
}
}
@MainActor

View File

@@ -0,0 +1,167 @@
import XCTest
@testable import VniDrop
@MainActor
final class SavedDeviceNotificationTests: XCTestCase {
private static let peer = "peer-endpoint"
private func state(
pendingRelationships: [DeviceRelationshipModel] = [],
eligibilities: [PairingEligibilityModel] = [],
savedDevices: [SavedDeviceModel] = [],
offers: [PendingTargetedOfferModel] = [],
senderDisplayNames: [String: String] = [:],
transfers: [SavedDeviceTransferItem] = []
) -> SavedDevicesState {
var state = SavedDevicesState()
state.isLoading = false
state.pendingRelationships = pendingRelationships
state.eligibilities = eligibilities
state.savedDevices = savedDevices
state.targetedOffers.pending = offers
state.targetedOffers.senderDisplayNames = senderDisplayNames
state.targetedTransfers = transfers
return state
}
private func relationship(_ state: DeviceRelationshipStateModel) -> DeviceRelationshipModel {
DeviceRelationshipModel(
remoteEndpointId: Self.peer, state: state, generation: 1,
minimumProtocolVersion: 1, createdAt: 1, updatedAt: 1
)
}
private func offer(_ transferId: String = "t1") -> PendingTargetedOfferModel {
PendingTargetedOfferModel(
transferId: transferId, senderEndpointId: Self.peer, receiverEndpointId: "me",
manifestId: "m", contentHash: "h", transferName: "Photos", fileCount: 1,
totalSize: 10, protocolVersion: 1, receivedAt: 1
)
}
private func transferItem(
id: String = "t1",
state: TargetedTransferStateModel,
direction: SavedDeviceTransferDirection = .incoming
) -> SavedDeviceTransferItem {
SavedDeviceTransferItem(
id: id, peerEndpointId: Self.peer, peerDisplayName: "Studio Mac",
direction: direction, transferName: "Photos", fileCount: 1, totalSize: 10,
verifiedBytes: 10, state: state, createdAt: 1, updatedAt: 1
)
}
// MARK: - Prompts
func testIncomingPairingRequestIsPlanned() {
let planned = plannedSavedDevicePrompts(state(pendingRelationships: [relationship(.pendingIncoming)]))
XCTAssertEqual(planned.map(\.kind), [.pairingRequest])
XCTAssertEqual(planned.first?.id, "pairing-request-\(Self.peer)")
}
func testOutgoingPairingRequestIsNotPlanned() {
// We are the ones waiting; there is nothing for the user to answer.
let planned = plannedSavedDevicePrompts(state(pendingRelationships: [relationship(.pendingOutgoing)]))
XCTAssertTrue(planned.isEmpty)
}
func testPairingRequestUsesEligibilityNameWhenAvailable() {
let eligibility = PairingEligibilityModel(
peerEndpointId: Self.peer, remoteDisplayName: "Alice's Mac", sessionId: "s",
protocolVersion: 1, createdAt: 1, expiresAt: 2
)
let planned = plannedSavedDevicePrompts(state(
pendingRelationships: [relationship(.pendingIncoming)],
eligibilities: [eligibility]
))
XCTAssertEqual(planned.first?.deviceName, "Alice's Mac")
}
func testPendingOfferIsPlannedWithSenderNameOnlyWhenSaved() {
let unnamed = plannedSavedDevicePrompts(state(offers: [offer()]))
XCTAssertEqual(unnamed.map(\.kind), [.targetedOffer])
// An unsaved sender has no name we can vouch for.
XCTAssertNil(unnamed.first?.deviceName)
let named = plannedSavedDevicePrompts(state(
offers: [offer()],
senderDisplayNames: [Self.peer: "Studio Mac"]
))
XCTAssertEqual(named.first?.deviceName, "Studio Mac")
}
func testPromptIdsAreStablePerSubject() {
// Stable ids are what let the coordinator withdraw a prompt once answered.
let first = plannedSavedDevicePrompts(state(offers: [offer()]))
let second = plannedSavedDevicePrompts(state(offers: [offer()]))
XCTAssertEqual(first.map(\.id), second.map(\.id))
}
// MARK: - Terminal outcomes
func testCompletedAndFailedTransfersArePlanned() {
let planned = plannedTargetedOutcomes(
[transferItem(id: "a", state: .completed), transferItem(id: "b", state: .failed)],
published: []
)
XCTAssertEqual(planned.map(\.kind), [.targetedReceiveCompleted, .targetedReceiveFailed])
}
func testOutcomeWordingFollowsDirection() {
// On the sending device it is the *peer* that finished downloading. Using
// the receive wording here told the sender it had downloaded its own files.
let outgoing = plannedTargetedOutcomes(
[
transferItem(id: "a", state: .completed, direction: .outgoing),
transferItem(id: "b", state: .failed, direction: .outgoing),
],
published: []
)
XCTAssertEqual(outgoing.map(\.kind), [.targetedSendCompleted, .targetedSendFailed])
let incoming = plannedTargetedOutcomes(
[transferItem(id: "c", state: .completed, direction: .incoming)],
published: []
)
XCTAssertEqual(incoming.map(\.kind), [.targetedReceiveCompleted])
}
func testIdsDoNotCollideAcrossDirections() {
let incoming = plannedTargetedOutcomes([transferItem(state: .completed)], published: [])
let outgoing = plannedTargetedOutcomes(
[transferItem(state: .completed, direction: .outgoing)], published: []
)
XCTAssertNotEqual(incoming.first?.id, outgoing.first?.id)
}
func testUserDrivenTerminalStatesAreNotPlanned() {
// The user already knows: they cancelled or declined it themselves.
let planned = plannedTargetedOutcomes(
[
transferItem(id: "a", state: .cancelled),
transferItem(id: "b", state: .declined),
transferItem(id: "c", state: .transferring),
],
published: []
)
XCTAssertTrue(planned.isEmpty)
}
func testAlreadyPublishedOutcomesAreNotReplanned() {
let first = plannedTargetedOutcomes([transferItem(state: .completed)], published: [])
XCTAssertEqual(first.count, 1)
let second = plannedTargetedOutcomes(
[transferItem(state: .completed)],
published: Set(first.map(\.id))
)
XCTAssertTrue(second.isEmpty)
}
}

View File

@@ -0,0 +1,508 @@
import XCTest
import Combine
@testable import VniDrop
@MainActor
final class SavedDevicesModelTests: XCTestCase {
private var gateway: FakeCoreGateway!
private var fileSystem: FakeFileSystemService!
private var messages: UiMessageController!
override func setUp() async throws {
gateway = FakeCoreGateway()
fileSystem = FakeFileSystemService()
messages = UiMessageController()
}
/// Builds the model and lets the initial refresh settle. The model loads on the
/// combined (preferences, isInitialized) signal, so the core must look ready.
private func makeModel() async -> SavedDevicesModel {
var state = CoreState()
state.isInitialized = true
state.status = CoreStatus(endpointId: Self.localEndpoint, activeTransfers: 0, activeShares: 0)
gateway.setState(state)
let model = SavedDevicesModel(
repository: gateway,
fileSystemService: fileSystem,
preferences: Fixtures.preferences(),
messages: messages
)
await waitUntil { !model.state.isLoading }
return model
}
private static let localEndpoint = "local-endpoint"
private static let peer = "peer-endpoint"
private func savedDevice(
_ endpointId: String = peer,
localLabel: String? = nil,
remoteDisplayName: String? = "Remote Name",
createdAt: Int64 = 1
) -> SavedDeviceModel {
SavedDeviceModel(
endpointId: endpointId, localLabel: localLabel, remoteDisplayName: remoteDisplayName,
createdAt: createdAt, lastAuthenticatedAt: nil
)
}
private func transfer(
id: String = "t1",
sender: String = localEndpoint,
receiver: String = peer,
state: TargetedTransferStateModel = .completed,
verifiedBytes: UInt64 = 0,
totalSize: UInt64 = 100,
updatedAt: Int64 = 1
) -> TargetedTransferModel {
TargetedTransferModel(
id: id, senderEndpointId: sender, receiverEndpointId: receiver, manifestId: "m",
transferName: "Photos", fileCount: 2, totalSize: totalSize, verifiedBytes: verifiedBytes,
state: state, createdAt: 1, updatedAt: updatedAt
)
}
private func relationship(
_ endpointId: String = peer,
state: DeviceRelationshipStateModel,
updatedAt: Int64 = 1
) -> DeviceRelationshipModel {
DeviceRelationshipModel(
remoteEndpointId: endpointId, state: state, generation: 1,
minimumProtocolVersion: 1, createdAt: 1, updatedAt: updatedAt
)
}
private func eligibility(
_ endpointId: String = peer,
remoteDisplayName: String? = "Eligible Device",
createdAt: Int64 = 1
) -> PairingEligibilityModel {
PairingEligibilityModel(
peerEndpointId: endpointId, remoteDisplayName: remoteDisplayName, sessionId: "s",
protocolVersion: 1, createdAt: createdAt, expiresAt: createdAt + 86_400
)
}
// MARK: - Snapshot composition
func testLoadsSnapshotAndKeepsTransfersOutOfTheDeviceList() async {
gateway.savedDevices = [savedDevice()]
gateway.deviceRelationships = [relationship(state: .saved)]
gateway.targetedTransfers = [transfer()]
let model = await makeModel()
XCTAssertEqual(model.state.savedDevices.map(\.endpointId), [Self.peer])
// A saved relationship is not "pending" and must not appear as one.
XCTAssertTrue(model.state.pendingRelationships.isEmpty)
// Transfers are reachable only per-device, never as a global list on screen.
XCTAssertEqual(model.state.transfers(for: Self.peer).map(\.id), ["t1"])
}
func testResolvesDirectionAgainstLocalEndpoint() async {
gateway.savedDevices = [savedDevice()]
gateway.targetedTransfers = [
transfer(id: "out", sender: Self.localEndpoint, receiver: Self.peer),
transfer(id: "in", sender: Self.peer, receiver: Self.localEndpoint),
]
let model = await makeModel()
let byId = Dictionary(uniqueKeysWithValues: model.state.targetedTransfers.map { ($0.id, $0) })
XCTAssertEqual(byId["out"]?.direction, .outgoing)
XCTAssertEqual(byId["in"]?.direction, .incoming)
// Either way the peer is the *other* device.
XCTAssertEqual(byId["out"]?.peerEndpointId, Self.peer)
XCTAssertEqual(byId["in"]?.peerEndpointId, Self.peer)
}
func testHidesDeletedTransfers() async {
gateway.targetedTransfers = [transfer(id: "kept"), transfer(id: "gone", state: .deleted)]
let model = await makeModel()
XCTAssertEqual(model.state.targetedTransfers.map(\.id), ["kept"])
}
func testPrefersLocalLabelOverRemoteDisplayName() async {
gateway.savedDevices = [savedDevice(localLabel: "My Laptop", remoteDisplayName: "hostname")]
gateway.targetedTransfers = [transfer()]
let model = await makeModel()
XCTAssertEqual(model.state.targetedTransfers.first?.peerDisplayName, "My Laptop")
}
func testBlankLocalLabelFallsBackToRemoteDisplayName() async {
gateway.savedDevices = [savedDevice(localLabel: " ", remoteDisplayName: "hostname")]
gateway.targetedTransfers = [transfer()]
let model = await makeModel()
XCTAssertEqual(model.state.targetedTransfers.first?.peerDisplayName, "hostname")
}
func testFailedLoadIsReportedRatherThanRenderedPartially() async {
gateway.savedDevicesResult = .failure(TestError.unimplemented)
let model = await makeModel()
XCTAssertTrue(model.state.loadFailed)
XCTAssertTrue(model.state.savedDevices.isEmpty)
}
// MARK: - Pairing prompt
func testIncomingRequestOutranksEligibility() async {
gateway.deviceRelationships = [relationship(state: .pendingIncoming)]
gateway.pairingEligibilities = [eligibility("other-peer")]
let model = await makeModel()
guard case .incomingRequest(let peerId, _) = model.state.pairingPrompt.prompt else {
return XCTFail("expected an incoming request to take priority")
}
XCTAssertEqual(peerId, Self.peer)
}
func testDismissingEligibilityDoesNotConsumeItInTheCore() async {
gateway.pairingEligibilities = [eligibility()]
let model = await makeModel()
XCTAssertNotNil(model.state.pairingPrompt.prompt)
model.dismissPairingPrompt()
XCTAssertNil(model.state.pairingPrompt.prompt)
// Dismissal is local suppression, not a decline: the single-use capability
// must survive so the device stays actionable from the list.
XCTAssertTrue(gateway.declinedEligibilities.isEmpty)
XCTAssertEqual(model.state.eligibilities.count, 1)
}
func testDismissedEligibilityDoesNotReappearOnRefresh() async {
gateway.pairingEligibilities = [eligibility()]
let model = await makeModel()
model.dismissPairingPrompt()
gateway.emit(.pairingChanged)
await waitUntil { !model.state.isLoading }
XCTAssertNil(model.state.pairingPrompt.prompt)
}
func testDecliningEligibilityConsumesItInTheCore() async {
gateway.pairingEligibilities = [eligibility()]
let model = await makeModel()
model.declinePairingPrompt()
await waitUntil { !model.state.pairingPrompt.busy }
XCTAssertEqual(gateway.declinedEligibilities, [Self.peer])
}
// MARK: - Label editing
func testLabelSaveTrimsAndClosesEditorOnSuccess() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.setLabelDraft(" Studio Mac ")
model.saveLabel()
await waitUntil { !model.state.isSavingLabel }
XCTAssertEqual(gateway.setLabels.map(\.label), ["Studio Mac"])
XCTAssertNil(model.state.labelingPeerId)
XCTAssertEqual(model.state.labelDraft, "")
}
func testLabelFailurePreservesDraftAndEditor() async {
gateway.savedDevices = [savedDevice()]
gateway.setLabelResult = .failure(TestError.unimplemented)
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.setLabelDraft("Studio Mac")
model.saveLabel()
await waitUntil { !model.state.isSavingLabel }
// The retry path depends on both surviving.
XCTAssertEqual(model.state.labelingPeerId, Self.peer)
XCTAssertEqual(model.state.labelDraft, "Studio Mac")
}
func testEditorCannotBeDismissedOrEditedWhileSaving() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.setLabelDraft("Studio Mac")
model.saveLabel()
// Still in flight: conflicting actions must be refused, not queued.
model.setLabelDraft("Something Else")
model.dismissLabelEditor()
XCTAssertEqual(model.state.labelDraft, "Studio Mac")
XCTAssertEqual(model.state.labelingPeerId, Self.peer)
await waitUntil { !model.state.isSavingLabel }
XCTAssertEqual(gateway.setLabels.map(\.label), ["Studio Mac"])
}
func testClearingLabelSendsNil() async {
gateway.savedDevices = [savedDevice(localLabel: "Old")]
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.clearLabel()
await waitUntil { !model.state.isSavingLabel }
XCTAssertEqual(gateway.setLabels.count, 1)
XCTAssertNil(gateway.setLabels[0].label)
}
func testBlankDraftClearsTheLabel() async {
gateway.savedDevices = [savedDevice(localLabel: "Old")]
let model = await makeModel()
model.openLabelEditor(Self.peer)
model.setLabelDraft(" ")
model.saveLabel()
await waitUntil { !model.state.isSavingLabel }
XCTAssertNil(gateway.setLabels[0].label)
}
// MARK: - Targeted offers
func testApprovingOfferStartsTheReceivePull() async {
gateway.offerResponseResult = .success(.approved(transferId: "t1"))
let model = await makeModel()
model.acceptTargetedOffer("t1")
await waitUntil { self.gateway.offerResponses.count == 1 && !model.state.isLoading }
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true])
XCTAssertEqual(gateway.targetedReceives.map(\.transferId), ["t1"])
}
func testDecliningOfferDoesNotPull() async {
gateway.offerResponseResult = .success(.declined)
let model = await makeModel()
model.declineTargetedOffer("t1")
await waitUntil { self.gateway.offerResponses.count == 1 }
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [false])
XCTAssertTrue(gateway.targetedReceives.isEmpty)
}
func testAlreadySettledOfferDoesNotStartASecondPull() async {
// The idempotent replay path returns the existing result; re-pulling would
// duplicate work against a transfer that is already resolved.
gateway.offerResponseResult = .success(.alreadySettled(transferId: "t1"))
let model = await makeModel()
model.acceptTargetedOffer("t1")
await waitUntil { self.gateway.offerResponses.count == 1 }
XCTAssertTrue(gateway.targetedReceives.isEmpty)
}
func testConcurrentResponsesToTheSameOfferAreIgnored() async {
gateway.offerResponseResult = .success(.declined)
let model = await makeModel()
model.declineTargetedOffer("t1")
model.declineTargetedOffer("t1")
await waitUntil { !model.state.targetedOffers.respondingIds.contains("t1") }
XCTAssertEqual(gateway.offerResponses.count, 1)
}
// MARK: - Transfer lifecycle
func testResumeUsesTheResumePath() async {
let model = await makeModel()
model.resumeTargetedTransfer("t1")
await waitUntil { !model.state.busyTransferIds.contains("t1") }
XCTAssertEqual(gateway.targetedResumes.map(\.id), ["t1"])
XCTAssertTrue(gateway.targetedReceives.isEmpty)
}
func testCancelAndDeleteReachTheCore() async {
let model = await makeModel()
model.cancelTargetedTransfer("t1")
await waitUntil { !model.state.busyTransferIds.contains("t1") }
model.deleteTargetedTransfer("t2")
await waitUntil { !model.state.busyTransferIds.contains("t2") }
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t1"])
XCTAssertEqual(gateway.deletedTargetedTransfers, ["t2"])
}
func testBusyTransferIgnoresRepeatedCommands() async {
let model = await makeModel()
model.cancelTargetedTransfer("t1")
model.cancelTargetedTransfer("t1")
await waitUntil { !model.state.busyTransferIds.contains("t1") }
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t1"])
}
// MARK: - Destructive actions
func testForgetAndBlockReachTheCoreAndRefresh() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
model.forget(Self.peer)
await waitUntil { !model.state.busyPeerIds.contains(Self.peer) }
model.block(Self.peer)
await waitUntil { !model.state.busyPeerIds.contains(Self.peer) }
XCTAssertEqual(gateway.forgottenDevices, [Self.peer])
XCTAssertEqual(gateway.blockedCalls, [Self.peer])
}
func testBusyPeerIgnoresRepeatedCommands() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
model.forget(Self.peer)
model.forget(Self.peer)
await waitUntil { !model.state.busyPeerIds.contains(Self.peer) }
XCTAssertEqual(gateway.forgottenDevices, [Self.peer])
}
// MARK: - Targeted send
private func picked(_ name: String, isDirectory: Bool = false) -> PickedShareFile {
PickedShareFile(
value: "/tmp/\(name)", displayName: name, sizeBytes: 10,
isTemporaryCopy: true, isDirectory: isDirectory
)
}
func testSendRequiresDestinationSourcesAndName() async {
gateway.savedDevices = [savedDevice()]
let model = await makeModel()
XCTAssertFalse(model.state.canCreateTargetedTransfer)
model.beginSend(to: Self.peer)
XCTAssertFalse(model.state.canCreateTargetedTransfer, "no sources yet")
model.onSendFilesPicked([picked("a.txt")])
XCTAssertTrue(model.state.canCreateTargetedTransfer)
model.setSendTransferName(" ")
XCTAssertFalse(model.state.canCreateTargetedTransfer, "a blank name is not a name")
}
func testSendCreatesTargetedTransferAndClosesComposition() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer())
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.createTargetedTransfer()
await waitUntil { !model.state.isCreatingSend }
XCTAssertEqual(gateway.createdTargetedTransfers.map(\.receiverEndpointId), [Self.peer])
XCTAssertEqual(gateway.createdTargetedTransfers.first?.transferName, "a.txt")
XCTAssertNil(model.state.sendTargetPeerId)
XCTAssertTrue(model.state.sendFiles.isEmpty)
// The core owns the bytes once the transfer exists; the picker copy goes.
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
}
func testSendFailureKeepsCompositionForRetry() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .failure(TestError.unimplemented)
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.setSendTransferName("Report")
model.createTargetedTransfer()
await waitUntil { !model.state.isCreatingSend }
// Retrying must not require re-picking the sources.
XCTAssertEqual(model.state.sendTargetPeerId, Self.peer)
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/a.txt"])
XCTAssertEqual(model.state.sendTransferName, "Report")
XCTAssertTrue(fileSystem.discardedFiles.isEmpty, "sources must survive a failed create")
}
func testRemovingASourceRederivesOnlyAGeneratedName() async {
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt"), picked("b.txt")])
model.setSendTransferName("My Name")
model.removeSendFile("/tmp/b.txt")
XCTAssertEqual(model.state.sendTransferName, "My Name")
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/a.txt"])
}
func testReplacingSelectionDiscardsThePreviousPickerCopies() async {
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.onSendFilesPicked([picked("b.txt")])
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/b.txt"])
}
func testCancellingSendDiscardsSources() async {
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.cancelSend()
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertNil(model.state.sendTargetPeerId)
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
}
func testFolderSourcesAreSupported() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer())
let model = await makeModel()
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("Photos", isDirectory: true)])
model.createTargetedTransfer()
await waitUntil { !model.state.isCreatingSend }
XCTAssertEqual(fileSystem.targetedSends.first?.files.first?.isDirectory, true)
}
// MARK: - Signals
func testPairingSignalTriggersRefresh() async {
let model = await makeModel()
gateway.savedDevices = [savedDevice()]
gateway.emit(.pairingChanged)
await waitUntil { model.state.savedDevices.count == 1 }
XCTAssertEqual(model.state.savedDevices.map(\.endpointId), [Self.peer])
}
func testInvitationSignalsDoNotTriggerRefresh() async {
let model = await makeModel()
gateway.savedDevices = [savedDevice()]
gateway.emit(.transfersChanged(transferId: 1))
gateway.emit(.approvalChanged(transferId: 1))
try? await Task.sleep(nanoseconds: 100_000_000)
// The saved-device domain is unaffected by invitation-share activity.
XCTAssertTrue(model.state.savedDevices.isEmpty)
}
}