mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-03-31 20:25:43 +00:00
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:
parent
f9758ca74b
commit
c11d657fba
11 changed files with 145 additions and 194 deletions
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
MacTorn/MacTorn/Utilities/NetworkMonitor.swift
Normal file
31
MacTorn/MacTorn/Utilities/NetworkMonitor.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue