diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml new file mode 100644 index 0000000..55e9d26 --- /dev/null +++ b/.github/workflows/apple.yml @@ -0,0 +1,79 @@ +name: Apple + +on: + pull_request: + paths: + - "apple/**" + - "crates/vnidrop/**" + - "crates/uniffi-bindgen/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/apple.yml" + push: + branches: + - master + paths: + - "apple/**" + - "crates/vnidrop/**" + - "crates/uniffi-bindgen/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/apple.yml" + +permissions: + contents: read + +concurrency: + group: apple-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-test: + runs-on: macos-latest + timeout-minutes: 75 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + # Pin so the simulator device name below stays predictable. + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: apple-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: apple-cargo- + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Build Rust core (xcframework + Swift bindings) + working-directory: apple + run: ./scripts/build-core.sh debug + + - name: Generate Xcode project + working-directory: apple + run: xcodegen generate + + - name: Run unit tests (iOS Simulator) + working-directory: apple + run: | + set -euo pipefail + DEVICE=$(xcrun simctl list devices available \ + | grep -oE 'iPhone [0-9]+( Pro)?' | head -1) + echo "Testing on: ${DEVICE:-iPhone 16}" + xcodebuild test \ + -project VniDrop.xcodeproj \ + -scheme VniDrop \ + -destination "platform=iOS Simulator,name=${DEVICE:-iPhone 16}" \ + CODE_SIGNING_ALLOWED=NO diff --git a/apple/Tests/AppModelTests.swift b/apple/Tests/AppModelTests.swift new file mode 100644 index 0000000..24da979 --- /dev/null +++ b/apple/Tests/AppModelTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import VniDrop + +/// Ports app-level assertions: core initialization on launch, destination +/// selection guard, and theme following preferences. +@MainActor +final class AppModelTests: XCTestCase { + + private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> AppModel { + AppModel( + environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()), + repository: core, + preferences: preferences, + messages: UiMessageController() + ) + } + + func testInitializesCoreOnLaunch() async { + let core = FakeCoreGateway() + _ = makeModel(core, preferences: Fixtures.preferences()) + await waitUntil { core.state.isInitialized } + XCTAssertTrue(core.state.isInitialized) + } + + func testSelectDestination() { + let model = makeModel(FakeCoreGateway(), preferences: Fixtures.preferences()) + XCTAssertEqual(model.destination, .send) + model.selectDestination(.settings) + XCTAssertEqual(model.destination, .settings) + model.selectDestination(.settings) // no-op guard + XCTAssertEqual(model.destination, .settings) + } + + func testThemeModeFollowsPreferences() async { + let prefs = Fixtures.preferences() + let model = makeModel(FakeCoreGateway(), preferences: prefs) + prefs.setThemeMode(.dark) + await waitUntil { model.themeMode == .dark } + XCTAssertEqual(model.themeMode, .dark) + } +} diff --git a/apple/Tests/AppPreferencesRepositoryTests.swift b/apple/Tests/AppPreferencesRepositoryTests.swift new file mode 100644 index 0000000..eb4ef11 --- /dev/null +++ b/apple/Tests/AppPreferencesRepositoryTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import VniDrop + +/// Ports `preferences/AppPreferencesRepositoryTest.kt` — values persist to the +/// backing store and reload identically. +@MainActor +final class AppPreferencesRepositoryTests: XCTestCase { + + private func defaults() -> UserDefaults { UserDefaults(suiteName: "vnidrop.prefs.\(UUID().uuidString)")! } + private func fallback() -> AppPreferencesDefaults { + AppPreferencesDefaults( + username: "Default", + receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"), + themeMode: .system + ) + } + + func testFallbacksWhenEmpty() { + let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback()) + XCTAssertEqual(repo.preferences.username, "Default") + XCTAssertEqual(repo.preferences.themeMode, .system) + XCTAssertFalse(repo.preferences.notificationsEnabled) + } + + func testValuesPersistAndReload() { + let store = defaults() + let fb = fallback() + let repo = AppPreferencesRepository(defaults: store, fallback: fb) + repo.setUsername("Bob") + repo.setThemeMode(.dark) + repo.setNotificationsEnabled(true) + repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) + + // A fresh repository over the same store reflects the persisted values. + let reloaded = AppPreferencesRepository(defaults: store, fallback: fb) + XCTAssertEqual(reloaded.preferences.username, "Bob") + XCTAssertEqual(reloaded.preferences.themeMode, .dark) + XCTAssertTrue(reloaded.preferences.notificationsEnabled) + XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") + XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) + } + + func testResetReceiveFolderRestoresFallback() { + let store = defaults() + let fb = fallback() + let repo = AppPreferencesRepository(defaults: store, fallback: fb) + repo.setReceiveFolder(ReceiveFolder(kind: .fileSystemPath, value: "/custom", displayName: "Custom")) + repo.resetReceiveFolder() + XCTAssertEqual(repo.preferences.receiveFolder.value, "/tmp") + } +} diff --git a/apple/Tests/ApprovalCoordinatorTests.swift b/apple/Tests/ApprovalCoordinatorTests.swift new file mode 100644 index 0000000..97ac8c0 --- /dev/null +++ b/apple/Tests/ApprovalCoordinatorTests.swift @@ -0,0 +1,69 @@ +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, + preferences: Fixtures.preferences(), + 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") + } +} diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift new file mode 100644 index 0000000..33d5ba1 --- /dev/null +++ b/apple/Tests/Fakes.swift @@ -0,0 +1,137 @@ +import Foundation +import Combine +import VnidropCore +@testable import VniDrop + +enum TestError: Error { case unimplemented } + +/// In-memory `CoreGateway`, mirroring `support/Fakes.kt`'s `FakeCoreGateway`. +/// Lets model tests drive core state/signals and stub results without the FFI. +@MainActor +final class FakeCoreGateway: CoreGateway { + private let stateSubject = CurrentValueSubject(CoreState()) + private let signalsSubject = PassthroughSubject() + + var state: CoreState { stateSubject.value } + var statePublisher: AnyPublisher { stateSubject.eraseToAnyPublisher() } + var signals: AnyPublisher { signalsSubject.eraseToAnyPublisher() } + + // Stubbed results + var requests: [UInt64: [ReceiverRequestModel]] = [:] + var responseResult: Result = .success(()) + var shareResult: Result = .failure(TestError.unimplemented) + var inspectionResult: Result = .failure(TestError.unimplemented) + var receiveResult: Result = .success(()) + var cancelResult: Result = .success(()) + var deleteResult: Result = .success(()) + var clearReceiveHistoryResult: Result = .success(0) + + // Recorded calls + private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = [] + private(set) var deletedTransfers: [UInt64] = [] + private(set) var cancelledTransfers: [UInt64] = [] + private(set) var clearReceiveHistoryCount = 0 + private(set) var receiveCount = 0 + private(set) var lastReceiveTicket: String? + private(set) var lastReceiveReceiverName: String? + private(set) var lastShareAccessPolicy: ShareAccessPolicy? + + func setState(_ state: CoreState) { stateSubject.send(state) } + func emit(_ signal: CoreSignal) { signalsSubject.send(signal) } + + func initialize(appDataDir: String) async -> Result { + var s = stateSubject.value + s.isInitialized = true + stateSubject.send(s) + return .success(()) + } + func shutdown() {} + func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result { + lastShareAccessPolicy = accessPolicy + return shareResult + } + func inspectTicket(_ ticket: String) async -> Result { inspectionResult } + func receive(ticket: String, outputDir: String, receiverName: String) async -> Result { + receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName + return receiveResult + } + func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result { + receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName + return receiveResult + } + func cancel(transferId: UInt64) async -> Result { cancelledTransfers.append(transferId); return cancelResult } + func delete(transferId: UInt64) async -> Result { deletedTransfers.append(transferId); return deleteResult } + func clearReceiveHistory() async -> Result { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult } + func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) } + func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result { + responses.append((requestId, accepted, reason)) + return responseResult + } + func refresh() async -> Result { .success(()) } +} + +/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal. +@MainActor +final class FakeFileSystemService: FileSystemService { + var supportsCustomReceiveFolders = false + var folder = ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents") + + func defaultReceiveFolder() -> ReceiveFolder { folder } + func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable } + func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } + func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result { + await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy) + } +} + +@MainActor +final class FakeDeviceInfoProvider: DeviceInfoProvider { + func load() async -> DeviceInfo { + DeviceInfo(deviceName: "Test Device", deviceModel: "TestModel", + operatingSystem: "TestOS 1.0", network: nil, batteryLevel: nil) + } +} + +// MARK: - Factories + +@MainActor +enum Fixtures { + static func preferences(username: String = "Tester") -> AppPreferencesRepository { + let defaults = UserDefaults(suiteName: "vnidrop.tests.\(UUID().uuidString)")! + return AppPreferencesRepository( + defaults: defaults, + fallback: AppPreferencesDefaults( + username: username, + receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents"), + themeMode: .system + ) + ) + } + + static func request(id: String, requestedAt: Int64, transferId: UInt64 = 1, status: ReceiverDeliveryStatus = .requested) -> ReceiverRequestModel { + ReceiverRequestModel( + id: id, transferId: transferId, remoteEndpointId: "endpoint-\(id)", + transferName: "Photos", receiverName: "Peer", receiverDeviceName: "Phone", + appVersion: "1.0", status: status, reason: nil, + requestedAt: requestedAt, respondedAt: nil, completedAt: nil + ) + } + + static func transfer(id: UInt64, direction: TransferDirection, status: TransferStatus) -> Transfer { + Transfer( + localId: "local-\(id)", transferId: id, direction: direction, status: status, + peerId: nil, transferName: "Photos", contentHash: nil, fileCount: 1, totalSize: 1024, + ticket: "ticket", accessPolicy: .requireApproval, createdAt: 0, updatedAt: 0 + ) + } +} + +/// Polls `condition` on the main actor until true or `timeout` elapses. Used to +/// await the models' internal `Task`s, which XCTest can't join directly. +@MainActor +func waitUntil(timeout: TimeInterval = 2, _ condition: @escaping () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + try? await Task.sleep(nanoseconds: 5_000_000) + } +} diff --git a/apple/Tests/FilePreviewRepositoryTests.swift b/apple/Tests/FilePreviewRepositoryTests.swift new file mode 100644 index 0000000..3dc4587 --- /dev/null +++ b/apple/Tests/FilePreviewRepositoryTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import VniDrop + +/// Ports `feature/send/FilePreviewRepositoryTest.kt` — persisted thumbnails, +/// restore pruned to live transfer ids, and removal. +@MainActor +final class FilePreviewRepositoryTests: XCTestCase { + + /// Minimal bytes that pass the PNG magic-byte check. + private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + + private func makeRepo() -> FilePreviewRepository { + FilePreviewRepository(appDataDir: NSTemporaryDirectory() + "previews-" + UUID().uuidString) + } + + func testSaveStoresPreview() { + let repo = makeRepo() + repo.save(transferId: 1, bytes: png) + XCTAssertEqual(repo.previews[1], png) + } + + func testSaveRejectsNonImageBytes() { + let repo = makeRepo() + repo.save(transferId: 1, bytes: Data("not an image".utf8)) + XCTAssertNil(repo.previews[1]) + } + + func testRestorePrunesToActiveIds() { + let repo = makeRepo() + repo.save(transferId: 1, bytes: png) + repo.save(transferId: 2, bytes: png) + repo.restore(activeTransferIds: [1]) + XCTAssertEqual(repo.previews[1], png) + XCTAssertNil(repo.previews[2]) + } + + func testRemoveDeletesPreview() { + let repo = makeRepo() + repo.save(transferId: 1, bytes: png) + repo.remove(transferId: 1) + XCTAssertNil(repo.previews[1]) + } +} diff --git a/apple/Tests/InvitationTests.swift b/apple/Tests/InvitationTests.swift new file mode 100644 index 0000000..1a50832 --- /dev/null +++ b/apple/Tests/InvitationTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import VniDrop + +/// Ports `feature/receive/ExternalInvitationControllerTest.kt` + the `.vnd` +/// decode/filename helpers. +@MainActor +final class InvitationTests: XCTestCase { + + func testValidateInvitationAcceptsValid() { + guard case .success(let raw) = validateInvitation("some-ticket") else { return XCTFail("expected success") } + XCTAssertEqual(raw, "some-ticket") + } + + func testValidateRejectsEmpty() { + guard case .failure(let error) = validateInvitation(" \n ") else { return XCTFail("expected failure") } + XCTAssertTrue((error as? InvitationError) != nil) + } + + func testValidateRejectsTooLarge() { + let big = String(repeating: "a", count: maxVniDropInvitationBytes + 1) + guard case .failure = validateInvitation(big) else { return XCTFail("expected failure") } + } + + func testDecodeInvitationBytesRoundTrip() throws { + let text = "vnidrop://ticket-abc" + let decoded = try decodeInvitationBytes(Data(text.utf8)) + XCTAssertEqual(decoded, text) + } + + func testDecodeRejectsEmptyData() { + XCTAssertThrowsError(try decodeInvitationBytes(Data())) + } + + func testDecodeRejectsInvalidUtf8() { + XCTAssertThrowsError(try decodeInvitationBytes(Data([0xFF, 0xFE, 0xFD]))) + } + + func testInvitationFileNameSanitizes() { + XCTAssertEqual(invitationFileName("My Photos"), "My-Photos.vnd") + XCTAssertEqual(invitationFileName(" "), "invitation.vnd") + XCTAssertTrue(invitationFileName("a/b:c*d").hasSuffix(".vnd")) + } +} diff --git a/apple/Tests/ProgressDerivationTests.swift b/apple/Tests/ProgressDerivationTests.swift index d4462ab..431eb1d 100644 --- a/apple/Tests/ProgressDerivationTests.swift +++ b/apple/Tests/ProgressDerivationTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import VniDropApp +@testable import VniDrop /// Ports selected `shared/src/commonTest/.../ui/state` assertions to verify the /// progress-derivation logic matches the Kotlin implementation. diff --git a/apple/Tests/ReceiveModelTests.swift b/apple/Tests/ReceiveModelTests.swift new file mode 100644 index 0000000..0a83491 --- /dev/null +++ b/apple/Tests/ReceiveModelTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import VniDrop + +/// Ports the receive-side state-machine assertions from `feature/ViewModelsTest.kt`. +@MainActor +final class ReceiveModelTests: XCTestCase { + + private func makeModel(_ core: FakeCoreGateway) -> ReceiveModel { + ReceiveModel( + repository: core, + fileSystemService: FakeFileSystemService(), + preferences: Fixtures.preferences(), + messages: UiMessageController() + ) + } + + func testDeleteHistoryItemDeletesTerminalReceiveTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)])) + await waitUntil { model.coreState.transfers.contains { $0.transferId == 5 } } + + model.requestDeleteHistoryItem(5) + XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5)) + + model.confirmHistoryDelete() + await waitUntil { core.deletedTransfers.contains(5) } + XCTAssertEqual(core.deletedTransfers, [5]) + XCTAssertNil(model.state.historyDeleteTarget) + } + + func testClearHistoryCallsClearReceiveHistory() async { + let core = FakeCoreGateway() + let model = makeModel(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)])) + await waitUntil { !model.coreState.transfers.isEmpty } + + model.requestClearHistory() + XCTAssertEqual(model.state.historyDeleteTarget, .all) + + model.confirmHistoryDelete() + await waitUntil { core.clearReceiveHistoryCount == 1 } + XCTAssertEqual(core.clearReceiveHistoryCount, 1) + XCTAssertNil(model.state.historyDeleteTarget) + } + + func testDeleteHistoryItemIgnoresNonTerminalTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 9, direction: .receive, status: .receiving)])) + await waitUntil { !model.coreState.transfers.isEmpty } + + model.requestDeleteHistoryItem(9) + XCTAssertNil(model.state.historyDeleteTarget) // in-flight receive can't be deleted from history + } + + func testCancelActiveReceiveCancelsTheReceivingTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 7, direction: .receive, status: .receiving)])) + await waitUntil { !model.coreState.transfers.isEmpty } + + model.cancelActiveReceive() + await waitUntil { core.cancelledTransfers.contains(7) } + XCTAssertEqual(core.cancelledTransfers, [7]) + } +} diff --git a/apple/Tests/SendModelTests.swift b/apple/Tests/SendModelTests.swift new file mode 100644 index 0000000..616ae67 --- /dev/null +++ b/apple/Tests/SendModelTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import VniDrop + +/// Ports the send-side state-machine assertions from `feature/ViewModelsTest.kt`. +@MainActor +final class SendModelTests: XCTestCase { + + private func makeModel(_ core: FakeCoreGateway) -> SendModel { + SendModel( + repository: core, + fileSystemService: FakeFileSystemService(), + preferences: Fixtures.preferences(), + filePreviewRepository: FilePreviewRepository(appDataDir: NSTemporaryDirectory() + UUID().uuidString), + messages: UiMessageController() + ) + } + + func testOpenAndCloseTransferDetails() { + let model = makeModel(FakeCoreGateway()) + model.openTransfer(3) + XCTAssertEqual(model.state.selectedTransferId, 3) + model.closeTransferDetails() + XCTAssertNil(model.state.selectedTransferId) + } + + func testDeleteTransferConfirmationFlow() async { + let core = FakeCoreGateway() + let model = makeModel(core) + model.openTransfer(3) + + model.requestDeleteTransfer() + XCTAssertTrue(model.state.isDeleteConfirmationOpen) + + model.confirmDeleteTransfer() + await waitUntil { core.deletedTransfers.contains(3) } + XCTAssertEqual(core.deletedTransfers, [3]) + XCTAssertNil(model.state.selectedTransferId) + XCTAssertFalse(model.state.isDeleteConfirmationOpen) + } + + func testStopSharingCancelsTheTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core) + model.stopSharing(transferId: 4) + await waitUntil { core.cancelledTransfers.contains(4) } + XCTAssertEqual(core.cancelledTransfers, [4]) + } + + func testCancelReceiverRefusesTheRequest() async { + let core = FakeCoreGateway() + let model = makeModel(core) + model.openTransfer(1) + model.cancelReceiver(requestId: "req-1") + await waitUntil { core.responses.contains { $0.id == "req-1" } } + let response = core.responses.first { $0.id == "req-1" } + XCTAssertEqual(response?.accepted, false) + } +} diff --git a/apple/Tests/SettingsModelTests.swift b/apple/Tests/SettingsModelTests.swift new file mode 100644 index 0000000..a6623f1 --- /dev/null +++ b/apple/Tests/SettingsModelTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import VniDrop + +/// Ports settings assertions from `feature/ViewModelsTest.kt` — username debounce +/// persistence and the Storage "delete all transfers" flow. +@MainActor +final class SettingsModelTests: XCTestCase { + + private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> SettingsModel { + SettingsModel( + environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()), + deviceInfoProvider: FakeDeviceInfoProvider(), + fileSystemService: FakeFileSystemService(), + repository: core, + preferences: preferences, + notifications: LocalNotificationService(), + messages: UiMessageController(), + bugReports: NoopBugReportService(), + diagnosticsIncluded: false + ) + } + + func testUsernameChangeDebouncesAndPersists() async { + let prefs = Fixtures.preferences(username: "Original") + let model = makeModel(FakeCoreGateway(), preferences: prefs) + + model.setUsername("Alice") + XCTAssertEqual(model.state.username, "Alice") // immediate local echo + await waitUntil { prefs.preferences.username == "Alice" } // persisted after debounce + XCTAssertEqual(prefs.preferences.username, "Alice") + } + + func testDeleteAllTransfersDeletesEveryTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core, preferences: Fixtures.preferences()) + core.setState(CoreState(isInitialized: true, transfers: [ + Fixtures.transfer(id: 2, direction: .send, status: .sharing), + Fixtures.transfer(id: 3, direction: .receive, status: .done), + ])) + + model.deleteAllTransfers() + await waitUntil { core.deletedTransfers.count == 2 } + XCTAssertEqual(Set(core.deletedTransfers), [2, 3]) + } +} diff --git a/apple/Tests/UiFeedbackTests.swift b/apple/Tests/UiFeedbackTests.swift new file mode 100644 index 0000000..c22bc32 --- /dev/null +++ b/apple/Tests/UiFeedbackTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import VniDrop + +/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`. +@MainActor +final class UiMessageControllerTests: XCTestCase { + + func testQueuesAndAdvances() { + let c = UiMessageController() + c.show(UiMessage(text: .dynamic("first"))) + c.show(UiMessage(text: .dynamic("second"))) + XCTAssertEqual(c.current?.text, .dynamic("first")) + + c.advance() + XCTAssertEqual(c.current?.text, .dynamic("second")) + + c.advance() + XCTAssertNil(c.current) + } + + func testErrorSuppressesUserCancellation() { + let c = UiMessageController() + c.error(InvitationError.message("QR scanning was cancelled")) + XCTAssertNil(c.current) // cancellations are swallowed + } + + func testErrorShowsNonCancellation() { + let c = UiMessageController() + c.error(InvitationError.message("The transfer was refused")) + XCTAssertEqual(c.current?.tone, .error) + } +} + +@MainActor +final class UserFacingErrorTests: XCTestCase { + + func testIsUserCancellation() { + XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation) + XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation) + XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation) + } + + func testToUiTextMapsKnownReasons() { + XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource("error_permission")) + XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket")) + XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty")) + XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("error_camera")) + } + + func testToUiTextFallsBackToGeneric() { + XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic")) + } +} diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift new file mode 100644 index 0000000..73d921c --- /dev/null +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -0,0 +1,38 @@ +import Foundation +import Combine +import VnidropCore + +/// Seam between the feature models and the Rust core, mirroring `CoreGateway` +/// in the KMP `shared` module. `CoreRepository` is the production implementation; +/// tests substitute a fake so the models can be exercised without the FFI. +@MainActor +protocol CoreGateway: AnyObject { + /// Latest published core state. + var state: CoreState { get } + /// Publisher of core-state changes (the models subscribe to this). + var statePublisher: AnyPublisher { get } + /// Coalesced change hints emitted by the event sink. + var signals: AnyPublisher { get } + + func initialize(appDataDir: String) async -> Result + func shutdown() + func shareSources( + _ sources: [ShareSource], + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy + ) async -> Result + func inspectTicket(_ ticket: String) async -> Result + func receive(ticket: String, outputDir: String, receiverName: String) async -> Result + func receiveIntoSecurityScopedDirectory( + ticket: String, + outputDirectoryUrl: String, + receiverName: String + ) async -> Result + func cancel(transferId: UInt64) async -> Result + func delete(transferId: UInt64) async -> Result + func clearReceiveHistory() async -> Result + func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> + func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result + func refresh() async -> Result +} diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 3c3dc69..c4895f6 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -9,8 +9,9 @@ import Combine /// UniFFI calls block (the core drives its own runtime via `block_on`), so they /// run on a background queue and results are hopped back to the main actor. @MainActor -final class CoreRepository: ObservableObject { +final class CoreRepository: ObservableObject, CoreGateway { @Published private(set) var state = CoreState() + var statePublisher: AnyPublisher { $state.eraseToAnyPublisher() } private let signalsSubject = PassthroughSubject() /// Coalesced change hints; subscribe to react to approval/history/transfer changes. diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index 6d3d9d0..f50916e 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -30,7 +30,7 @@ protocol FileSystemService { /// Releases only app-owned picker copies; never deletes original user sources. func discardPickedFiles(_ files: [PickedShareFile]) async func sharePickedFiles( - repository: CoreRepository, + repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, diff --git a/apple/VniDrop/Features/App/AppModel.swift b/apple/VniDrop/Features/App/AppModel.swift index 699114c..f9df8b5 100644 --- a/apple/VniDrop/Features/App/AppModel.swift +++ b/apple/VniDrop/Features/App/AppModel.swift @@ -9,13 +9,13 @@ final class AppModel: ObservableObject { @Published private(set) var themeMode: ThemeMode = .system private let environment: PlatformEnvironment - private let repository: CoreRepository + private let repository: CoreGateway private let messages: UiMessageController private var cancellables = Set() init( environment: PlatformEnvironment, - repository: CoreRepository, + repository: CoreGateway, preferences: AppPreferencesRepository, messages: UiMessageController ) { diff --git a/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift b/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift index 0f20a03..0417987 100644 --- a/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift +++ b/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift @@ -26,7 +26,7 @@ struct ApprovalState: Equatable { final class ApprovalCoordinator: ObservableObject { @Published private(set) var state = ApprovalState() - private let repository: CoreRepository + private let repository: CoreGateway private let preferences: AppPreferencesRepository private let notifications: LocalNotificationService private let visibility: AppVisibility @@ -36,7 +36,7 @@ final class ApprovalCoordinator: ObservableObject { private var cancellables = Set() init( - repository: CoreRepository, + repository: CoreGateway, preferences: AppPreferencesRepository, notifications: LocalNotificationService, visibility: AppVisibility, @@ -57,7 +57,7 @@ final class ApprovalCoordinator: ObservableObject { } .store(in: &cancellables) - repository.$state + repository.statePublisher .sink { [weak self] core in guard let self, core.isInitialized else { return } let sharing = core.transfers.filter { $0.direction == .send && $0.status == .sharing } diff --git a/apple/VniDrop/Features/Receive/ReceiveModel.swift b/apple/VniDrop/Features/Receive/ReceiveModel.swift index ed3b871..8957007 100644 --- a/apple/VniDrop/Features/Receive/ReceiveModel.swift +++ b/apple/VniDrop/Features/Receive/ReceiveModel.swift @@ -51,13 +51,13 @@ final class ReceiveModel: ObservableObject { @Published private(set) var state = ReceiveState() @Published private(set) var coreState = CoreState() - private let repository: CoreRepository + private let repository: CoreGateway private let fileSystemService: FileSystemService private let messages: UiMessageController private var cancellables = Set() init( - repository: CoreRepository, + repository: CoreGateway, fileSystemService: FileSystemService, preferences: AppPreferencesRepository, messages: UiMessageController @@ -66,7 +66,7 @@ final class ReceiveModel: ObservableObject { self.fileSystemService = fileSystemService self.messages = messages - repository.$state.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables) + repository.statePublisher.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables) preferences.$preferences .sink { [weak self] prefs in diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index ca96b5d..53aa931 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -50,14 +50,14 @@ final class SendModel: ObservableObject { /// small transfer complete. @Published private(set) var receiversByTransfer: [UInt64: [ReceiverRequestModel]] = [:] - private let repository: CoreRepository + private let repository: CoreGateway private let fileSystemService: FileSystemService private let filePreviewRepository: FilePreviewRepository private let messages: UiMessageController private var cancellables = Set() init( - repository: CoreRepository, + repository: CoreGateway, fileSystemService: FileSystemService, preferences: AppPreferencesRepository, filePreviewRepository: FilePreviewRepository, @@ -68,7 +68,7 @@ final class SendModel: ObservableObject { self.filePreviewRepository = filePreviewRepository self.messages = messages - repository.$state.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables) + repository.statePublisher.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables) repository.signals .sink { [weak self] signal in @@ -86,7 +86,7 @@ final class SendModel: ObservableObject { // Keep receiver delivery records current for every sharing/importing // outgoing transfer (new shares appear here; status transitions arrive via // the receiverHistoryChanged signal above). - repository.$state + repository.statePublisher .map { core -> Set in Set(core.transfers .filter { $0.direction == .send && ($0.status == .sharing || $0.status == .importing) } @@ -100,7 +100,7 @@ final class SendModel: ObservableObject { .sink { [weak self] previews in self?.state.transferThumbnails = previews } .store(in: &cancellables) - repository.$state + repository.statePublisher .map { core -> Set? in core.isInitialized ? Set(core.transfers.map(\.transferId)) : nil } diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index b376f82..f237e4f 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -85,7 +85,7 @@ final class SettingsModel: ObservableObject { private let environment: PlatformEnvironment private let deviceInfoProvider: DeviceInfoProvider private let fileSystemService: FileSystemService - private let repository: CoreRepository + private let repository: CoreGateway private let preferences: AppPreferencesRepository private let notifications: LocalNotificationService private let messages: UiMessageController @@ -101,7 +101,7 @@ final class SettingsModel: ObservableObject { environment: PlatformEnvironment, deviceInfoProvider: DeviceInfoProvider, fileSystemService: FileSystemService, - repository: CoreRepository, + repository: CoreGateway, preferences: AppPreferencesRepository, notifications: LocalNotificationService, messages: UiMessageController, diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index 94b0ef3..e9f4a37 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -55,7 +55,7 @@ struct IosFileSystemService: FileSystemService { } func sharePickedFiles( - repository: CoreRepository, + repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index ec8be0f..55628ec 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -35,7 +35,7 @@ struct MacFileSystemService: FileSystemService { } func sharePickedFiles( - repository: CoreRepository, + repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, diff --git a/apple/project.yml b/apple/project.yml index e4042d4..3f643f6 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -52,3 +52,29 @@ targets: - sdk: SystemConfiguration.framework - sdk: Security.framework - sdk: libresolv.tbd + + VniDropTests: + type: bundle.unit-test + supportedDestinations: [iOS, macOS] + sources: + - path: Tests + settings: + base: + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + CODE_SIGN_STYLE: Automatic + dependencies: + - target: VniDrop + +schemes: + VniDrop: + build: + targets: + VniDrop: all + run: + config: Debug + test: + config: Debug + targets: + - VniDropTests