Improve code quality: network DI, deduplicate logic, parallelize fetches

- Add NetworkMonitor with NetworkConnectivity protocol for DI and testability
- Deduplicate flag mapping (MenuBarLabel now uses TornDestination.flag(for:))
- Centralize logger subsystem string in TornConstants.logSubsystem
- Fold API error check into parseDataInBackground, removing double JSON parse
- Fix fetchFactionData double JSON parse (single parse with early error check)
- Parallelize user data parse and faction fetch with async let
- Deduplicate refreshNow() by delegating to startPolling()
- Remove redundant showLoginError state in SettingsView (use computed Binding)
- Batch watchlist saves after concurrent price fetches (N saves → 1 save)
- Add change detection for offline errorMsg to avoid no-op @Published updates
- Remove unused ObservableObject conformance from NetworkMonitor
- Fix tokens CodingKey (company_funds → donator)
- Replace print() calls with os.log Logger
- Remove stub UI tests that only asserted window.exists
This commit is contained in:
Paweł Orzech 2026-03-14 22:50:47 +01:00
parent f9758ca74b
commit c11d657fba
No known key found for this signature in database
11 changed files with 145 additions and 194 deletions

View file

@ -33,6 +33,7 @@
AAA00024 /* TransparencyEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10025 /* TransparencyEnvironment.swift */; }; AAA00024 /* TransparencyEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10025 /* TransparencyEnvironment.swift */; };
AAA00025 /* FeedbackPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10026 /* FeedbackPromptView.swift */; }; AAA00025 /* FeedbackPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10026 /* FeedbackPromptView.swift */; };
AAA00026 /* BrowserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10027 /* BrowserManager.swift */; }; AAA00026 /* BrowserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10027 /* BrowserManager.swift */; };
AAA00028 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10028 /* NetworkMonitor.swift */; };
/* Unit Tests */ /* Unit Tests */
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; }; BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; }; BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; };
@ -96,6 +97,7 @@
AAA10025 /* TransparencyEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparencyEnvironment.swift; sourceTree = "<group>"; }; AAA10025 /* TransparencyEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparencyEnvironment.swift; sourceTree = "<group>"; };
AAA10026 /* FeedbackPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPromptView.swift; sourceTree = "<group>"; }; AAA10026 /* FeedbackPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPromptView.swift; sourceTree = "<group>"; };
AAA10027 /* BrowserManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserManager.swift; sourceTree = "<group>"; }; AAA10027 /* BrowserManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserManager.swift; sourceTree = "<group>"; };
AAA10028 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; }; AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* Unit Test Files */ /* Unit Test Files */
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; }; BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
@ -232,6 +234,7 @@
AAA10011 /* LaunchAtLoginManager.swift */, AAA10011 /* LaunchAtLoginManager.swift */,
AAA10012 /* ShortcutsManager.swift */, AAA10012 /* ShortcutsManager.swift */,
AAA10016 /* SoundManager.swift */, AAA10016 /* SoundManager.swift */,
AAA10028 /* NetworkMonitor.swift */,
); );
path = Utilities; path = Utilities;
sourceTree = "<group>"; sourceTree = "<group>";
@ -468,6 +471,7 @@
AAA00024 /* TransparencyEnvironment.swift in Sources */, AAA00024 /* TransparencyEnvironment.swift in Sources */,
AAA00025 /* FeedbackPromptView.swift in Sources */, AAA00025 /* FeedbackPromptView.swift in Sources */,
AAA00026 /* BrowserManager.swift in Sources */, AAA00026 /* BrowserManager.swift in Sources */,
AAA00028 /* NetworkMonitor.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View file

@ -14,7 +14,7 @@ struct MacTornApp: App {
.onAppear { .onAppear {
updateAppearance() updateAppearance()
} }
.onChange(of: appearanceModeRaw) { .onChange(of: appearanceModeRaw) { _ in
updateAppearance() updateAppearance()
} }
} label: { } label: {
@ -79,21 +79,7 @@ struct MenuBarLabel: View {
} }
private func flagForDestination(_ destination: String) -> String { private func flagForDestination(_ destination: String) -> String {
switch destination.lowercased() { TornDestination.flag(for: destination)
case "mexico": return "🇲🇽"
case "cayman islands": return "🇰🇾"
case "canada": return "🇨🇦"
case "hawaii": return "🇺🇸"
case "united kingdom": return "🇬🇧"
case "argentina": return "🇦🇷"
case "switzerland": return "🇨🇭"
case "japan": return "🇯🇵"
case "china": return "🇨🇳"
case "uae": return "🇦🇪"
case "south africa": return "🇿🇦"
case "torn": return "🇺🇸"
default: return "🌍"
}
} }
private func formatShortTime(_ seconds: Int) -> String { private func formatShortTime(_ seconds: Int) -> String {

View file

@ -1,9 +1,13 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import os.log
private let logger = Logger(subsystem: TornConstants.logSubsystem, category: "TornModels")
// MARK: - Constants // MARK: - Constants
enum TornConstants { enum TornConstants {
static let developerID = 2362436 static let developerID = 2362436
static let logSubsystem = "com.mactorn"
} }
// MARK: - Root Response // MARK: - Root Response
@ -204,6 +208,14 @@ enum TornDestination: String, CaseIterable, Identifiable {
var travelAgencyURL: URL { var travelAgencyURL: URL {
URL(string: "https://www.torn.com/travelagency.php")! URL(string: "https://www.torn.com/travelagency.php")!
} }
/// Look up flag emoji for a destination string, including non-enum values like "Torn"
static func flag(for destination: String) -> String {
if let known = TornDestination(rawValue: destination) {
return known.flag
}
return destination.lowercased() == "torn" ? "🇺🇸" : "🌍"
}
} }
// MARK: - Travel Notification Settings // MARK: - Travel Notification Settings
@ -314,7 +326,7 @@ struct MoneyData: Codable {
case cash = "money_onhand" case cash = "money_onhand"
case vault = "vault_amount" case vault = "vault_amount"
case points case points
case tokens = "company_funds" case tokens = "donator"
case cayman = "cayman_bank" case cayman = "cayman_bank"
} }
@ -664,7 +676,7 @@ class UpdateManager {
} }
} catch { } catch {
print("Update check failed: \(error)") logger.warning("Update check failed: \(error.localizedDescription)")
} }
return nil return nil

View file

@ -1,9 +1,13 @@
import Foundation import Foundation
import ServiceManagement import ServiceManagement
import os.log
private let logger = Logger(subsystem: TornConstants.logSubsystem, category: "LaunchAtLoginManager")
@MainActor @MainActor
class LaunchAtLoginManager: ObservableObject { class LaunchAtLoginManager: ObservableObject {
@Published var isEnabled: Bool = false @Published var isEnabled: Bool = false
@Published var errorMessage: String?
private let service = SMAppService.mainApp private let service = SMAppService.mainApp
@ -22,9 +26,11 @@ class LaunchAtLoginManager: ObservableObject {
} else { } else {
try service.register() try service.register()
} }
errorMessage = nil
updateStatus() updateStatus()
} catch { } catch {
print("Launch at Login error: \(error)") logger.error("Launch at Login error: \(error.localizedDescription)")
errorMessage = error.localizedDescription
} }
} }
} }

View file

@ -0,0 +1,31 @@
import Foundation
import Network
/// Protocol for network connectivity checks, enabling dependency injection for testing
@MainActor
protocol NetworkConnectivity: AnyObject {
var isConnected: Bool { get }
}
@MainActor
final class NetworkMonitor: NetworkConnectivity {
static let shared = NetworkMonitor()
private(set) var isConnected: Bool = true
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.mactorn.networkmonitor")
private init() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
self?.isConnected = path.status == .satisfied
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}

View file

@ -1,6 +1,9 @@
import Foundation import Foundation
import UserNotifications import UserNotifications
import AppKit import AppKit
import os.log
private let logger = Logger(subsystem: TornConstants.logSubsystem, category: "NotificationManager")
enum NotificationType: String { enum NotificationType: String {
case drugReady case drugReady
@ -48,10 +51,10 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
let granted = try await UNUserNotificationCenter.current() let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) .requestAuthorization(options: [.alert, .sound, .badge])
if granted { if granted {
print("Notification permission granted") logger.info("Notification permission granted")
} }
} catch { } catch {
print("Notification permission error: \(error)") logger.error("Notification permission error: \(error.localizedDescription)")
} }
} }
@ -70,7 +73,7 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
UNUserNotificationCenter.current().add(request) { error in UNUserNotificationCenter.current().add(request) { error in
if let error = error { if let error = error {
print("Notification error: \(error)") logger.error("Notification error: \(error.localizedDescription)")
} }
} }
} }
@ -94,9 +97,9 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
UNUserNotificationCenter.current().add(request) { error in UNUserNotificationCenter.current().add(request) { error in
if let error = error { if let error = error {
print("Scheduled notification error: \(error)") logger.error("Scheduled notification error: \(error.localizedDescription)")
} else { } else {
print("Scheduled notification '\(identifier)' for \(date)") logger.info("Scheduled notification '\(identifier)' for \(date)")
} }
} }
} }

View file

@ -3,7 +3,7 @@ import Combine
import SwiftUI import SwiftUI
import os.log import os.log
private let logger = Logger(subsystem: "com.mactorn", category: "AppState") private let logger = Logger(subsystem: TornConstants.logSubsystem, category: "AppState")
// MARK: - Appearance // MARK: - Appearance
enum AppearanceMode: String, CaseIterable { enum AppearanceMode: String, CaseIterable {
@ -65,6 +65,7 @@ class AppState: ObservableObject {
// MARK: - Networking (Dependency Injection for Testing) // MARK: - Networking (Dependency Injection for Testing)
private let session: NetworkSession private let session: NetworkSession
private let connectivity: NetworkConnectivity
// MARK: - State Comparison // MARK: - State Comparison
private var previousBars: Bars? private var previousBars: Bars?
@ -73,11 +74,16 @@ class AppState: ObservableObject {
private var previousChain: Chain? private var previousChain: Chain?
private var previousStatus: Status? private var previousStatus: Status?
// MARK: - Task Handles (for deduplication)
private var fetchTask: Task<Void, Never>?
private var watchlistTask: Task<Void, Never>?
// MARK: - Timer // MARK: - Timer
private var timerCancellable: AnyCancellable? private var timerCancellable: AnyCancellable?
init(session: NetworkSession = URLSession.shared) { init(session: NetworkSession = URLSession.shared, connectivity: NetworkConnectivity = NetworkMonitor.shared) {
self.session = session self.session = session
self.connectivity = connectivity
loadNotificationRules() loadNotificationRules()
loadTravelNotificationSettings() loadTravelNotificationSettings()
loadWatchlist() loadWatchlist()
@ -237,18 +243,23 @@ class AppState: ObservableObject {
} }
func refreshWatchlistPrices() { func refreshWatchlistPrices() {
Task { watchlistTask?.cancel()
watchlistTask = Task {
await fetchWatchlistPrices() await fetchWatchlistPrices()
} }
} }
private func fetchWatchlistPrices() async { private func fetchWatchlistPrices() async {
for item in watchlistItems { guard connectivity.isConnected else { return }
await fetchItemPrice(itemId: item.id) await withTaskGroup(of: Void.self) { group in
for item in watchlistItems {
group.addTask { await self.fetchItemPrice(itemId: item.id, save: false) }
}
} }
saveWatchlist()
} }
private func fetchItemPrice(itemId: Int) async { private func fetchItemPrice(itemId: Int, save: Bool = true) async {
guard !apiKey.isEmpty, guard !apiKey.isEmpty,
let url = TornAPI.marketURL(itemId: itemId, apiKey: apiKey) else { return } let url = TornAPI.marketURL(itemId: itemId, apiKey: apiKey) else { return }
@ -261,7 +272,7 @@ class AppState: ObservableObject {
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
logger.error("Item \(itemId) HTTP Error: \(httpResponse.statusCode)") logger.error("Item \(itemId) HTTP Error: \(httpResponse.statusCode)")
await updateItemError(itemId: itemId, error: "HTTP \(httpResponse.statusCode)") await updateItemError(itemId: itemId, error: "HTTP \(httpResponse.statusCode)", save: save)
return return
} }
@ -270,7 +281,7 @@ class AppState: ObservableObject {
// Check if API returned error // Check if API returned error
if let error = json["error"] as? [String: Any], let errorText = error["error"] as? String { if let error = json["error"] as? [String: Any], let errorText = error["error"] as? String {
logger.warning("Item \(itemId) API error: \(errorText)") logger.warning("Item \(itemId) API error: \(errorText)")
await updateItemError(itemId: itemId, error: errorText) await updateItemError(itemId: itemId, error: errorText, save: save)
return return
} }
@ -308,22 +319,23 @@ class AppState: ObservableObject {
if let best = sortedListings.first { if let best = sortedListings.first {
let secondPrice = sortedListings.count > 1 ? sortedListings[1].price : 0 let secondPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
await updateItemPrice(itemId: itemId, lowestPrice: best.price, lowestPriceQuantity: best.amount, secondLowestPrice: secondPrice) await updateItemPrice(itemId: itemId, lowestPrice: best.price, lowestPriceQuantity: best.amount, secondLowestPrice: secondPrice, save: save)
} else { } else {
await updateItemError(itemId: itemId, error: "No listings") await updateItemError(itemId: itemId, error: "No listings", save: save)
} }
} else { } else {
logger.error("Item \(itemId): failed to parse JSON response") logger.error("Item \(itemId): failed to parse JSON response")
await updateItemError(itemId: itemId, error: "Parse Error") await updateItemError(itemId: itemId, error: "Parse Error", save: save)
} }
} catch { } catch {
if Task.isCancelled { return }
logger.error("Item \(itemId) price fetch error: \(error.localizedDescription)") logger.error("Item \(itemId) price fetch error: \(error.localizedDescription)")
await updateItemError(itemId: itemId, error: "Network Error") await updateItemError(itemId: itemId, error: "Network Error", save: save)
} }
} }
@MainActor @MainActor
private func updateItemPrice(itemId: Int, lowestPrice: Int, lowestPriceQuantity: Int, secondLowestPrice: Int) { private func updateItemPrice(itemId: Int, lowestPrice: Int, lowestPriceQuantity: Int, secondLowestPrice: Int, save: Bool = true) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) { if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
var item = watchlistItems[index] var item = watchlistItems[index]
item.lowestPrice = lowestPrice item.lowestPrice = lowestPrice
@ -332,17 +344,17 @@ class AppState: ObservableObject {
item.lastUpdated = Date() item.lastUpdated = Date()
item.error = nil item.error = nil
watchlistItems[index] = item watchlistItems[index] = item
saveWatchlist() if save { saveWatchlist() }
} }
} }
@MainActor @MainActor
private func updateItemError(itemId: Int, error: String) { private func updateItemError(itemId: Int, error: String, save: Bool = true) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) { if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
var item = watchlistItems[index] var item = watchlistItems[index]
item.error = error item.error = error
watchlistItems[index] = item watchlistItems[index] = item
saveWatchlist() if save { saveWatchlist() }
} }
} }
@ -363,11 +375,19 @@ class AppState: ObservableObject {
} }
func refreshNow() { func refreshNow() {
fetchData() startPolling()
} }
// MARK: - Fetch Data // MARK: - Fetch Data
func fetchData() { func fetchData() {
guard connectivity.isConnected else {
if errorMsg != "No internet connection" {
errorMsg = "No internet connection"
logger.warning("Fetch aborted: No internet connection")
}
return
}
guard !apiKey.isEmpty else { guard !apiKey.isEmpty else {
errorMsg = "API Key required" errorMsg = "API Key required"
logger.warning("Fetch aborted: API Key required") logger.warning("Fetch aborted: API Key required")
@ -385,7 +405,8 @@ class AppState: ObservableObject {
logger.info("Starting data fetch from: \(url.absoluteString.prefix(80))...") logger.info("Starting data fetch from: \(url.absoluteString.prefix(80))...")
Task { fetchTask?.cancel()
fetchTask = Task {
let startTime = Date() let startTime = Date()
// Ensure minimum loading time for UX, then set isLoading = false // Ensure minimum loading time for UX, then set isLoading = false
@ -416,23 +437,13 @@ class AppState: ObservableObject {
case 200: case 200:
// Log raw JSON for debugging (first 500 chars) // Log raw JSON for debugging (first 500 chars)
if let jsonString = String(data: data, encoding: .utf8) { if let jsonString = String(data: data, encoding: .utf8) {
logger.info("Raw API response: \(jsonString.prefix(500))") logger.debug("Raw API response: \(jsonString.prefix(500))")
} }
// Check for Torn API error in response (API returns 200 even on errors) // Parse user data and fetch faction data in parallel
if let tornError = checkForTornAPIError(data: data) { async let factionResult: Void = self.fetchFactionData()
await MainActor.run {
self.errorMsg = tornError
}
logger.error("Torn API error: \(tornError)")
return
}
// Parse on background thread
try await parseDataInBackground(data: data) try await parseDataInBackground(data: data)
await factionResult
// Fetch faction data separately
await fetchFactionData()
logger.info("Data fetch completed successfully") logger.info("Data fetch completed successfully")
@ -449,6 +460,7 @@ class AppState: ObservableObject {
logger.error("HTTP Error: \(httpResponse.statusCode)") logger.error("HTTP Error: \(httpResponse.statusCode)")
} }
} catch { } catch {
if Task.isCancelled { return }
await MainActor.run { await MainActor.run {
self.errorMsg = "Network error: \(error.localizedDescription)" self.errorMsg = "Network error: \(error.localizedDescription)"
} }
@ -457,19 +469,6 @@ class AppState: ObservableObject {
} }
} }
/// Check if Torn API returned an error (API returns HTTP 200 even on errors like rate limiting)
private func checkForTornAPIError(data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = json["error"] as? [String: Any],
let errorMessage = error["error"] as? String else {
return nil
}
let errorCode = error["code"] as? Int ?? 0
logger.warning("Torn API error code \(errorCode): \(errorMessage)")
return "API Error: \(errorMessage)"
}
// Move parsing logic here and mark as non-isolated or detached // Move parsing logic here and mark as non-isolated or detached
private func parseDataInBackground(data: Data) async throws { private func parseDataInBackground(data: Data) async throws {
// Run CPU-heavy parsing detached from MainActor // Run CPU-heavy parsing detached from MainActor
@ -478,6 +477,12 @@ class AppState: ObservableObject {
return (nil, nil, nil, nil, nil, "Failed to parse response") return (nil, nil, nil, nil, nil, "Failed to parse response")
} }
// Check for Torn API error (API returns HTTP 200 even on errors like rate limiting)
if let apiError = json["error"] as? [String: Any],
let errorMessage = apiError["error"] as? String {
return (nil, nil, nil, nil, nil, "API Error: \(errorMessage)")
}
// Attempt to decode TornResponse first // Attempt to decode TornResponse first
let decodedTornResponse = try? JSONDecoder().decode(TornResponse.self, from: data) let decodedTornResponse = try? JSONDecoder().decode(TornResponse.self, from: data)
@ -545,7 +550,7 @@ class AppState: ObservableObject {
// Check for parse errors // Check for parse errors
if let parseError = result.5, result.0 == nil { if let parseError = result.5, result.0 == nil {
self.errorMsg = parseError self.errorMsg = parseError
logger.error("Parse error: \(parseError)") logger.error("Data error: \(parseError)")
self.lastUpdated = Date() // Still update timestamp on error self.lastUpdated = Date() // Still update timestamp on error
return return
} }
@ -597,15 +602,14 @@ class AppState: ObservableObject {
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
let (data, _) = try await session.data(for: request) let (data, _) = try await session.data(for: request)
// Check for Torn API error
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = json["error"] as? [String: Any],
let errorMessage = error["error"] as? String {
logger.warning("Faction API error: \(errorMessage)")
return
}
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
// Check for Torn API error first
if let error = json["error"] as? [String: Any],
let errorMessage = error["error"] as? String {
logger.warning("Faction API error: \(errorMessage)")
return
}
let name = json["name"] as? String ?? "" let name = json["name"] as? String ?? ""
let factionId = json["ID"] as? Int ?? 0 let factionId = json["ID"] as? Int ?? 0
let respect = json["respect"] as? Int ?? 0 let respect = json["respect"] as? Int ?? 0

View file

@ -73,7 +73,7 @@ struct SettingsView: View {
Text("2m").tag(120) Text("2m").tag(120)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: appState.refreshInterval) { .onChange(of: appState.refreshInterval) { _ in
Task { @MainActor in Task { @MainActor in
appState.startPolling() appState.startPolling()
} }
@ -232,6 +232,16 @@ struct SettingsView: View {
inputKey = appState.apiKey inputKey = appState.apiKey
refreshAvailableBrowsers() refreshAvailableBrowsers()
} }
.alert("Launch at Login Error", isPresented: Binding(
get: { appState.launchAtLogin.errorMessage != nil },
set: { if !$0 { appState.launchAtLogin.errorMessage = nil } }
)) {
Button("OK") {
appState.launchAtLogin.errorMessage = nil
}
} message: {
Text(appState.launchAtLogin.errorMessage ?? "An unknown error occurred.")
}
} }
private func refreshAvailableBrowsers() { private func refreshAvailableBrowsers() {

View file

@ -201,7 +201,7 @@ enum TornAPIFixtures {
"money_onhand": 1000000, "money_onhand": 1000000,
"vault_amount": 50000000, "vault_amount": 50000000,
"points": 5000, "points": 5000,
"company_funds": 100, "donator": 100,
"cayman_bank": 100000000 "cayman_bank": 100000000
] ]

View file

@ -73,7 +73,7 @@ final class MoneyDataTests: XCTestCase {
"money_onhand": 999999999999, "money_onhand": 999999999999,
"vault_amount": 9999999999999, "vault_amount": 9999999999999,
"points": 100000, "points": 100000,
"company_funds": 50000, "donator": 50000,
"cayman_bank": 99999999999999 "cayman_bank": 99999999999999
] ]
let data = try JSONSerialization.data(withJSONObject: json) let data = try JSONSerialization.data(withJSONObject: json)

View file

@ -17,111 +17,6 @@ final class MacTornUITests: XCTestCase {
app = nil app = nil
} }
// MARK: - Settings View Tests
func testSettingsView_appearsWhenNoAPIKey() throws {
// When no API key is set, settings should appear
// Note: This test assumes a clean state or --uitesting launch argument clears the key
// Look for settings-related UI elements
let settingsElements = app.windows.firstMatch
// The app should show some form of settings or API key input
// Adjust selectors based on actual UI implementation
XCTAssertTrue(settingsElements.exists)
}
// MARK: - Tab Navigation Tests
func testTabNavigation_allTabsExist() throws {
// Skip if settings view is blocking
// This test verifies the main tab structure
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
// Look for tab bar or navigation elements
// Adjust based on actual UI implementation
}
func testTabNavigation_switchBetweenTabs() throws {
let window = app.windows.firstMatch
// Tab navigation test
// Adjust selectors based on actual tab implementation
// e.g., app.buttons["Status"].tap()
XCTAssertTrue(window.exists)
}
// MARK: - Refresh Button Tests
func testRefreshButton_exists() throws {
let window = app.windows.firstMatch
// Look for refresh button
// Adjust selector based on actual implementation
// let refreshButton = window.buttons["Refresh"]
XCTAssertTrue(window.exists)
}
func testRefreshButton_triggersRefresh() throws {
// This test would verify that tapping refresh triggers a data fetch
// Would need to mock network or observe state changes
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
}
// MARK: - Watchlist UI Tests
func testWatchlist_addItem() throws {
// Navigate to watchlist tab
// Add an item
// Verify it appears in the list
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
}
func testWatchlist_removeItem() throws {
// Navigate to watchlist tab
// Remove an item
// Verify it's no longer in the list
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
}
// MARK: - Error State Tests
func testErrorState_displaysErrorMessage() throws {
// Trigger an error state
// Verify error message is displayed
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
}
// MARK: - Loading State Tests
func testLoadingState_showsLoadingIndicator() throws {
// During data fetch, loading indicator should be visible
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
}
// MARK: - Status View Tests
func testStatusView_displaysBars() throws {
// Verify energy, nerve, life, happy bars are displayed
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
}
// MARK: - Performance Tests // MARK: - Performance Tests
func testLaunchPerformance() throws { func testLaunchPerformance() throws {