diff --git a/Canvas.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Canvas.xcworkspace/xcshareddata/swiftpm/Package.resolved index 402ceda791..458d2a9f67 100644 --- a/Canvas.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Canvas.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "102d90cdfa1716b86d908a5b9c78094cc7e8ac6573d7ecb6e8ea7a20d67852e4", + "originHash" : "484f3bb026c883046090249288537aa1c517090fdb78f2b0675a8aa42b38fbed", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -159,8 +159,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" + "revision" : "2547102afd04fe49f1b286090f13ebce07284980", + "version" : "1.31.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "2e6a85b73fc14e27d7542165ae73b1a10516ca9a", + "version" : "1.17.7" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { diff --git a/Core/Core/Common/CommonUI/Fonts/UIFontExtensions.swift b/Core/Core/Common/CommonUI/Fonts/UIFontExtensions.swift index 921c089008..0a343e5c8d 100644 --- a/Core/Core/Common/CommonUI/Fonts/UIFontExtensions.swift +++ b/Core/Core/Common/CommonUI/Fonts/UIFontExtensions.swift @@ -142,7 +142,14 @@ public extension UIFont { static func applicationFontName(weight: UIFont.Weight, isItalic: Bool = false) -> String { let isK5Font = AppEnvironment.shared.k5.isK5Enabled - let font = isK5Font ? "BalsamiqSans" : "Lato" + let font: String + if isK5Font { + font = "BalsamiqSans" + } else if AppEnvironment.shared.app == .horizon { + font = "Figtree" + } else { + font = "Lato" + } var suffix = "" if isK5Font { diff --git a/Horizon/Horizon/horizon.yml b/Horizon/Horizon/horizon.yml index cf33c86e00..9c5b634a7e 100644 --- a/Horizon/Horizon/horizon.yml +++ b/Horizon/Horizon/horizon.yml @@ -34,3 +34,7 @@ schemes: build: targets: Horizon: all + test: + targets: + - HorizonSnapshotTests + - HorizonUnitTests diff --git a/Horizon/HorizonSnapshotTests/Features/Account/AccountViewSnapshotTests.swift b/Horizon/HorizonSnapshotTests/Features/Account/AccountViewSnapshotTests.swift new file mode 100644 index 0000000000..e17ec9e352 --- /dev/null +++ b/Horizon/HorizonSnapshotTests/Features/Account/AccountViewSnapshotTests.swift @@ -0,0 +1,168 @@ +// +// This file is part of Canvas. +// Copyright (C) 2024-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import CoreData +@testable import Horizon +import HorizonUI +import SnapshotTesting +import SwiftUI +import TestsFoundation +import XCTest + +class AccountViewSnapshotTests: HorizonSnapshotTestCase { + func testAccountViewDefault() { + let viewModel = createMockAccountViewModel( + userName: "John Doe", + isExperienceSwitchAvailable: true, + isLoading: false + ) + let view = AccountView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "account-default" + ) + } + + func testAccountViewLoading() { + let viewModel = createMockAccountViewModel( + userName: "John Doe", + isExperienceSwitchAvailable: true, + isLoading: true + ) + let view = AccountView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "account-loading" + ) + } + + func testAccountViewNoExperienceSwitch() { + let viewModel = createMockAccountViewModel( + userName: "Jane Smith", + isExperienceSwitchAvailable: false, + isLoading: false + ) + let view = AccountView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "account-no-experience-switch" + ) + } + + func testAccountViewAccessibility() { + let viewModel = createMockAccountViewModel( + userName: "Very Long User Name That Should Wrap", + isExperienceSwitchAvailable: true, + isLoading: false + ) + let view = AccountView(viewModel: viewModel) + + assertAccessibilitySnapshot( + of: view, + named: "account-accessibility" + ) + } + + // MARK: - Helper Methods + + private func createMockAccountViewModel( + userName: String, + isExperienceSwitchAvailable: Bool, + isLoading: Bool + ) -> AccountViewModel { + let mockUserInteractor = MockGetUserInteractor(userName: userName, context: databaseClient) + let mockExperienceInteractor = MockExperienceSummaryInteractor(isAvailable: isExperienceSwitchAvailable) + + let viewModel = AccountViewModel( + getUserInteractor: mockUserInteractor, + appExperienceInteractor: mockExperienceInteractor + ) + + // Trigger data loading + viewModel.getUserName() + + // Override loading state after initial setup + viewModel.isLoading = isLoading + + return viewModel + } +} + +// MARK: - Mock Interactors + +private class MockGetUserInteractor: GetUserInteractor { + let userName: String + let context: NSManagedObjectContext + + init(userName: String, context: NSManagedObjectContext) { + self.userName = userName + self.context = context + } + + func getUser() -> AnyPublisher { + let user = UserProfile(context: context) + user.id = "1" + user.name = userName + user.shortName = userName + user.pronouns = nil + user.avatarURL = URL(string: "https://example.com/avatar.jpg") + user.email = "user@example.com" + user.locale = "en" + + return Just(user) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + func canUpdateName() -> AnyPublisher { + Just(true) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} + +private class MockExperienceSummaryInteractor: ExperienceSummaryInteractor { + let isAvailable: Bool + + init(isAvailable: Bool) { + self.isAvailable = isAvailable + } + + func getExperienceSummary() -> AnyPublisher { + Just(.careerLearner) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + func isExperienceSwitchAvailable() -> AnyPublisher { + Just(isAvailable).eraseToAnyPublisher() + } + + func isExperienceSwitchAvailableAsync() async -> Bool { + isAvailable + } + + func switchExperience(to _: Experience) -> AnyPublisher { + Just(()).eraseToAnyPublisher() + } +} diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Advanced/ProfileAdvancedViewSnapshotTests.swift b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/ProfileAdvancedViewSnapshotTests.swift new file mode 100644 index 0000000000..ba9a570c91 --- /dev/null +++ b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/ProfileAdvancedViewSnapshotTests.swift @@ -0,0 +1,112 @@ +// +// This file is part of Canvas. +// Copyright (C) 2024-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import CoreData +@testable import Horizon +import HorizonUI +import SnapshotTesting +import SwiftUI +import TestsFoundation +import XCTest + +class ProfileAdvancedViewSnapshotTests: HorizonSnapshotTestCase { + func testProfileAdvancedViewDefault() { + let viewModel = createMockProfileAdvancedViewModel(timeZone: "America/Denver") + let view = ProfileAdvancedView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "profile-advanced-default" + ) + } + + func testProfileAdvancedViewLoading() { + let viewModel = createMockProfileAdvancedViewModel(timeZone: "America/Denver") + viewModel.isLoading = true + let view = ProfileAdvancedView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "profile-advanced-loading" + ) + } + + private func createMockProfileAdvancedViewModel(timeZone: String) -> ProfileAdvancedViewModel { + let mockGetUserInteractor = MockAdvancedGetUserInteractor(timeZone: timeZone, context: databaseClient) + let mockUpdateInteractor = MockAdvancedUpdateUserProfileInteractor(context: databaseClient) + + return ProfileAdvancedViewModel( + getUserInteractor: mockGetUserInteractor, + updateUserProfileInteractor: mockUpdateInteractor + ) + } +} + +private class MockAdvancedGetUserInteractor: GetUserInteractor { + let timeZone: String + let context: NSManagedObjectContext + + init(timeZone: String, context: NSManagedObjectContext) { + self.timeZone = timeZone + self.context = context + } + + func getUser() -> AnyPublisher { + let user = UserProfile(context: context) + user.id = "1" + user.name = "Test User" + user.shortName = "Test" + user.email = "test@example.com" + user.defaultTimeZone = timeZone + + return Just(user) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + func canUpdateName() -> AnyPublisher { + Just(true) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} + +private class MockAdvancedUpdateUserProfileInteractor: UpdateUserProfileInteractor { + let context: NSManagedObjectContext + + init(context: NSManagedObjectContext) { + self.context = context + } + + func set(name _: String, shortName _: String) -> AnyPublisher { + fatalError("Not implemented") + } + + func set(timeZone: String) -> AnyPublisher { + let user = UserProfile(context: context) + user.id = "1" + user.name = "Test User" + user.defaultTimeZone = timeZone + + return Just(user) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewDefault.profile-advanced-default-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewDefault.profile-advanced-default-iPhone13.png new file mode 100644 index 0000000000..9ad7035f2d Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewDefault.profile-advanced-default-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewDefault.profile-advanced-default-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewDefault.profile-advanced-default-iPhoneSE.png new file mode 100644 index 0000000000..416cd3a4ad Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewDefault.profile-advanced-default-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewLoading.profile-advanced-loading-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewLoading.profile-advanced-loading-iPhone13.png new file mode 100644 index 0000000000..b4fc40af02 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewLoading.profile-advanced-loading-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewLoading.profile-advanced-loading-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewLoading.profile-advanced-loading-iPhoneSE.png new file mode 100644 index 0000000000..239db12b3f Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Advanced/__Snapshots__/testProfileAdvancedViewLoading.profile-advanced-loading-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/NotificationSettingsViewSnapshotTests.swift b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/NotificationSettingsViewSnapshotTests.swift new file mode 100644 index 0000000000..b5dffee5a8 --- /dev/null +++ b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/NotificationSettingsViewSnapshotTests.swift @@ -0,0 +1,179 @@ +// +// This file is part of Canvas. +// Copyright (C) 2024-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import CombineSchedulers +import Core +import CoreData +@testable import Horizon +import HorizonUI +import SnapshotTesting +import SwiftUI +import TestsFoundation +import XCTest + +class NotificationSettingsViewSnapshotTests: HorizonSnapshotTestCase { + func testNotificationSettingsViewDefault() { + let viewModel = createMockNotificationSettingsViewModel( + isOSNotificationEnabled: true, + isPushConfigured: true + ) + viewModel.viewState = .data + let view = NotificationSettingsView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "notification-settings-default" + ) + } + + func testNotificationSettingsViewLoading() { + let viewModel = createMockNotificationSettingsViewModel( + isOSNotificationEnabled: true, + isPushConfigured: true + ) + viewModel.viewState = .loading + let view = NotificationSettingsView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "notification-settings-loading" + ) + } + + func testNotificationSettingsViewPushDisabled() { + let viewModel = createMockNotificationSettingsViewModel( + isOSNotificationEnabled: false, + isPushConfigured: true + ) + viewModel.viewState = .data + let view = NotificationSettingsView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "notification-settings-push-disabled" + ) + } + + func testNotificationSettingsViewNoPushConfigured() { + let viewModel = createMockNotificationSettingsViewModel( + isOSNotificationEnabled: true, + isPushConfigured: false + ) + viewModel.viewState = .data + let view = NotificationSettingsView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "notification-settings-no-push-configured" + ) + } + + private func createMockNotificationSettingsViewModel( + isOSNotificationEnabled: Bool, + isPushConfigured: Bool + ) -> NotificationSettingsViewModel { + let mockInteractor = MockNotificationSettingsInteractor(isPushConfigured: isPushConfigured, context: databaseClient) + let mockRouter = MockRouter() + + let viewModel = NotificationSettingsViewModel( + notificationSettingsInteractor: mockInteractor, + router: mockRouter, + scheduler: .immediate + ) + viewModel.isOSNotificationEnabled = isOSNotificationEnabled + + return viewModel + } +} + +private class MockNotificationSettingsInteractor: NotificationSettingsInteractor { + let isPushConfigured: Bool + let context: NSManagedObjectContext + + init(isPushConfigured: Bool, context: NSManagedObjectContext) { + self.isPushConfigured = isPushConfigured + self.context = context + } + + func getNotificationPreferences() -> AnyPublisher<[NotificationPreference], Error> { + var preferences: [NotificationPreference] = [] + + let emailCategories: [NotificationPreference.AssociatedCategories] = [.announcement, .due_date, .grading] + let pushCategories: [NotificationPreference.AssociatedCategories] = isPushConfigured ? [.announcement, .due_date, .grading] : [] + + for category in emailCategories { + if let pref = NotificationPreference.make(context: context, category: category, frequency: .immediate, type: .email) { + preferences.append(pref) + } + } + + for category in pushCategories { + if let pref = NotificationPreference.make(context: context, category: category, frequency: .immediate, type: .push) { + preferences.append(pref) + } + } + + return Just(preferences) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + func isOSNotificationEnabled() -> AnyPublisher { + Just(true).eraseToAnyPublisher() + } + + func updateNotificationPreferences( + type _: NotificationChannel.ChannelType, + visibleCategory _: NotificationPreference.VisibleCategories, + currentPreferences _: [NotificationPreference], + isOn _: Bool + ) -> AnyPublisher { + Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} + +private class MockRouter: Router { + init() { + super.init(routes: []) + } +} + +extension NotificationPreference { + fileprivate static func make( + context: NSManagedObjectContext, + category: AssociatedCategories, + frequency: Frequency, + type: NotificationChannel.ChannelType + ) -> NotificationPreference? { + guard let notificationCategory = NSEntityDescription.insertNewObject( + forEntityName: "NotificationCategory", + into: context + ) as? NotificationCategory else { + return nil + } + notificationCategory.category = category.rawValue + notificationCategory.channelID = "channel-1" + notificationCategory.frequency = frequency == .immediate ? .immediately : .never + notificationCategory.notifications = ["notif-1"] + + return NotificationPreference(from: notificationCategory, type: type) + } +} diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewDefault.notification-settings-default-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewDefault.notification-settings-default-iPhone13.png new file mode 100644 index 0000000000..29d1cca7b8 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewDefault.notification-settings-default-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewDefault.notification-settings-default-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewDefault.notification-settings-default-iPhoneSE.png new file mode 100644 index 0000000000..7b5e892f9e Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewDefault.notification-settings-default-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewLoading.notification-settings-loading-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewLoading.notification-settings-loading-iPhone13.png new file mode 100644 index 0000000000..e3103bad85 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewLoading.notification-settings-loading-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewLoading.notification-settings-loading-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewLoading.notification-settings-loading-iPhoneSE.png new file mode 100644 index 0000000000..738e3e97c7 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewLoading.notification-settings-loading-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewNoPushConfigured.notification-settings-no-push-configured-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewNoPushConfigured.notification-settings-no-push-configured-iPhone13.png new file mode 100644 index 0000000000..aef96ce1a6 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewNoPushConfigured.notification-settings-no-push-configured-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewNoPushConfigured.notification-settings-no-push-configured-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewNoPushConfigured.notification-settings-no-push-configured-iPhoneSE.png new file mode 100644 index 0000000000..f76cef62a1 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewNoPushConfigured.notification-settings-no-push-configured-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewPushDisabled.notification-settings-push-disabled-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewPushDisabled.notification-settings-push-disabled-iPhone13.png new file mode 100644 index 0000000000..8773acc7b9 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewPushDisabled.notification-settings-push-disabled-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewPushDisabled.notification-settings-push-disabled-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewPushDisabled.notification-settings-push-disabled-iPhoneSE.png new file mode 100644 index 0000000000..6d2aa1da60 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Notifications/__Snapshots__/testNotificationSettingsViewPushDisabled.notification-settings-push-disabled-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Profile/ProfileViewSnapshotTests.swift b/Horizon/HorizonSnapshotTests/Features/Account/Profile/ProfileViewSnapshotTests.swift new file mode 100644 index 0000000000..52db871541 --- /dev/null +++ b/Horizon/HorizonSnapshotTests/Features/Account/Profile/ProfileViewSnapshotTests.swift @@ -0,0 +1,163 @@ +// +// This file is part of Canvas. +// Copyright (C) 2024-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +import Combine +import Core +import CoreData +@testable import Horizon +import HorizonUI +import SnapshotTesting +import SwiftUI +import TestsFoundation +import XCTest + +class ProfileViewSnapshotTests: HorizonSnapshotTestCase { + func testProfileViewDefault() { + let viewModel = createMockProfileViewModel( + name: "John Doe", + displayName: "Johnny", + email: "john.doe@example.com", + canUpdateName: true + ) + let view = ProfileView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "profile-default" + ) + } + + func testProfileViewReadOnly() { + let viewModel = createMockProfileViewModel( + name: "Jane Smith", + displayName: "Jane", + email: "jane.smith@example.com", + canUpdateName: false + ) + let view = ProfileView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "profile-readonly" + ) + } + + func testProfileViewWithErrors() { + let viewModel = createMockProfileViewModel( + name: "", + displayName: "", + email: "test@example.com", + canUpdateName: true + ) + let view = ProfileView(viewModel: viewModel) + + assertSnapshot( + of: view, + named: "profile-errors" + ) + } + + private func createMockProfileViewModel( + name: String, + displayName: String, + email: String, + canUpdateName: Bool + ) -> ProfileViewModel { + let mockGetUserInteractor = MockGetUserInteractor( + name: name, + displayName: displayName, + email: email, + canUpdateName: canUpdateName, + context: databaseClient + ) + let mockUpdateInteractor = MockUpdateUserProfileInteractor(context: databaseClient) + + return ProfileViewModel( + getUserInteractor: mockGetUserInteractor, + updateUserProfileInteractor: mockUpdateInteractor + ) + } +} + +private class MockGetUserInteractor: GetUserInteractor { + let name: String + let displayName: String + let email: String + let canUpdate: Bool + let context: NSManagedObjectContext + + init(name: String, displayName: String, email: String, canUpdateName: Bool, context: NSManagedObjectContext) { + self.name = name + self.displayName = displayName + self.email = email + canUpdate = canUpdateName + self.context = context + } + + func getUser() -> AnyPublisher { + let user = UserProfile(context: context) + user.id = "1" + user.name = name + user.shortName = displayName + user.pronouns = nil + user.avatarURL = URL(string: "https://example.com/avatar.jpg") + user.email = email + user.locale = "en" + + return Just(user) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + func canUpdateName() -> AnyPublisher { + Just(canUpdate) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} + +private class MockUpdateUserProfileInteractor: UpdateUserProfileInteractor { + let context: NSManagedObjectContext + + init(context: NSManagedObjectContext) { + self.context = context + } + + func set(name: String, shortName: String) -> AnyPublisher { + let user = UserProfile(context: context) + user.id = "1" + user.name = name + user.shortName = shortName + user.email = "test@example.com" + + return Just(user) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + func set(timeZone: String) -> AnyPublisher { + let user = UserProfile(context: context) + user.id = "1" + user.name = "Test User" + user.defaultTimeZone = timeZone + + return Just(user) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } +} diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewDefault.profile-default-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewDefault.profile-default-iPhone13.png new file mode 100644 index 0000000000..959448df1c Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewDefault.profile-default-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewDefault.profile-default-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewDefault.profile-default-iPhoneSE.png new file mode 100644 index 0000000000..fe11188aad Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewDefault.profile-default-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewReadOnly.profile-readonly-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewReadOnly.profile-readonly-iPhone13.png new file mode 100644 index 0000000000..840b7e2a05 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewReadOnly.profile-readonly-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewReadOnly.profile-readonly-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewReadOnly.profile-readonly-iPhoneSE.png new file mode 100644 index 0000000000..829d0f32a6 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewReadOnly.profile-readonly-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewWithErrors.profile-errors-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewWithErrors.profile-errors-iPhone13.png new file mode 100644 index 0000000000..a12b9ca0d2 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewWithErrors.profile-errors-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewWithErrors.profile-errors-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewWithErrors.profile-errors-iPhoneSE.png new file mode 100644 index 0000000000..2e1fa4d2ef Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/Profile/__Snapshots__/testProfileViewWithErrors.profile-errors-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-accLarge.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-accLarge.png new file mode 100644 index 0000000000..342616a000 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-accLarge.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-large.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-large.png new file mode 100644 index 0000000000..84dae652d5 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-large.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-xxLarge.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-xxLarge.png new file mode 100644 index 0000000000..a4a71d485d Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewAccessibility.account-accessibility-xxLarge.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewDefault.account-default-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewDefault.account-default-iPhone13.png new file mode 100644 index 0000000000..d975be26bd Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewDefault.account-default-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewDefault.account-default-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewDefault.account-default-iPhoneSE.png new file mode 100644 index 0000000000..c740cb7328 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewDefault.account-default-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewLoading.account-loading-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewLoading.account-loading-iPhone13.png new file mode 100644 index 0000000000..728fec0aac Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewLoading.account-loading-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewLoading.account-loading-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewLoading.account-loading-iPhoneSE.png new file mode 100644 index 0000000000..2cbcefbfed Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewLoading.account-loading-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewNoExperienceSwitch.account-no-experience-switch-iPhone13.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewNoExperienceSwitch.account-no-experience-switch-iPhone13.png new file mode 100644 index 0000000000..b72b56c9dd Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewNoExperienceSwitch.account-no-experience-switch-iPhone13.png differ diff --git a/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewNoExperienceSwitch.account-no-experience-switch-iPhoneSE.png b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewNoExperienceSwitch.account-no-experience-switch-iPhoneSE.png new file mode 100644 index 0000000000..571c00e255 Binary files /dev/null and b/Horizon/HorizonSnapshotTests/Features/Account/__Snapshots__/testAccountViewNoExperienceSwitch.account-no-experience-switch-iPhoneSE.png differ diff --git a/Horizon/HorizonSnapshotTests/Helpers/SnapshotTestCase.swift b/Horizon/HorizonSnapshotTests/Helpers/SnapshotTestCase.swift new file mode 100644 index 0000000000..25d24d83aa --- /dev/null +++ b/Horizon/HorizonSnapshotTests/Helpers/SnapshotTestCase.swift @@ -0,0 +1,220 @@ +// +// This file is part of Canvas. +// Copyright (C) 2024-present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +@testable import Core +import CoreData +@testable import Horizon +import HorizonUI +import SnapshotTesting +import SwiftUI +import TestsFoundation +import XCTest + +class HorizonSnapshotTestCase: XCTestCase { + struct Device { + let name: String + let config: ViewImageConfig + + static let iPhone13 = Device( + name: "iPhone13", + config: .iPhone13 + ) + + static let iPhoneSE = Device( + name: "iPhoneSE", + config: .iPhoneSe + ) + } + + static let defaultDevices = [ + Device.iPhone13, + Device.iPhoneSE + ] + + static let allDevices = [ + Device.iPhone13, + Device.iPhoneSE + ] + + func snapshotDirectory(file: StaticString = #file) -> String? { + let testFilePath = "\(file)" + if let featureRange = testFilePath.range(of: "/Features/") { + if let lastSlash = testFilePath.range(of: "/", options: .backwards, range: featureRange.upperBound ..< testFilePath.endIndex) { + let basePath = testFilePath[..( + of view: Content, + named name: String? = nil, + devices: [Device] = HorizonSnapshotTestCase.defaultDevices, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line + ) { + for device in devices { + let snapshotName = name.map { "\($0)-\(device.name)" } ?? device.name + + let failure = verifySnapshot( + of: view, + as: .image(layout: .device(config: device.config)), + named: snapshotName, + record: nil, + snapshotDirectory: snapshotDirectory(file: file), + file: file, + testName: testName, + line: line + ) + + if let failureMessage = failure { + XCTFail(failureMessage, file: file, line: line) + } + } + } + + func assertSnapshot( + of viewController: UIViewController, + named name: String? = nil, + devices: [Device] = HorizonSnapshotTestCase.defaultDevices, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line + ) { + for device in devices { + let snapshotName = name.map { "\($0)-\(device.name)" } ?? device.name + + let failure = verifySnapshot( + of: viewController, + as: .image(on: device.config), + named: snapshotName, + record: nil, + snapshotDirectory: snapshotDirectory(file: file), + file: file, + testName: testName, + line: line + ) + + if let failureMessage = failure { + XCTFail(failureMessage, file: file, line: line) + } + } + } + + func assertAccessibilitySnapshot( + of view: Content, + named name: String? = nil, + sizes: [UIContentSizeCategory] = [.large, .extraExtraLarge, .accessibilityLarge], + file: StaticString = #file, + testName: String = #function, + line: UInt = #line + ) { + for size in sizes { + let sizeString = accessibilitySizeString(for: size) + let snapshotName = name.map { "\($0)-\(sizeString)" } ?? sizeString + + var config = Device.iPhone13.config + config.traits = UITraitCollection(preferredContentSizeCategory: size) + + let failure = verifySnapshot( + of: view, + as: .image(layout: .device(config: config)), + named: snapshotName, + record: nil, + snapshotDirectory: snapshotDirectory(file: file), + file: file, + testName: testName, + line: line + ) + + if let failureMessage = failure { + XCTFail(failureMessage, file: file, line: line) + } + } + } + + private func accessibilitySizeString(for category: UIContentSizeCategory) -> String { + switch category { + case .extraSmall: return "xSmall" + case .small: return "small" + case .medium: return "medium" + case .large: return "large" + case .extraLarge: return "xLarge" + case .extraExtraLarge: return "xxLarge" + case .extraExtraExtraLarge: return "xxxLarge" + case .accessibilityMedium: return "accMedium" + case .accessibilityLarge: return "accLarge" + case .accessibilityExtraLarge: return "accXLarge" + case .accessibilityExtraExtraLarge: return "accXXLarge" + case .accessibilityExtraExtraExtraLarge: return "accXXXLarge" + default: return "default" + } + } + + var database: NSPersistentContainer { + return TestsFoundation.singleSharedTestDatabase + } + + var databaseClient: NSManagedObjectContext { + return database.viewContext + } + + var api: API { environment.api } + var environment: TestEnvironment! + var queue = OperationQueue() + var router = TestRouter() + var logger = TestLogger() + + let window = UIWindow() + + override func setUp() { + super.setUp() + HorizonUI.registerCustomFonts() + OfflineModeAssembly.mock(OfflineModeInteractorMock(mockIsFeatureFlagEnabled: false)) + Clock.reset() + API.resetMocks() + LoginSession.clearAll() + TestsFoundation.singleSharedTestDatabase = resetSingleSharedTestDatabase() + environment = TestEnvironment() + AppEnvironment.shared = environment + environment.app = .horizon + environment.api = API() + environment.database = singleSharedTestDatabase + environment.globalDatabase = singleSharedTestDatabase + environment.router = router + environment.logger = logger + environment.currentSession = LoginSession.make() + environment.window = window + window.rootViewController = UIViewController() + window.makeKeyAndVisible() + } + + override func tearDown() { + super.tearDown() + LoginSession.clearAll() + window.rootViewController = UIViewController() + } +} diff --git a/Horizon/HorizonSnapshotTests/horizon-snapshot-tests.yml b/Horizon/HorizonSnapshotTests/horizon-snapshot-tests.yml new file mode 100644 index 0000000000..4076717809 --- /dev/null +++ b/Horizon/HorizonSnapshotTests/horizon-snapshot-tests.yml @@ -0,0 +1,18 @@ +--- +targets: + HorizonSnapshotTests: + type: bundle.unit-test + platform: iOS + settings: + APPLICATION_EXTENSION_API_ONLY: false + ENABLE_TESTABILITY: true + GENERATE_INFOPLIST_FILE: true + sources: + - path: ./ + excludes: + - ".swiftlint.yml" + dependencies: + - target: Horizon + - target: Core/TestsFoundation + - package: SnapshotTesting + - package: HorizonUI \ No newline at end of file diff --git a/Horizon/project.yml b/Horizon/project.yml index 4c8f7a0e06..7dc6c31dcb 100644 --- a/Horizon/project.yml +++ b/Horizon/project.yml @@ -23,6 +23,7 @@ sources: include: - Horizon/horizon.yml - HorizonUnitTests/horizon-unit-tests.yml + - HorizonSnapshotTests/horizon-snapshot-tests.yml projectReferences: Core: path: ../Core/Core.xcodeproj @@ -47,3 +48,6 @@ packages: FirebaseRemoteConfigSwift: url: https://github.com/firebase/firebase-ios-sdk.git exactVersion: 10.23.1 + SnapshotTesting: + url: https://github.com/pointfreeco/swift-snapshot-testing + from: 1.17.0 diff --git a/Student/Student/student.yml b/Student/Student/student.yml index c0933907b5..b8b1162f4b 100644 --- a/Student/Student/student.yml +++ b/Student/Student/student.yml @@ -22,6 +22,7 @@ targets: - package: FirebaseCrashlytics - package: FirebaseRemoteConfig - package: FirebaseRemoteConfigSwift + - package: HorizonUI - target: Core/Core - target: SubmitAssignment - target: Widgets @@ -53,6 +54,8 @@ schemes: targets: - Core/CoreTests - StudentUnitTests + - Horizon/HorizonUnitTests + - Horizon/HorizonSnapshotTests - name: Core/CoreTester skipped: true gatherCoverageData: true diff --git a/Student/project-ci.yml b/Student/project-ci.yml index a50be593a1..3e4129b62e 100644 --- a/Student/project-ci.yml +++ b/Student/project-ci.yml @@ -30,6 +30,11 @@ projectReferences: Horizon: path: ../Horizon/Horizon.xcodeproj packages: + # Local + HorizonUI: + path: ../packages/HorizonUI + + # Remote Pendo: url: https://github.com/pendo-io/pendo-mobile-sdk exactVersion: 3.7.5 diff --git a/Student/project.yml b/Student/project.yml index 9228afadf5..cba5e6604b 100644 --- a/Student/project.yml +++ b/Student/project.yml @@ -32,6 +32,11 @@ projectReferences: Horizon: path: ../Horizon/Horizon.xcodeproj packages: + # Local + HorizonUI: + path: ../packages/HorizonUI + + # Remote Pendo: url: https://github.com/pendo-io/pendo-mobile-sdk exactVersion: 3.7.5 diff --git a/TestPlans/CITests.xctestplan b/TestPlans/CITests.xctestplan index 5973968d79..758bd2fda7 100644 --- a/TestPlans/CITests.xctestplan +++ b/TestPlans/CITests.xctestplan @@ -84,6 +84,13 @@ "identifier" : "41BA2B211CB47478407A6101", "name" : "ParentUnitTests" } + }, + { + "target" : { + "containerPath" : "container:Horizon\/Horizon.xcodeproj", + "identifier" : "20252627727AC8643AB77E40", + "name" : "HorizonSnapshotTests" + } } ], "version" : 1