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

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,