Files
vnidrop/apple/Tests/SavedDevicesModelTests.swift
cdricms 8bb1442338 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.
2026-08-13 19:42:37 +02:00

509 lines
16 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: - 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)
}
}