Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions BitMatch/App/BitMatchApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ struct BitMatchApp: App {

init() {
SharedLogger.transferEvent(.appStarted, run: nil)
#if DEBUG
let isUnitTestHost = MacUnitTestHost.isRunning()
if isUnitTestHost { MacUnitTestHost.clearState() }
let environment = isUnitTestHost ? MacAppEnvironment.makeForUnitTestHost() : MacAppEnvironment.make()
#else
let environment = MacAppEnvironment.make()
#endif
_environment = StateObject(wrappedValue: environment)
_updater = StateObject(wrappedValue: UpdaterController(coordinator: environment.coordinator))
appDelegate.coordinator = environment.coordinator
Expand Down Expand Up @@ -291,6 +297,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {

func applicationWillTerminate(_ notification: Notification) {
SharedLogger.transferEvent(.appEnded, run: nil)
#if DEBUG
if MacUnitTestHost.isRunning() { MacUnitTestHost.clearState() }
#endif
}

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
Expand Down
50 changes: 50 additions & 0 deletions BitMatch/App/MacAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,41 @@
// copy of its state: SFTP off-site backups (the thesis's named Mac
// exception), volume access and backup-drive discovery, and choosing a
// detected camera card as the source.
import Foundation
import SwiftUI

#if DEBUG
/// App-hosted unit tests launch BitMatch.app before their own fixtures exist.
/// Keep that host away from the user's drives and saved transfer state. This
/// predicate is compiled out of Release builds.
enum MacUnitTestHost {
static func isRunning(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool {
environment["XCTestConfigurationFilePath"] != nil
|| environment["XCTestBundlePath"] != nil
}

// Parallel XCTest app hosts have different process IDs. Reuse these
// locations within one host and clear them at launch and normal exit.
static var defaultsSuiteName: String {
"BitMatch.UnitTestHost.\(ProcessInfo.processInfo.processIdentifier)"
}

private static var scratchDirectory: URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("BitMatchUnitTestHost-\(ProcessInfo.processInfo.processIdentifier)", isDirectory: true)
}

static var journalURL: URL {
scratchDirectory.appendingPathComponent("history.json")
}

static func clearState() {
UserDefaults(suiteName: defaultsSuiteName)?.removePersistentDomain(forName: defaultsSuiteName)
try? FileManager.default.removeItem(at: scratchDirectory)
}
}
#endif

@MainActor
final class MacAppEnvironment: ObservableObject {
let coordinator: SharedAppCoordinator
Expand Down Expand Up @@ -65,6 +98,23 @@ final class MacAppEnvironment: ObservableObject {
monitorsVolumes: false
)
}

#if DEBUG
/// The XCTest app host needs a window, but no live drive discovery,
/// persisted projects, transfer history or remote queue scheduler.
static func makeForUnitTestHost() -> MacAppEnvironment {
let persistence = BitMatchPersistenceController(inMemory: true)
let store = CoreDataPhotographerJobStore(persistence: persistence)
let defaults = UserDefaults(suiteName: MacUnitTestHost.defaultsSuiteName)!
let coordinator = SharedAppCoordinator(
platformManager: MacOSPlatformManager.shared,
transferJournal: LocalTransferJournal(fileURL: MacUnitTestHost.journalURL),
photographerJobViewModel: PhotographerJobViewModel(store: store),
defaults: defaults
)
return makeForTesting(coordinator: coordinator)
}
#endif
}

extension View {
Expand Down
3 changes: 3 additions & 0 deletions BitMatch/Core/Services/MacRemoteBackupController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ final class MacRemoteBackupController: ObservableObject {
let photographerJobViewModel: PhotographerJobViewModel
private let results: () -> [ResultRow]
private var remoteBackupQueue: RemoteBackupQueue?
#if DEBUG
var hasRemoteQueueForTests: Bool { remoteBackupQueue != nil }
#endif
private var remoteBackupTimer: Timer?
private var remoteSchedulerIsRunning = false
private var remoteSchedulerNeedsRun = false
Expand Down
17 changes: 17 additions & 0 deletions BitMatch/Core/Services/VolumeMonitorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,28 @@ final class VolumeMonitorService: ObservableObject {
// A singleton: it lives as long as the app, so it never tears monitoring
// down, and the Disk Arbitration context below never dangles.
private init() {
#if DEBUG
// Test models and MacSetupLocations reference this singleton even
// when their own monitoring is off. The app-hosted unit-test process
// must not enumerate real mounted media before a fixture is set up.
guard !MacUnitTestHost.isRunning() else { return }
#endif
startMonitoring()
// Initial scan of existing volumes
scanExistingVolumes()
}

#if DEBUG
private(set) var startedLiveMonitoring = false
private(set) var scannedExistingVolumes = false
#endif

// MARK: - Volume Monitoring

private func startMonitoring() {
#if DEBUG
startedLiveMonitoring = true
#endif
#if os(macOS)
startDiskArbitrationMonitoring()
startFileSystemMonitoring()
Expand Down Expand Up @@ -286,6 +300,9 @@ final class VolumeMonitorService: ObservableObject {
}

private func scanExistingVolumes() {
#if DEBUG
scannedExistingVolumes = true
#endif
vlog("🔍 Scanning existing volumes...")
guard let volumes = Self.mountedVolumes() else { return }
vlog("📂 Found \(volumes.count) volumes: \(volumes.map { $0.lastPathComponent })")
Expand Down
24 changes: 24 additions & 0 deletions BitMatchTests/MacAppEnvironmentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ import Testing
/// job view model; none keeps its own copy.
@MainActor
struct MacAppEnvironmentTests {
#if DEBUG
@Test func unitTestHostDoesNotStartLiveDriveMonitoring() {
#expect(MacUnitTestHost.isRunning())
#expect(MacUnitTestHost.isRunning(environment: ["XCTestConfigurationFilePath": "/tmp/test.xctest"]))
#expect(MacUnitTestHost.isRunning(environment: ["XCTestBundlePath": "/tmp/BitMatchTests.xctest"]))
#expect(!MacUnitTestHost.isRunning(environment: [:]))

// The real singleton is reached by test models and the Mac setup
// view. Its initializer must not scan /Volumes in this process.
#expect(!VolumeMonitorService.shared.startedLiveMonitoring)
#expect(!VolumeMonitorService.shared.scannedExistingVolumes)
}

@Test func unitTestHostKeepsStateOutOfTheUsersStores() {
let environment = MacAppEnvironment.makeForUnitTestHost()
let coordinator = environment.coordinator
#expect(coordinator.defaults !== UserDefaults.standard)
#expect(coordinator.transferJournal.fileURL == MacUnitTestHost.journalURL)
#expect(!environment.remoteBackups.hasRemoteQueueForTests)
#expect(!VolumeMonitorService.shared.startedLiveMonitoring)
#expect(!VolumeMonitorService.shared.scannedExistingVolumes)
}
#endif

private func makeEnvironment() throws -> (MacAppEnvironment, CoordinatorFolders) {
let folders = try CoordinatorFolders()
let coordinator = SharedAppCoordinator(
Expand Down
Loading