Commit aa49c4e3da
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -517,7 +517,7 @@ | |||
| 517 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 517 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 518 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 518 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 519 | CODE_SIGN_STYLE = Automatic; | 519 | CODE_SIGN_STYLE = Automatic; |
| 520 | CURRENT_PROJECT_VERSION = 80; | 520 | CURRENT_PROJECT_VERSION = 81; |
| 521 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 521 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 522 | ENABLE_PREVIEWS = YES; | 522 | ENABLE_PREVIEWS = YES; |
| 523 | GENERATE_INFOPLIST_FILE = YES; | 523 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -534,7 +534,7 @@ | |||
| 534 | "$(inherited)", | 534 | "$(inherited)", |
| 535 | "@executable_path/Frameworks", | 535 | "@executable_path/Frameworks", |
| 536 | ); | 536 | ); |
| 537 | MARKETING_VERSION = 3.2.0; | 537 | MARKETING_VERSION = 3.2.1; |
| 538 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 538 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 539 | PRODUCT_NAME = "$(TARGET_NAME)"; | 539 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 540 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 540 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -554,7 +554,7 @@ | |||
| 554 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 554 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 555 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 555 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 556 | CODE_SIGN_STYLE = Automatic; | 556 | CODE_SIGN_STYLE = Automatic; |
| 557 | CURRENT_PROJECT_VERSION = 80; | 557 | CURRENT_PROJECT_VERSION = 81; |
| 558 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 558 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 559 | ENABLE_PREVIEWS = YES; | 559 | ENABLE_PREVIEWS = YES; |
| 560 | GENERATE_INFOPLIST_FILE = YES; | 560 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -571,7 +571,7 @@ | |||
| 571 | "$(inherited)", | 571 | "$(inherited)", |
| 572 | "@executable_path/Frameworks", | 572 | "@executable_path/Frameworks", |
| 573 | ); | 573 | ); |
| 574 | MARKETING_VERSION = 3.2.0; | 574 | MARKETING_VERSION = 3.2.1; |
| 575 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 575 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 576 | PRODUCT_NAME = "$(TARGET_NAME)"; | 576 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 577 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 577 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -634,7 +634,7 @@ | |||
| 634 | APPLICATION_EXTENSION_API_ONLY = YES; | 634 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 635 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 635 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 636 | CODE_SIGN_STYLE = Automatic; | 636 | CODE_SIGN_STYLE = Automatic; |
| 637 | CURRENT_PROJECT_VERSION = 80; | 637 | CURRENT_PROJECT_VERSION = 81; |
| 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 639 | GENERATE_INFOPLIST_FILE = NO; | 639 | GENERATE_INFOPLIST_FILE = NO; |
| 640 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 640 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -644,7 +644,7 @@ | |||
| 644 | "@executable_path/Frameworks", | 644 | "@executable_path/Frameworks", |
| 645 | "@executable_path/../../Frameworks", | 645 | "@executable_path/../../Frameworks", |
| 646 | ); | 646 | ); |
| 647 | MARKETING_VERSION = 3.2.0; | 647 | MARKETING_VERSION = 3.2.1; |
| 648 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 648 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 649 | PRODUCT_NAME = "$(TARGET_NAME)"; | 649 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 650 | SKIP_INSTALL = YES; | 650 | SKIP_INSTALL = YES; |
| @@ -663,7 +663,7 @@ | |||
| 663 | APPLICATION_EXTENSION_API_ONLY = YES; | 663 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 664 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 664 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 665 | CODE_SIGN_STYLE = Automatic; | 665 | CODE_SIGN_STYLE = Automatic; |
| 666 | CURRENT_PROJECT_VERSION = 80; | 666 | CURRENT_PROJECT_VERSION = 81; |
| 667 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 667 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 668 | GENERATE_INFOPLIST_FILE = NO; | 668 | GENERATE_INFOPLIST_FILE = NO; |
| 669 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 669 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -673,7 +673,7 @@ | |||
| 673 | "@executable_path/Frameworks", | 673 | "@executable_path/Frameworks", |
| 674 | "@executable_path/../../Frameworks", | 674 | "@executable_path/../../Frameworks", |
| 675 | ); | 675 | ); |
| 676 | MARKETING_VERSION = 3.2.0; | 676 | MARKETING_VERSION = 3.2.1; |
| 677 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 677 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 678 | PRODUCT_NAME = "$(TARGET_NAME)"; | 678 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 679 | SKIP_INSTALL = YES; | 679 | SKIP_INSTALL = YES; |
Hutch/App/AppStorageKeys.swift +1
| @@ -18,6 +18,7 @@ enum AppStorageKeys { | |||
| 18 | static let homeBuildsExpanded = "homeBuildsExpanded" | 18 | static let homeBuildsExpanded = "homeBuildsExpanded" |
| 19 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" | 19 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" |
| 20 | static let buildsRepoFilter = "buildsRepoFilter" | 20 | static let buildsRepoFilter = "buildsRepoFilter" |
| 21 | static let buildsLookbackDays = "buildsLookbackDays" | ||
| 21 | static let ticketFilterState = "ticketFilterState" | 22 | static let ticketFilterState = "ticketFilterState" |
| 22 | static let ticketSavedFilters = "ticketSavedFilters" | 23 | static let ticketSavedFilters = "ticketSavedFilters" |
| 23 | static let appTheme = "appTheme" | 24 | static let appTheme = "appTheme" |
Hutch/Models/Meta.swift +7 −1
| @@ -22,10 +22,16 @@ struct UserProfile: Codable, Sendable { | |||
| 22 | 22 | ||
| 23 | struct SSHKey: Codable, Sendable, Identifiable { | 23 | struct SSHKey: Codable, Sendable, Identifiable { |
| 24 | let id: Int | 24 | let id: Int |
| 25 | let fingerprint: String | ||
| 26 | let comment: String? | 25 | let comment: String? |
| 27 | let created: Date | 26 | let created: Date |
| 28 | let lastUsed: Date? | 27 | let lastUsed: Date? |
| 28 | |||
| 29 | var displayLabel: String { | ||
| 30 | if let comment, !comment.isEmpty { | ||
| 31 | return comment | ||
| 32 | } | ||
| 33 | return "SSH key #\(id)" | ||
| 34 | } | ||
| 29 | } | 35 | } |
| 30 | 36 | ||
| 31 | struct SSHKeyPage: Codable, Sendable { | 37 | struct SSHKeyPage: Codable, Sendable { |
Hutch/Views/Builds/BuildListView.swift +26
| @@ -4,6 +4,8 @@ struct BuildListView: View { | |||
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true | 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 5 | @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 | 5 | @AppStorage(AppStorageKeys.buildsAutoRefreshInterval) private var autoRefreshRawValue = 0 |
| 6 | @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" | 6 | @AppStorage(AppStorageKeys.buildsRepoFilter) private var savedRepoFilter = "" |
| 7 | @AppStorage(AppStorageKeys.buildsLookbackDays, store: .standard) | ||
| 8 | private var lookbackDays = BuildListViewModel.defaultLookbackDays | ||
| 7 | @Environment(AppState.self) private var appState | 9 | @Environment(AppState.self) private var appState |
| 8 | @Environment(\.isAMOLEDTheme) private var isAMOLED | 10 | @Environment(\.isAMOLEDTheme) private var isAMOLED |
| 9 | @State private var viewModel: BuildListViewModel? | 11 | @State private var viewModel: BuildListViewModel? |
| @@ -65,6 +67,20 @@ struct BuildListView: View { | |||
| 65 | } | 67 | } |
| 66 | } | 68 | } |
| 67 | } | 69 | } |
| 70 | Section("Timeframe") { | ||
| 71 | ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in | ||
| 72 | Button { | ||
| 73 | lookbackDays = days | ||
| 74 | viewModel.lookbackDays = days | ||
| 75 | } label: { | ||
| 76 | if lookbackDays == days { | ||
| 77 | Label(HomeViewModel.failedBuildLookbackLabel(days: days), systemImage: "checkmark") | ||
| 78 | } else { | ||
| 79 | Text(HomeViewModel.failedBuildLookbackLabel(days: days)) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } | ||
| 68 | } label: { | 84 | } label: { |
| 69 | Image(systemName: "line.3.horizontal.decrease.circle") | 85 | Image(systemName: "line.3.horizontal.decrease.circle") |
| 70 | } | 86 | } |
| @@ -107,6 +123,7 @@ struct BuildListView: View { | |||
| 107 | if viewModel == nil { | 123 | if viewModel == nil { |
| 108 | let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) | 124 | let vm = BuildListViewModel(client: appState.client, defaults: appState.accountDefaults) |
| 109 | vm.repoFilter = savedRepoFilter | 125 | vm.repoFilter = savedRepoFilter |
| 126 | vm.lookbackDays = lookbackDays | ||
| 110 | viewModel = vm | 127 | viewModel = vm |
| 111 | await vm.loadJobs() | 128 | await vm.loadJobs() |
| 112 | } | 129 | } |
| @@ -114,6 +131,9 @@ struct BuildListView: View { | |||
| 114 | // onDisappear stops it when navigating away. | 131 | // onDisappear stops it when navigating away. |
| 115 | viewModel?.startAutoRefresh(interval: autoRefreshInterval) | 132 | viewModel?.startAutoRefresh(interval: autoRefreshInterval) |
| 116 | } | 133 | } |
| 134 | .onChange(of: lookbackDays) { _, newValue in | ||
| 135 | viewModel?.lookbackDays = newValue | ||
| 136 | } | ||
| 117 | .onDisappear { | 137 | .onDisappear { |
| 118 | viewModel?.stopAutoRefresh() | 138 | viewModel?.stopAutoRefresh() |
| 119 | } | 139 | } |
| @@ -236,6 +256,12 @@ struct BuildListView: View { | |||
| 236 | systemImage: "magnifyingglass", | 256 | systemImage: "magnifyingglass", |
| 237 | description: Text("No builds matched “\(viewModel.searchText)”.") | 257 | description: Text("No builds matched “\(viewModel.searchText)”.") |
| 238 | ) | 258 | ) |
| 259 | } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty { | ||
| 260 | ContentUnavailableView( | ||
| 261 | "No Builds In Timeframe", | ||
| 262 | systemImage: "calendar.badge.clock", | ||
| 263 | description: Text("No builds were updated \(HomeViewModel.failedBuildLookbackLabel(days: lookbackDays)).") | ||
| 264 | ) | ||
| 239 | } else if viewModel.jobs.isEmpty, viewModel.error == nil { | 265 | } else if viewModel.jobs.isEmpty, viewModel.error == nil { |
| 240 | ContentUnavailableView( | 266 | ContentUnavailableView( |
| 241 | "No Builds", | 267 | "No Builds", |
Hutch/Views/Builds/BuildListViewModel.swift +25 −3
| @@ -45,6 +45,7 @@ enum AutoRefreshInterval: Int, CaseIterable, Sendable { | |||
| 45 | @MainActor | 45 | @MainActor |
| 46 | final class BuildListViewModel { | 46 | final class BuildListViewModel { |
| 47 | private static let searchHistoryScopeID = "builds" | 47 | private static let searchHistoryScopeID = "builds" |
| 48 | nonisolated static let defaultLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays | ||
| 48 | 49 | ||
| 49 | private(set) var jobs: [JobSummary] = [] { | 50 | private(set) var jobs: [JobSummary] = [] { |
| 50 | didSet { updateFilteredJobs() } | 51 | didSet { updateFilteredJobs() } |
| @@ -68,6 +69,9 @@ final class BuildListViewModel { | |||
| 68 | updateFilteredJobs() | 69 | updateFilteredJobs() |
| 69 | } | 70 | } |
| 70 | } | 71 | } |
| 72 | var lookbackDays = defaultLookbackDays { | ||
| 73 | didSet { updateFilteredJobs() } | ||
| 74 | } | ||
| 71 | // Cached filtered result. Updated whenever jobs, filter, searchText, or | 75 | // Cached filtered result. Updated whenever jobs, filter, searchText, or |
| 72 | // repoFilter changes. Only notifies observers when the content actually | 76 | // repoFilter changes. Only notifies observers when the content actually |
| 73 | // differs, which prevents the list from re-rendering on auto-refresh when | 77 | // differs, which prevents the list from re-rendering on auto-refresh when |
| @@ -99,7 +103,7 @@ final class BuildListViewModel { | |||
| 99 | } | 103 | } |
| 100 | 104 | ||
| 101 | private func updateFilteredJobs() { | 105 | private func updateFilteredJobs() { |
| 102 | var result = Self.filterJobs(jobs, filter: filter) | 106 | var result = Self.filterJobs(jobs, filter: filter, lookbackDays: lookbackDays) |
| 103 | if !repoFilter.isEmpty { | 107 | if !repoFilter.isEmpty { |
| 104 | result = result.filter { $0.tags.contains(repoFilter) } | 108 | result = result.filter { $0.tags.contains(repoFilter) } |
| 105 | } | 109 | } |
| @@ -382,8 +386,14 @@ final class BuildListViewModel { | |||
| 382 | let cancel: CancelResult | 386 | let cancel: CancelResult |
| 383 | } | 387 | } |
| 384 | 388 | ||
| 385 | nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] { | 389 | nonisolated static func filterJobs( |
| 386 | jobs.filter { job in | 390 | _ jobs: [JobSummary], |
| 391 | filter: BuildListFilter, | ||
| 392 | lookbackDays: Int, | ||
| 393 | now: Date = .now, | ||
| 394 | calendar: Calendar = .current | ||
| 395 | ) -> [JobSummary] { | ||
| 396 | let filteredByStatus = jobs.filter { job in | ||
| 387 | switch filter { | 397 | switch filter { |
| 388 | case .attention: | 398 | case .attention: |
| 389 | switch job.status { | 399 | switch job.status { |
| @@ -403,6 +413,18 @@ final class BuildListViewModel { | |||
| 403 | return true | 413 | return true |
| 404 | } | 414 | } |
| 405 | } | 415 | } |
| 416 | |||
| 417 | let normalizedLookbackDays = HomeViewModel.allowedFailedBuildLookbackDays.contains(lookbackDays) | ||
| 418 | ? lookbackDays | ||
| 419 | : defaultLookbackDays | ||
| 420 | let startOfToday = calendar.startOfDay(for: now) | ||
| 421 | let windowStart = calendar.date( | ||
| 422 | byAdding: .day, | ||
| 423 | value: -(normalizedLookbackDays - 1), | ||
| 424 | to: startOfToday | ||
| 425 | ) ?? startOfToday | ||
| 426 | |||
| 427 | return filteredByStatus.filter { $0.updated >= windowStart } | ||
| 406 | } | 428 | } |
| 407 | 429 | ||
| 408 | nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] { | 430 | nonisolated static func searchJobs(_ jobs: [JobSummary], matching query: String) -> [JobSummary] { |
Hutch/Views/Home/HomeView.swift +3
| @@ -56,6 +56,9 @@ struct HomeView: View { | |||
| 56 | loadRecentActivity() | 56 | loadRecentActivity() |
| 57 | } | 57 | } |
| 58 | } | 58 | } |
| 59 | .onChange(of: failedBuildLookbackDays) { _, _ in | ||
| 60 | viewModel?.refreshNeedsAttentionSnapshot() | ||
| 61 | } | ||
| 59 | } | 62 | } |
| 60 | 63 | ||
| 61 | @ViewBuilder | 64 | @ViewBuilder |
Hutch/Views/Home/HomeViewModel.swift +4
| @@ -670,6 +670,10 @@ final class HomeViewModel { | |||
| 670 | persistNeedsAttentionSnapshot() | 670 | persistNeedsAttentionSnapshot() |
| 671 | } | 671 | } |
| 672 | 672 | ||
| 673 | func refreshNeedsAttentionSnapshot() { | ||
| 674 | persistNeedsAttentionSnapshot() | ||
| 675 | } | ||
| 676 | |||
| 673 | private func loadProjects() async -> Result<[Project], Error> { | 677 | private func loadProjects() async -> Result<[Project], Error> { |
| 674 | do { | 678 | do { |
| 675 | return .success(try await projectService.fetchProjects()) | 679 | return .success(try await projectService.fetchProjects()) |
Hutch/Views/More/ProfileView.swift +2 −2
| @@ -243,7 +243,7 @@ struct ProfileView: View { | |||
| 243 | Section { | 243 | Section { |
| 244 | ForEach(viewModel.sshKeys) { key in | 244 | ForEach(viewModel.sshKeys) { key in |
| 245 | VStack(alignment: .leading, spacing: 2) { | 245 | VStack(alignment: .leading, spacing: 2) { |
| 246 | Text(key.fingerprint) | 246 | Text(key.displayLabel) |
| 247 | .font(.caption.monospaced()) | 247 | .font(.caption.monospaced()) |
| 248 | .lineLimit(1) | 248 | .lineLimit(1) |
| 249 | .truncationMode(.middle) | 249 | .truncationMode(.middle) |
| @@ -677,7 +677,7 @@ private enum ProfileDestructiveAction { | |||
| 677 | var message: String { | 677 | var message: String { |
| 678 | switch self { | 678 | switch self { |
| 679 | case .deleteSSHKey(let key): | 679 | case .deleteSSHKey(let key): |
| 680 | "Remove SSH key \(key.fingerprint) from your account?" | 680 | "Remove \(key.displayLabel) from your account?" |
| 681 | case .deletePGPKey(let key): | 681 | case .deletePGPKey(let key): |
| 682 | "Remove PGP key \(key.fingerprint) from your account?" | 682 | "Remove PGP key \(key.fingerprint) from your account?" |
| 683 | } | 683 | } |
Hutch/Views/Settings/SettingsViewModel.swift +2 −2
| @@ -90,7 +90,7 @@ final class SettingsViewModel { | |||
| 90 | avatar | 90 | avatar |
| 91 | userType | 91 | userType |
| 92 | sshKeys { | 92 | sshKeys { |
| 93 | results { id fingerprint comment created lastUsed } | 93 | results { id comment created lastUsed } |
| 94 | cursor | 94 | cursor |
| 95 | } | 95 | } |
| 96 | pgpKeys { | 96 | pgpKeys { |
| @@ -114,7 +114,7 @@ final class SettingsViewModel { | |||
| 114 | private static let createSSHKeyMutation = """ | 114 | private static let createSSHKeyMutation = """ |
| 115 | mutation createSSHKey($key: String!) { | 115 | mutation createSSHKey($key: String!) { |
| 116 | createSSHKey(key: $key) { | 116 | createSSHKey(key: $key) { |
| 117 | id fingerprint comment created lastUsed | 117 | id comment created lastUsed |
| 118 | } | 118 | } |
| 119 | } | 119 | } |
| 120 | """ | 120 | """ |
HutchTests/BuildListViewModelTests.swift +53 −5
| @@ -49,22 +49,70 @@ struct BuildListViewModelTests { | |||
| 49 | makeJob(id: 4, status: .cancelled, tags: []) | 49 | makeJob(id: 4, status: .cancelled, tags: []) |
| 50 | ] | 50 | ] |
| 51 | 51 | ||
| 52 | let attention = BuildListViewModel.filterJobs(jobs, filter: .attention) | 52 | let attention = BuildListViewModel.filterJobs( |
| 53 | let active = BuildListViewModel.filterJobs(jobs, filter: .active) | 53 | jobs, |
| 54 | filter: .attention, | ||
| 55 | lookbackDays: BuildListViewModel.defaultLookbackDays | ||
| 56 | ) | ||
| 57 | let active = BuildListViewModel.filterJobs( | ||
| 58 | jobs, | ||
| 59 | filter: .active, | ||
| 60 | lookbackDays: BuildListViewModel.defaultLookbackDays | ||
| 61 | ) | ||
| 54 | 62 | ||
| 55 | #expect(attention.map(\.id) == [2, 3]) | 63 | #expect(attention.map(\.id) == [2, 3]) |
| 56 | #expect(active.map(\.id) == [3]) | 64 | #expect(active.map(\.id) == [3]) |
| 57 | } | 65 | } |
| 58 | 66 | ||
| 67 | @Test | ||
| 68 | func buildFilterRestrictsJobsToSelectedLookbackWindow() { | ||
| 69 | let now = Date(timeIntervalSince1970: 60 * 60 * 24 * 20) | ||
| 70 | let jobs = [ | ||
| 71 | makeJob( | ||
| 72 | id: 1, | ||
| 73 | status: .failed, | ||
| 74 | tags: [], | ||
| 75 | updated: now.addingTimeInterval(-(60 * 60 * 24)) | ||
| 76 | ), | ||
| 77 | makeJob( | ||
| 78 | id: 2, | ||
| 79 | status: .running, | ||
| 80 | tags: [], | ||
| 81 | updated: now.addingTimeInterval(-(60 * 60 * 24 * 8)) | ||
| 82 | ), | ||
| 83 | makeJob( | ||
| 84 | id: 3, | ||
| 85 | status: .success, | ||
| 86 | tags: [], | ||
| 87 | updated: now.addingTimeInterval(-(60 * 60 * 24 * 2)) | ||
| 88 | ), | ||
| 89 | ] | ||
| 90 | |||
| 91 | let filtered = BuildListViewModel.filterJobs( | ||
| 92 | jobs, | ||
| 93 | filter: .all, | ||
| 94 | lookbackDays: 3, | ||
| 95 | now: now, | ||
| 96 | calendar: Calendar(identifier: .gregorian) | ||
| 97 | ) | ||
| 98 | |||
| 99 | #expect(filtered.map(\.id) == [1, 3]) | ||
| 100 | } | ||
| 101 | |||
| 59 | private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { | 102 | private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { |
| 60 | BuildListViewModel.searchJobs(jobs, matching: query) | 103 | BuildListViewModel.searchJobs(jobs, matching: query) |
| 61 | } | 104 | } |
| 62 | 105 | ||
| 63 | private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary { | 106 | private func makeJob( |
| 107 | id: Int, | ||
| 108 | status: JobStatus = .success, | ||
| 109 | tags: [String], | ||
| 110 | updated: Date = Date() | ||
| 111 | ) -> JobSummary { | ||
| 64 | JobSummary( | 112 | JobSummary( |
| 65 | id: id, | 113 | id: id, |
| 66 | created: Date(), | 114 | created: updated, |
| 67 | updated: Date(), | 115 | updated: updated, |
| 68 | status: status, | 116 | status: status, |
| 69 | note: nil, | 117 | note: nil, |
| 70 | tags: tags, | 118 | tags: tags, |
HutchTests/SettingsViewModelTests.swift +55
| @@ -2,6 +2,37 @@ import Foundation | |||
| 2 | import Testing | 2 | import Testing |
| 3 | @testable import Hutch | 3 | @testable import Hutch |
| 4 | 4 | ||
| 5 | private final class SettingsViewModelCapturingURLProtocol: URLProtocol, @unchecked Sendable { | ||
| 6 | nonisolated(unsafe) static var capturedRequests: [URLRequest] = [] | ||
| 7 | |||
| 8 | override class func canInit(with _: URLRequest) -> Bool { true } | ||
| 9 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } | ||
| 10 | |||
| 11 | override func startLoading() { | ||
| 12 | Self.capturedRequests.append(request) | ||
| 13 | |||
| 14 | let response = HTTPURLResponse( | ||
| 15 | url: request.url!, | ||
| 16 | statusCode: 401, | ||
| 17 | httpVersion: nil, | ||
| 18 | headerFields: nil | ||
| 19 | )! | ||
| 20 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) | ||
| 21 | client?.urlProtocol(self, didLoad: Data()) | ||
| 22 | client?.urlProtocolDidFinishLoading(self) | ||
| 23 | } | ||
| 24 | |||
| 25 | override func stopLoading() { | ||
| 26 | // No cleanup is needed because the stub responds immediately in `startLoading()`. | ||
| 27 | } | ||
| 28 | |||
| 29 | static func makeSession() -> URLSession { | ||
| 30 | let config = URLSessionConfiguration.ephemeral | ||
| 31 | config.protocolClasses = [Self.self] | ||
| 32 | return URLSession(configuration: config) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 5 | private struct DeletePGPKeyEnvelope: Decodable { | 36 | private struct DeletePGPKeyEnvelope: Decodable { |
| 6 | let deletePGPKey: DeleteResultPayload? | 37 | let deletePGPKey: DeleteResultPayload? |
| 7 | } | 38 | } |
| @@ -10,6 +41,7 @@ private struct DeleteResultPayload: Decodable { | |||
| 10 | let id: Int? | 41 | let id: Int? |
| 11 | } | 42 | } |
| 12 | 43 | ||
| 44 | @Suite(.serialized) | ||
| 13 | struct SettingsViewModelTests { | 45 | struct SettingsViewModelTests { |
| 14 | 46 | ||
| 15 | @Test | 47 | @Test |
| @@ -36,4 +68,27 @@ struct SettingsViewModelTests { | |||
| 36 | #expect(decoded.data?.deletePGPKey == nil) | 68 | #expect(decoded.data?.deletePGPKey == nil) |
| 37 | #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true) | 69 | #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true) |
| 38 | } | 70 | } |
| 71 | |||
| 72 | @Test | ||
| 73 | @MainActor | ||
| 74 | func loadProfileDoesNotRequestSSHKeyFingerprintField() async throws { | ||
| 75 | SettingsViewModelCapturingURLProtocol.capturedRequests = [] | ||
| 76 | |||
| 77 | let client = SRHTClient( | ||
| 78 | session: SettingsViewModelCapturingURLProtocol.makeSession(), | ||
| 79 | token: "test-token" | ||
| 80 | ) | ||
| 81 | let viewModel = SettingsViewModel(client: client) | ||
| 82 | |||
| 83 | await viewModel.loadProfile() | ||
| 84 | |||
| 85 | let request = try #require(SettingsViewModelCapturingURLProtocol.capturedRequests.first) | ||
| 86 | let body = try #require(request.httpBody) | ||
| 87 | let jsonObject = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) | ||
| 88 | let query = try #require(jsonObject["query"] as? String) | ||
| 89 | |||
| 90 | #expect(query.contains("sshKeys")) | ||
| 91 | #expect(!query.contains("results { id fingerprint comment created lastUsed }")) | ||
| 92 | #expect(!query.contains("fingerprint comment created lastUsed")) | ||
| 93 | } | ||
| 39 | } | 94 | } |