mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
Creating a targeted transfer contacts the peer and only returns once the offer is answered or its timeouts expire — connection_timeout plus offer_wait_timeout, so minutes against a device that never answers. The composer disabled every control for that whole window, including Close, leaving no way out. Worse, the core records the row before it reaches out and leaves it in `failed` when the peer never replies, so giving up still produced a failure notification and a history entry for a send the user had already called off. Cancelling now reaches the core while that create is still running. The `created` lifecycle event carries the transfer id and is emitted before the peer is contacted, so the id is known in time; the cancel goes out through the interrupt lane, which exists precisely to reach a core busy inside another call. The transfer is then deleted, and its id is filtered out of the published list so a refresh racing the delete cannot leak it into history or into a notification. If the id has not arrived yet, the result carries it and the same cleanup runs on return. Picked sources are released only once the call lands, because the import owns them until then, and a generation counter keeps a late result from disturbing a newer send. Close and Cancel were also the same action under two labels. There is now one control: the sheet's cancellation item, reading Cancel while a send is waiting and Close otherwise, never disabled. Two further fixes from device testing: - Receive and Resume were gated on transfer state alone, so an approved outgoing transfer offered the sender a Receive button — an invitation to download the files it was uploading. Both pull into a local folder and are now receiver-only. - Renamed the deprecated `laptopcomputerAndIphone` symbol to `macbookAndIphone`; the deployment targets are well past where it was introduced, so no availability guard is needed. Adds a typed `targetedTransferId` accessor on CoreEventModel. This is a narrow, deliberate exception to the wake-up-only event rule in DESIGN-DEVICE-HISTORY.md §13: it takes the subject id and never state, and it exists because no query can answer while the create holds the serial lane — which is exactly when the user wants to cancel. Known gap: direction is inferred by comparing endpoint ids, because the binding does not expose the row's role. After an identity reset, rows predating it match neither endpoint, so past sends read as incoming from the device's own retired identity. Fixing that needs `role` on the core's TargetedTransfer.
670 lines
23 KiB
Swift
670 lines
23 KiB
Swift
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: - Transfer actions
|
|
|
|
func testOnlyTheReceivingSideIsOfferedReceiveAndResume() async {
|
|
gateway.savedDevices = [savedDevice()]
|
|
gateway.targetedTransfers = [
|
|
transfer(id: "in-approved", sender: Self.peer, receiver: Self.localEndpoint, state: .approved),
|
|
transfer(id: "out-approved", sender: Self.localEndpoint, receiver: Self.peer, state: .approved),
|
|
transfer(id: "in-interrupted", sender: Self.peer, receiver: Self.localEndpoint, state: .interrupted),
|
|
transfer(id: "out-interrupted", sender: Self.localEndpoint, receiver: Self.peer, state: .interrupted),
|
|
]
|
|
let model = await makeModel()
|
|
let byId = Dictionary(uniqueKeysWithValues: model.state.targetedTransfers.map { ($0.id, $0) })
|
|
|
|
XCTAssertEqual(byId["in-approved"]?.canReceive, true)
|
|
XCTAssertEqual(byId["in-interrupted"]?.canResume, true)
|
|
// The sender has nothing to pull: it is the one holding the files. Offering
|
|
// "Receive" there asked it to download its own outgoing transfer.
|
|
XCTAssertEqual(byId["out-approved"]?.canReceive, false)
|
|
XCTAssertEqual(byId["out-interrupted"]?.canResume, false)
|
|
}
|
|
|
|
// 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"])
|
|
}
|
|
|
|
/// Sets up a create that never returns until released — an unavailable peer,
|
|
/// where the core waits out its connection and offer timeouts. `announcesId`
|
|
/// mirrors the core emitting `created` once the row exists, which it does
|
|
/// before it ever contacts the peer.
|
|
private func stalledSend(
|
|
_ model: SavedDevicesModel,
|
|
id: String = "t-inflight",
|
|
announcesId: Bool = true
|
|
) async {
|
|
gateway.holdsTargetedCreate = true
|
|
model.beginSend(to: Self.peer)
|
|
model.onSendFilesPicked([picked("a.txt")])
|
|
model.createTargetedTransfer()
|
|
await waitUntil { self.gateway.isHoldingTargetedCreate }
|
|
if announcesId {
|
|
gateway.emitTargetedTransferCreated(id: id)
|
|
await waitUntil { !model.state.isCreatingSend || model.knowsInFlightSendTransfer }
|
|
}
|
|
XCTAssertTrue(model.state.isCreatingSend)
|
|
}
|
|
|
|
func testCancellingAnUnansweredSendReachesTheCoreWhileItIsStillRunning() async {
|
|
gateway.savedDevices = [savedDevice()]
|
|
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
|
|
let model = await makeModel()
|
|
await stalledSend(model)
|
|
|
|
model.abandonSend()
|
|
|
|
// The point of cancelling: it must not wait for the create to finish. The
|
|
// cancel goes out while the core is still parked inside that very call.
|
|
await waitUntil { !self.gateway.cancelledTargetedTransfers.isEmpty }
|
|
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t-inflight"])
|
|
XCTAssertFalse(model.state.isCreatingSend)
|
|
XCTAssertNil(model.state.sendTargetPeerId)
|
|
|
|
gateway.releaseTargetedCreate()
|
|
}
|
|
|
|
func testCancelledSendIsDeletedRatherThanLeftInHistory() async {
|
|
gateway.savedDevices = [savedDevice()]
|
|
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
|
|
let model = await makeModel()
|
|
await stalledSend(model)
|
|
|
|
model.abandonSend()
|
|
await waitUntil { !self.gateway.deletedTargetedTransfers.isEmpty }
|
|
|
|
// Cancelling is not "closing the sheet": the core would otherwise record a
|
|
// failed transfer, and the user would be told a send they called off failed.
|
|
XCTAssertEqual(gateway.deletedTargetedTransfers, ["t-inflight"])
|
|
gateway.releaseTargetedCreate()
|
|
}
|
|
|
|
func testCancelledSendNeverReachesHistoryEvenIfARefreshRacesTheDelete() async {
|
|
gateway.savedDevices = [savedDevice()]
|
|
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
|
|
// The core reports it as failed — the state an unanswered offer lands in.
|
|
gateway.targetedTransfers = [transfer(id: "t-inflight", state: .failed)]
|
|
let model = await makeModel()
|
|
await stalledSend(model)
|
|
|
|
model.abandonSend()
|
|
gateway.releaseTargetedCreate()
|
|
await waitUntil { !self.gateway.deletedTargetedTransfers.isEmpty }
|
|
|
|
// Nothing about the cancelled send may surface, or the notification
|
|
// coordinator announces a failure for work the user called off.
|
|
XCTAssertTrue(model.state.targetedTransfers.isEmpty)
|
|
}
|
|
|
|
func testCancellingReleasesTheComposerBeforeTheCreateReturns() async {
|
|
gateway.savedDevices = [savedDevice()]
|
|
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
|
|
let model = await makeModel()
|
|
await stalledSend(model)
|
|
|
|
model.abandonSend()
|
|
|
|
XCTAssertFalse(model.state.isCreatingSend)
|
|
XCTAssertTrue(model.state.sendFiles.isEmpty)
|
|
// The import still owns the sources until the call lands.
|
|
XCTAssertTrue(fileSystem.discardedFiles.isEmpty)
|
|
|
|
gateway.releaseTargetedCreate()
|
|
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
|
|
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
|
|
}
|
|
|
|
func testCancelledSendWithNoIdYetIsStillCleanedUpWhenTheCreateReturns() async {
|
|
gateway.savedDevices = [savedDevice()]
|
|
gateway.createTargetedTransferResult = .success(transfer(id: "t-late"))
|
|
let model = await makeModel()
|
|
// The create beat its own `created` event, so cancelling has no id to use.
|
|
await stalledSend(model, announcesId: false)
|
|
model.abandonSend()
|
|
XCTAssertTrue(gateway.cancelledTargetedTransfers.isEmpty)
|
|
|
|
gateway.releaseTargetedCreate()
|
|
|
|
// The result carries the id, so the cleanup still happens — just later.
|
|
await waitUntil { !self.gateway.cancelledTargetedTransfers.isEmpty }
|
|
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t-late"])
|
|
XCTAssertEqual(gateway.deletedTargetedTransfers, ["t-late"])
|
|
}
|
|
|
|
func testCancelledSendThatNeverRegisteredHasNothingToCancel() async {
|
|
gateway.savedDevices = [savedDevice()]
|
|
gateway.createTargetedTransferResult = .failure(TestError.unimplemented)
|
|
let model = await makeModel()
|
|
await stalledSend(model, announcesId: false)
|
|
model.abandonSend()
|
|
|
|
gateway.releaseTargetedCreate()
|
|
|
|
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
|
|
XCTAssertTrue(gateway.cancelledTargetedTransfers.isEmpty)
|
|
// The composition is gone, so a failure the user walked away from is not
|
|
// resurrected as an error they have to dismiss.
|
|
XCTAssertTrue(model.state.sendFiles.isEmpty)
|
|
}
|
|
|
|
func testCancelledResultDoesNotDisturbANewerSend() async {
|
|
gateway.savedDevices = [savedDevice()]
|
|
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
|
|
let model = await makeModel()
|
|
await stalledSend(model)
|
|
model.abandonSend()
|
|
|
|
// The user starts composing again while the first call is still pending.
|
|
model.beginSend(to: Self.peer)
|
|
model.onSendFilesPicked([picked("b.txt")])
|
|
gateway.releaseTargetedCreate()
|
|
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
|
|
|
|
XCTAssertEqual(model.state.sendTargetPeerId, Self.peer)
|
|
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/b.txt"])
|
|
XCTAssertFalse(model.state.isCreatingSend)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|