mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-03-31 12:15:48 +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 */; };
|
||||
AAA00025 /* FeedbackPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10026 /* FeedbackPromptView.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 */
|
||||
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
/* Unit Test Files */
|
||||
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -232,6 +234,7 @@
|
|||
AAA10011 /* LaunchAtLoginManager.swift */,
|
||||
AAA10012 /* ShortcutsManager.swift */,
|
||||
AAA10016 /* SoundManager.swift */,
|
||||
AAA10028 /* NetworkMonitor.swift */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -468,6 +471,7 @@
|
|||
AAA00024 /* TransparencyEnvironment.swift in Sources */,
|
||||
AAA00025 /* FeedbackPromptView.swift in Sources */,
|
||||
AAA00026 /* BrowserManager.swift in Sources */,
|
||||
AAA00028 /* NetworkMonitor.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ struct MacTornApp: App {
|
|||
.onAppear {
|
||||
updateAppearance()
|
||||
}
|
||||
.onChange(of: appearanceModeRaw) {
|
||||
.onChange(of: appearanceModeRaw) { _ in
|
||||
updateAppearance()
|
||||
}
|
||||
} label: {
|
||||
|
|
@ -79,21 +79,7 @@ struct MenuBarLabel: View {
|
|||
}
|
||||
|
||||
private func flagForDestination(_ destination: String) -> String {
|
||||
switch destination.lowercased() {
|
||||
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 "🌍"
|
||||
}
|
||||
TornDestination.flag(for: destination)
|
||||
}
|
||||
|
||||
private func formatShortTime(_ seconds: Int) -> String {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import os.log
|
||||
|
||||
private let logger = Logger(subsystem: TornConstants.logSubsystem, category: "TornModels")
|
||||
|
||||
// MARK: - Constants
|
||||
enum TornConstants {
|
||||
static let developerID = 2362436
|
||||
static let logSubsystem = "com.mactorn"
|
||||
}
|
||||
|
||||
// MARK: - Root Response
|
||||
|
|
@ -204,6 +208,14 @@ enum TornDestination: String, CaseIterable, Identifiable {
|
|||
var travelAgencyURL: URL {
|
||||
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
|
||||
|
|
@ -314,7 +326,7 @@ struct MoneyData: Codable {
|
|||
case cash = "money_onhand"
|
||||
case vault = "vault_amount"
|
||||
case points
|
||||
case tokens = "company_funds"
|
||||
case tokens = "donator"
|
||||
case cayman = "cayman_bank"
|
||||
}
|
||||
|
||||
|
|
@ -664,7 +676,7 @@ class UpdateManager {
|
|||
}
|
||||
|
||||
} catch {
|
||||
print("Update check failed: \(error)")
|
||||
logger.warning("Update check failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import Foundation
|
||||
import ServiceManagement
|
||||
import os.log
|
||||
|
||||
private let logger = Logger(subsystem: TornConstants.logSubsystem, category: "LaunchAtLoginManager")
|
||||
|
||||
@MainActor
|
||||
class LaunchAtLoginManager: ObservableObject {
|
||||
@Published var isEnabled: Bool = false
|
||||
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let service = SMAppService.mainApp
|
||||
|
||||
init() {
|
||||
|
|
@ -22,9 +26,11 @@ class LaunchAtLoginManager: ObservableObject {
|
|||
} else {
|
||||
try service.register()
|
||||
}
|
||||
errorMessage = nil
|
||||
updateStatus()
|
||||
} 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 UserNotifications
|
||||
import AppKit
|
||||
import os.log
|
||||
|
||||
private let logger = Logger(subsystem: TornConstants.logSubsystem, category: "NotificationManager")
|
||||
|
||||
enum NotificationType: String {
|
||||
case drugReady
|
||||
|
|
@ -48,10 +51,10 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
|
|||
let granted = try await UNUserNotificationCenter.current()
|
||||
.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
if granted {
|
||||
print("Notification permission granted")
|
||||
logger.info("Notification permission granted")
|
||||
}
|
||||
} 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
|
||||
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
|
||||
if let error = error {
|
||||
print("Scheduled notification error: \(error)")
|
||||
logger.error("Scheduled notification error: \(error.localizedDescription)")
|
||||
} else {
|
||||
print("Scheduled notification '\(identifier)' for \(date)")
|
||||
logger.info("Scheduled notification '\(identifier)' for \(date)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Combine
|
|||
import SwiftUI
|
||||
import os.log
|
||||
|
||||
private let logger = Logger(subsystem: "com.mactorn", category: "AppState")
|
||||
private let logger = Logger(subsystem: TornConstants.logSubsystem, category: "AppState")
|
||||
|
||||
// MARK: - Appearance
|
||||
enum AppearanceMode: String, CaseIterable {
|
||||
|
|
@ -65,6 +65,7 @@ class AppState: ObservableObject {
|
|||
|
||||
// MARK: - Networking (Dependency Injection for Testing)
|
||||
private let session: NetworkSession
|
||||
private let connectivity: NetworkConnectivity
|
||||
|
||||
// MARK: - State Comparison
|
||||
private var previousBars: Bars?
|
||||
|
|
@ -73,11 +74,16 @@ class AppState: ObservableObject {
|
|||
private var previousChain: Chain?
|
||||
private var previousStatus: Status?
|
||||
|
||||
// MARK: - Task Handles (for deduplication)
|
||||
private var fetchTask: Task<Void, Never>?
|
||||
private var watchlistTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Timer
|
||||
private var timerCancellable: AnyCancellable?
|
||||
|
||||
init(session: NetworkSession = URLSession.shared) {
|
||||
init(session: NetworkSession = URLSession.shared, connectivity: NetworkConnectivity = NetworkMonitor.shared) {
|
||||
self.session = session
|
||||
self.connectivity = connectivity
|
||||
loadNotificationRules()
|
||||
loadTravelNotificationSettings()
|
||||
loadWatchlist()
|
||||
|
|
@ -237,18 +243,23 @@ class AppState: ObservableObject {
|
|||
}
|
||||
|
||||
func refreshWatchlistPrices() {
|
||||
Task {
|
||||
watchlistTask?.cancel()
|
||||
watchlistTask = Task {
|
||||
await fetchWatchlistPrices()
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchWatchlistPrices() async {
|
||||
for item in watchlistItems {
|
||||
await fetchItemPrice(itemId: item.id)
|
||||
guard connectivity.isConnected else { return }
|
||||
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,
|
||||
let url = TornAPI.marketURL(itemId: itemId, apiKey: apiKey) else { return }
|
||||
|
||||
|
|
@ -261,21 +272,21 @@ class AppState: ObservableObject {
|
|||
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
|
||||
// Check if API returned error
|
||||
if let error = json["error"] as? [String: Any], let errorText = error["error"] as? String {
|
||||
logger.warning("Item \(itemId) API error: \(errorText)")
|
||||
await updateItemError(itemId: itemId, error: errorText)
|
||||
await updateItemError(itemId: itemId, error: errorText, save: save)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var allListings: [(price: Int, amount: Int)] = []
|
||||
|
||||
|
||||
// Check itemmarket v2 structure
|
||||
if let itemmarket = json["itemmarket"] as? [String: Any],
|
||||
let listings = itemmarket["listings"] as? [[String: Any]] {
|
||||
|
|
@ -284,7 +295,7 @@ class AppState: ObservableObject {
|
|||
return (p, dict["amount"] as? Int ?? 1)
|
||||
}
|
||||
allListings.append(contentsOf: mapped)
|
||||
}
|
||||
}
|
||||
// Fallback for v1
|
||||
else if let itemmarketArr = json["itemmarket"] as? [[String: Any]] {
|
||||
let mapped = itemmarketArr.compactMap { dict -> (Int, Int)? in
|
||||
|
|
@ -293,7 +304,7 @@ class AppState: ObservableObject {
|
|||
}
|
||||
allListings.append(contentsOf: mapped)
|
||||
}
|
||||
|
||||
|
||||
// Check bazaar
|
||||
if let bazaarArr = json["bazaar"] as? [[String: Any]] {
|
||||
let mapped = bazaarArr.compactMap { dict -> (Int, Int)? in
|
||||
|
|
@ -302,28 +313,29 @@ class AppState: ObservableObject {
|
|||
}
|
||||
allListings.append(contentsOf: mapped)
|
||||
}
|
||||
|
||||
|
||||
let sortedListings = allListings.sorted { $0.price < $1.price }
|
||||
logger.debug("Item \(itemId): found \(sortedListings.count) listings, lowest: \(sortedListings.first?.price ?? 0)")
|
||||
|
||||
if let best = sortedListings.first {
|
||||
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 {
|
||||
await updateItemError(itemId: itemId, error: "No listings")
|
||||
await updateItemError(itemId: itemId, error: "No listings", save: save)
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
if Task.isCancelled { return }
|
||||
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
|
||||
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 }) {
|
||||
var item = watchlistItems[index]
|
||||
item.lowestPrice = lowestPrice
|
||||
|
|
@ -332,17 +344,17 @@ class AppState: ObservableObject {
|
|||
item.lastUpdated = Date()
|
||||
item.error = nil
|
||||
watchlistItems[index] = item
|
||||
saveWatchlist()
|
||||
if save { saveWatchlist() }
|
||||
}
|
||||
}
|
||||
|
||||
@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 }) {
|
||||
var item = watchlistItems[index]
|
||||
item.error = error
|
||||
watchlistItems[index] = item
|
||||
saveWatchlist()
|
||||
if save { saveWatchlist() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -363,11 +375,19 @@ class AppState: ObservableObject {
|
|||
}
|
||||
|
||||
func refreshNow() {
|
||||
fetchData()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
// MARK: - Fetch Data
|
||||
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 {
|
||||
errorMsg = "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))...")
|
||||
|
||||
Task {
|
||||
fetchTask?.cancel()
|
||||
fetchTask = Task {
|
||||
let startTime = Date()
|
||||
|
||||
// Ensure minimum loading time for UX, then set isLoading = false
|
||||
|
|
@ -416,23 +437,13 @@ class AppState: ObservableObject {
|
|||
case 200:
|
||||
// Log raw JSON for debugging (first 500 chars)
|
||||
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)
|
||||
if let tornError = checkForTornAPIError(data: data) {
|
||||
await MainActor.run {
|
||||
self.errorMsg = tornError
|
||||
}
|
||||
logger.error("Torn API error: \(tornError)")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse on background thread
|
||||
// Parse user data and fetch faction data in parallel
|
||||
async let factionResult: Void = self.fetchFactionData()
|
||||
try await parseDataInBackground(data: data)
|
||||
|
||||
// Fetch faction data separately
|
||||
await fetchFactionData()
|
||||
await factionResult
|
||||
|
||||
logger.info("Data fetch completed successfully")
|
||||
|
||||
|
|
@ -449,6 +460,7 @@ class AppState: ObservableObject {
|
|||
logger.error("HTTP Error: \(httpResponse.statusCode)")
|
||||
}
|
||||
} catch {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run {
|
||||
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
|
||||
private func parseDataInBackground(data: Data) async throws {
|
||||
// Run CPU-heavy parsing detached from MainActor
|
||||
|
|
@ -477,7 +476,13 @@ class AppState: ObservableObject {
|
|||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
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
|
||||
let decodedTornResponse = try? JSONDecoder().decode(TornResponse.self, from: data)
|
||||
|
||||
|
|
@ -545,7 +550,7 @@ class AppState: ObservableObject {
|
|||
// Check for parse errors
|
||||
if let parseError = result.5, result.0 == nil {
|
||||
self.errorMsg = parseError
|
||||
logger.error("Parse error: \(parseError)")
|
||||
logger.error("Data error: \(parseError)")
|
||||
self.lastUpdated = Date() // Still update timestamp on error
|
||||
return
|
||||
}
|
||||
|
|
@ -597,15 +602,14 @@ class AppState: ObservableObject {
|
|||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
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] {
|
||||
// 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 factionId = json["ID"] as? Int ?? 0
|
||||
let respect = json["respect"] as? Int ?? 0
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ struct SettingsView: View {
|
|||
Text("2m").tag(120)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: appState.refreshInterval) {
|
||||
.onChange(of: appState.refreshInterval) { _ in
|
||||
Task { @MainActor in
|
||||
appState.startPolling()
|
||||
}
|
||||
|
|
@ -232,6 +232,16 @@ struct SettingsView: View {
|
|||
inputKey = appState.apiKey
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ enum TornAPIFixtures {
|
|||
"money_onhand": 1000000,
|
||||
"vault_amount": 50000000,
|
||||
"points": 5000,
|
||||
"company_funds": 100,
|
||||
"donator": 100,
|
||||
"cayman_bank": 100000000
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ final class MoneyDataTests: XCTestCase {
|
|||
"money_onhand": 999999999999,
|
||||
"vault_amount": 9999999999999,
|
||||
"points": 100000,
|
||||
"company_funds": 50000,
|
||||
"donator": 50000,
|
||||
"cayman_bank": 99999999999999
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: json)
|
||||
|
|
|
|||
|
|
@ -17,111 +17,6 @@ final class MacTornUITests: XCTestCase {
|
|||
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
|
||||
|
||||
func testLaunchPerformance() throws {
|
||||
|
|
|
|||
Loading…
Reference in a new issue