test(apple): CoreGateway seam, XCTest suite, and CI

- Introduce a CoreGateway protocol so feature models depend on a seam
  (CoreRepository conforms); enables faking the core in tests
- Add a VniDropTests target with 42 tests mirroring the KMP suites:
  approval coordinator, send/receive/settings/app models, preferences,
  file previews, invitation decode, message queue, error mapping
- Add a fake gateway/file-system/device-info and fixtures
- Add .github/workflows/apple.yml: build the Rust core, generate the
  project, and run the tests on an iOS Simulator
This commit is contained in:
2026-07-19 23:48:11 +02:00
parent e35c8c840a
commit 17099d32f2
23 changed files with 771 additions and 20 deletions

79
.github/workflows/apple.yml vendored Normal file
View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

137
apple/Tests/Fakes.swift Normal file
View File

@@ -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, Never>(CoreState())
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
var state: CoreState { stateSubject.value }
var statePublisher: AnyPublisher<CoreState, Never> { stateSubject.eraseToAnyPublisher() }
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
// Stubbed results
var requests: [UInt64: [ReceiverRequestModel]] = [:]
var responseResult: Result<Void, Error> = .success(())
var shareResult: Result<Share, Error> = .failure(TestError.unimplemented)
var inspectionResult: Result<TicketInspectionModel, Error> = .failure(TestError.unimplemented)
var receiveResult: Result<Void, Error> = .success(())
var cancelResult: Result<Void, Error> = .success(())
var deleteResult: Result<Void, Error> = .success(())
var clearReceiveHistoryResult: Result<UInt64, Error> = .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<Void, Error> {
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<Share, Error> {
lastShareAccessPolicy = accessPolicy
return shareResult
}
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { inspectionResult }
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
return receiveResult
}
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> {
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
return receiveResult
}
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
func clearReceiveHistory() async -> Result<UInt64, Error> { 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<Void, Error> {
responses.append((requestId, accepted, reason))
return responseResult
}
func refresh() async -> Result<Void, Error> { .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<Share, Error> {
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)
}
}

View File

@@ -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])
}
}

View File

@@ -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"))
}
}

View File

@@ -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.

View File

@@ -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])
}
}

View File

@@ -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)
}
}

View File

@@ -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])
}
}

View File

@@ -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"))
}
}

View File

@@ -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<CoreState, Never> { get }
/// Coalesced change hints emitted by the event sink.
var signals: AnyPublisher<CoreSignal, Never> { get }
func initialize(appDataDir: String) async -> Result<Void, Error>
func shutdown()
func shareSources(
_ sources: [ShareSource],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error>
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error>
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error>
func receiveIntoSecurityScopedDirectory(
ticket: String,
outputDirectoryUrl: String,
receiverName: String
) async -> Result<Void, Error>
func cancel(transferId: UInt64) async -> Result<Void, Error>
func delete(transferId: UInt64) async -> Result<Void, Error>
func clearReceiveHistory() async -> Result<UInt64, Error>
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
func refresh() async -> Result<Void, Error>
}

View File

@@ -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<CoreState, Never> { $state.eraseToAnyPublisher() }
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.

View File

@@ -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,

View File

@@ -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<AnyCancellable>()
init(
environment: PlatformEnvironment,
repository: CoreRepository,
repository: CoreGateway,
preferences: AppPreferencesRepository,
messages: UiMessageController
) {

View File

@@ -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<AnyCancellable>()
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 }

View File

@@ -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<AnyCancellable>()
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

View File

@@ -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<AnyCancellable>()
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<UInt64> 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<UInt64>? in
core.isInitialized ? Set(core.transfers.map(\.transferId)) : nil
}

View File

@@ -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,

View File

@@ -55,7 +55,7 @@ struct IosFileSystemService: FileSystemService {
}
func sharePickedFiles(
repository: CoreRepository,
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
senderName: String,

View File

@@ -35,7 +35,7 @@ struct MacFileSystemService: FileSystemService {
}
func sharePickedFiles(
repository: CoreRepository,
repository: CoreGateway,
files: [PickedShareFile],
transferName: String,
senderName: String,

View File

@@ -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