mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
41
apple/Tests/AppModelTests.swift
Normal file
41
apple/Tests/AppModelTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
51
apple/Tests/AppPreferencesRepositoryTests.swift
Normal file
51
apple/Tests/AppPreferencesRepositoryTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
69
apple/Tests/ApprovalCoordinatorTests.swift
Normal file
69
apple/Tests/ApprovalCoordinatorTests.swift
Normal 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
137
apple/Tests/Fakes.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
43
apple/Tests/FilePreviewRepositoryTests.swift
Normal file
43
apple/Tests/FilePreviewRepositoryTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
43
apple/Tests/InvitationTests.swift
Normal file
43
apple/Tests/InvitationTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
67
apple/Tests/ReceiveModelTests.swift
Normal file
67
apple/Tests/ReceiveModelTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
58
apple/Tests/SendModelTests.swift
Normal file
58
apple/Tests/SendModelTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
45
apple/Tests/SettingsModelTests.swift
Normal file
45
apple/Tests/SettingsModelTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
53
apple/Tests/UiFeedbackTests.swift
Normal file
53
apple/Tests/UiFeedbackTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
38
apple/VniDrop/Core/CoreGateway.swift
Normal file
38
apple/VniDrop/Core/CoreGateway.swift
Normal 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>
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,7 +55,7 @@ struct IosFileSystemService: FileSystemService {
|
||||
}
|
||||
|
||||
func sharePickedFiles(
|
||||
repository: CoreRepository,
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
|
||||
@@ -35,7 +35,7 @@ struct MacFileSystemService: FileSystemService {
|
||||
}
|
||||
|
||||
func sharePickedFiles(
|
||||
repository: CoreRepository,
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user