From e6b0d9087b18517e5d1d40bb49cd9e00a24dbe3b Mon Sep 17 00:00:00 2001 From: Waldemar Date: Mon, 25 May 2026 20:29:42 +0400 Subject: [PATCH 1/5] refactor(config): extract AuthConfiguration value type, standardize MARK in services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constants.swift moves into AuthConfiguration.swift and gains an AuthConfiguration struct with a .standard default. All services (OAuth2Service, ProfileService, ProfileImageService, ImagesListService) now read URLs/keys via AuthConfiguration.standard.* instead of the flat Constants enum. While here, group every service file with a consistent MARK structure — Properties / Initialization / Public Methods / Private Methods — so they read uniformly side by side. Same MARK polish for LogoutService and SingleImageViewController. --- ImageFeed/Helpers/AuthConfiguration.swift | 47 +++++++++++ ImageFeed/Helpers/Constants.swift | 9 -- .../ImagesList/ImagesListService.swift | 84 ++++++++++--------- ImageFeed/Services/OAuth/OAuth2Service.swift | 57 +++++++------ .../Services/Profile/LogoutService.swift | 22 +++-- .../Profile/ProfileImageService.swift | 30 ++++--- .../Services/Profile/ProfileService.swift | 27 +++--- .../SingleImageViewController.swift | 71 +++++++++------- 8 files changed, 215 insertions(+), 132 deletions(-) create mode 100644 ImageFeed/Helpers/AuthConfiguration.swift delete mode 100644 ImageFeed/Helpers/Constants.swift diff --git a/ImageFeed/Helpers/AuthConfiguration.swift b/ImageFeed/Helpers/AuthConfiguration.swift new file mode 100644 index 0000000..6eea84f --- /dev/null +++ b/ImageFeed/Helpers/AuthConfiguration.swift @@ -0,0 +1,47 @@ +enum Constants { + static let accessKey = "AD3mth0JjFP4_HSz59cknghLFx5MHnVAlQxEaCIbvwg" + static let secretKey = "4Hd6rAavk-ZRIp5jD81CaxQFaeBVkgvDriLU0VUEf90" + static let redirectURI = "urn:ietf:wg:oauth:2.0:oob" + static let accessScope = "public+read_user+write_likes" + static let unsplashAuthorizeURLString = "https://unsplash.com/oauth/authorize" + static let defaultBaseURLString = "https://api.unsplash.com" + static let photosPerPage = 10 +} + +struct AuthConfiguration { + static let standard = AuthConfiguration( + accessKey: Constants.accessKey, + secretKey: Constants.secretKey, + redirectURI: Constants.redirectURI, + accessScope: Constants.accessScope, + unsplashAuthorizeURLString: Constants.unsplashAuthorizeURLString, + defaultBaseURLString: Constants.defaultBaseURLString, + photosPerPage: Constants.photosPerPage + ) + + let accessKey: String + let secretKey: String + let redirectURI: String + let accessScope: String + let unsplashAuthorizeURLString: String + let defaultBaseURLString: String + let photosPerPage: Int + + init( + accessKey: String, + secretKey: String, + redirectURI: String, + accessScope: String, + unsplashAuthorizeURLString: String, + defaultBaseURLString: String, + photosPerPage: Int + ) { + self.accessKey = accessKey + self.secretKey = secretKey + self.redirectURI = redirectURI + self.accessScope = accessScope + self.unsplashAuthorizeURLString = unsplashAuthorizeURLString + self.defaultBaseURLString = defaultBaseURLString + self.photosPerPage = photosPerPage + } +} diff --git a/ImageFeed/Helpers/Constants.swift b/ImageFeed/Helpers/Constants.swift deleted file mode 100644 index 4b0a9ab..0000000 --- a/ImageFeed/Helpers/Constants.swift +++ /dev/null @@ -1,9 +0,0 @@ -enum Constants { - static let accessKey = "AD3mth0JjFP4_HSz59cknghLFx5MHnVAlQxEaCIbvwg" - static let secretKey = "4Hd6rAavk-ZRIp5jD81CaxQFaeBVkgvDriLU0VUEf90" - static let redirectURI = "urn:ietf:wg:oauth:2.0:oob" - static let accessScope = "public+read_user+write_likes" - static let unsplashAuthorizeURLString = "https://unsplash.com/oauth/authorize" - static let defaultBaseURLString = "https://api.unsplash.com" - static let photosPerPage = 10 -} diff --git a/ImageFeed/Services/ImagesList/ImagesListService.swift b/ImageFeed/Services/ImagesList/ImagesListService.swift index 4bbfaf9..411794c 100644 --- a/ImageFeed/Services/ImagesList/ImagesListService.swift +++ b/ImageFeed/Services/ImagesList/ImagesListService.swift @@ -2,56 +2,23 @@ import Foundation import Logging final class ImagesListService { + // MARK: - Properties + static let shared = ImagesListService() static let didChangeNotification = Notification.Name("ImagesListServiceDidChange") - private init() { - } - private let logger = Logger(label: "ImagesListService") - private var task: URLSessionTask? - private(set) var photos: [Photo] = [] + private var task: URLSessionTask? private var lastLoadedPage = 0 - private func makeImagesListRequest(token: String) -> URLRequest? { - let urlString = Constants.defaultBaseURLString + "/photos" - - guard var urlComponents = URLComponents(string: urlString) else { - logger.error("makeImagesListRequest: failed to create URLComponents from '\(urlString)'") - return nil - } - urlComponents.queryItems = [ - URLQueryItem(name: "page", value: "\(lastLoadedPage + 1)"), - URLQueryItem(name: "per_page", value: "\(Constants.photosPerPage)") - ] - - guard let url = urlComponents.url else { - logger.error("makeImagesListRequest: failed to build URL from URLComponents: \(urlComponents)") - return nil - } - var request = URLRequest(url: url) - request.httpMethod = HTTPMethod.get.rawValue - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - return request - } - - private func makeChangeLikeRequest(token: String, photoId: String, isLike: Bool) -> URLRequest? { - let urlString = Constants.defaultBaseURLString + "/photos/\(photoId)/like" - - guard let url = URL(string: urlString) else { - logger.error("makeChangeLikeRequest: failed to build URL from '\(urlString)'") - return nil - } + // MARK: - Initialization - var request = URLRequest(url: url) - request.httpMethod = isLike ? HTTPMethod.post.rawValue : HTTPMethod.delete.rawValue - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + private init() {} - return request - } + // MARK: - Public Methods func fetchPhotosNextPage() { assert(Thread.isMainThread) @@ -140,4 +107,43 @@ final class ImagesListService { photos = [] lastLoadedPage = 0 } + + // MARK: - Private Methods + + private func makeImagesListRequest(token: String) -> URLRequest? { + let urlString = AuthConfiguration.standard.defaultBaseURLString + "/photos" + + guard var urlComponents = URLComponents(string: urlString) else { + logger.error("makeImagesListRequest: failed to create URLComponents from '\(urlString)'") + return nil + } + urlComponents.queryItems = [ + URLQueryItem(name: "page", value: "\(lastLoadedPage + 1)"), + URLQueryItem(name: "per_page", value: "\(AuthConfiguration.standard.photosPerPage)") + ] + + guard let url = urlComponents.url else { + logger.error("makeImagesListRequest: failed to build URL from URLComponents: \(urlComponents)") + return nil + } + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.get.rawValue + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } + + private func makeChangeLikeRequest(token: String, photoId: String, isLike: Bool) -> URLRequest? { + let urlString = AuthConfiguration.standard.defaultBaseURLString + "/photos/\(photoId)/like" + + guard let url = URL(string: urlString) else { + logger.error("makeChangeLikeRequest: failed to build URL from '\(urlString)'") + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = isLike ? HTTPMethod.post.rawValue : HTTPMethod.delete.rawValue + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + return request + } } diff --git a/ImageFeed/Services/OAuth/OAuth2Service.swift b/ImageFeed/Services/OAuth/OAuth2Service.swift index 974df84..189916d 100644 --- a/ImageFeed/Services/OAuth/OAuth2Service.swift +++ b/ImageFeed/Services/OAuth/OAuth2Service.swift @@ -2,6 +2,8 @@ import Foundation import Logging final class OAuth2Service { + // MARK: - Properties + static let shared = OAuth2Service() private let logger = Logger(label: "OAuth2Service") @@ -9,33 +11,11 @@ final class OAuth2Service { private var task: URLSessionTask? private var lastCode: String? - private init() { - } + // MARK: - Initialization - private func makeOAuthTokenRequest(code: String) -> URLRequest? { - let urlString = "https://unsplash.com/oauth/token" - guard var urlComponents = URLComponents(string: urlString) else { - logger.error("makeOAuthTokenRequest: failed to create URLComponents from '\(urlString)'") - return nil - } - urlComponents.queryItems = [ - URLQueryItem(name: "client_id", value: Constants.accessKey), - URLQueryItem(name: "client_secret", value: Constants.secretKey), - URLQueryItem(name: "redirect_uri", value: Constants.redirectURI), - URLQueryItem(name: "code", value: code), - URLQueryItem(name: "grant_type", value: "authorization_code"), - ] + private init() {} - guard let url = urlComponents.url else { - logger.error("makeOAuthTokenRequest: failed to build URL from URLComponents: \(urlComponents)") - return nil - } - - var request = URLRequest(url: url) - request.httpMethod = HTTPMethod.post.rawValue - - return request - } + // MARK: - Public Methods func fetchOAuthToken( from code: String, @@ -78,4 +58,31 @@ final class OAuth2Service { self.task = task task?.resume() } + + // MARK: - Private Methods + + private func makeOAuthTokenRequest(code: String) -> URLRequest? { + let urlString = "https://unsplash.com/oauth/token" + guard var urlComponents = URLComponents(string: urlString) else { + logger.error("makeOAuthTokenRequest: failed to create URLComponents from '\(urlString)'") + return nil + } + urlComponents.queryItems = [ + URLQueryItem(name: "client_id", value: AuthConfiguration.standard.accessKey), + URLQueryItem(name: "client_secret", value: AuthConfiguration.standard.secretKey), + URLQueryItem(name: "redirect_uri", value: AuthConfiguration.standard.redirectURI), + URLQueryItem(name: "code", value: code), + URLQueryItem(name: "grant_type", value: "authorization_code"), + ] + + guard let url = urlComponents.url else { + logger.error("makeOAuthTokenRequest: failed to build URL from URLComponents: \(urlComponents)") + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.post.rawValue + + return request + } } diff --git a/ImageFeed/Services/Profile/LogoutService.swift b/ImageFeed/Services/Profile/LogoutService.swift index f8bf9e0..ea1a2b7 100644 --- a/ImageFeed/Services/Profile/LogoutService.swift +++ b/ImageFeed/Services/Profile/LogoutService.swift @@ -2,9 +2,15 @@ import UIKit import WebKit final class LogoutService { + // MARK: - Properties + static let shared = LogoutService() - private init() { } + // MARK: - Initialization + + private init() {} + + // MARK: - Public Methods func logout() { OAuth2TokenStorage.token = nil @@ -17,13 +23,15 @@ final class LogoutService { switchToSplashScreenController() } + // MARK: - Private Methods + private func cleanCookies() { - HTTPCookieStorage.shared.removeCookies(since: Date.distantPast) - WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in - records.forEach { record in - WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {}) - } - } + HTTPCookieStorage.shared.removeCookies(since: Date.distantPast) + WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in + records.forEach { record in + WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {}) + } + } } private func switchToSplashScreenController() { diff --git a/ImageFeed/Services/Profile/ProfileImageService.swift b/ImageFeed/Services/Profile/ProfileImageService.swift index b5e5d02..ab61c51 100644 --- a/ImageFeed/Services/Profile/ProfileImageService.swift +++ b/ImageFeed/Services/Profile/ProfileImageService.swift @@ -2,27 +2,22 @@ import Foundation import Logging final class ProfileImageService { + // MARK: - Properties + static let shared = ProfileImageService() static let didChangeNotification = Notification.Name("ProfileImageProviderDidChange") - private init() { - - } - private let logger = Logger(label: "ProfileImageService") private(set) var profileImageURL: String? private var task: URLSessionTask? - private func makeProfileImageRequest(token: String, username: String) -> URLRequest? { - let urlString = Constants.defaultBaseURLString + "/users/\(username)" - guard let url = URL(string: urlString) else { return nil } - var request = URLRequest(url: url) - request.httpMethod = HTTPMethod.get.rawValue - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - return request - } + // MARK: - Initialization + + private init() {} + + // MARK: - Public Methods func fetchProfileImageURL(username: String, _ completion: @escaping (Result) -> Void) { assert(Thread.isMainThread) @@ -71,4 +66,15 @@ final class ProfileImageService { task = nil profileImageURL = nil } + + // MARK: - Private Methods + + private func makeProfileImageRequest(token: String, username: String) -> URLRequest? { + let urlString = AuthConfiguration.standard.defaultBaseURLString + "/users/\(username)" + guard let url = URL(string: urlString) else { return nil } + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.get.rawValue + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request + } } diff --git a/ImageFeed/Services/Profile/ProfileService.swift b/ImageFeed/Services/Profile/ProfileService.swift index 6c1723e..67f9a64 100644 --- a/ImageFeed/Services/Profile/ProfileService.swift +++ b/ImageFeed/Services/Profile/ProfileService.swift @@ -2,9 +2,9 @@ import Foundation import Logging final class ProfileService { - static let shared = ProfileService() + // MARK: - Properties - private init() {} + static let shared = ProfileService() private let logger = Logger(label: "ProfileService") @@ -12,14 +12,11 @@ final class ProfileService { private var task: URLSessionTask? - private func makeProfileRequest(token: String) -> URLRequest? { - guard let url = URL(string: Constants.defaultBaseURLString + "/me") else { return nil } + // MARK: - Initialization - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = HTTPMethod.get.rawValue - urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - return urlRequest - } + private init() {} + + // MARK: - Public Methods func fetchProfile(completion: @escaping (Result) -> Void) { assert(Thread.isMainThread) @@ -72,5 +69,15 @@ final class ProfileService { task = nil profile = nil } -} + // MARK: - Private Methods + + private func makeProfileRequest(token: String) -> URLRequest? { + guard let url = URL(string: AuthConfiguration.standard.defaultBaseURLString + "/me") else { return nil } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = HTTPMethod.get.rawValue + urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return urlRequest + } +} diff --git a/ImageFeed/SingleImage/SingleImageViewController.swift b/ImageFeed/SingleImage/SingleImageViewController.swift index 686bf85..5f8b6b4 100644 --- a/ImageFeed/SingleImage/SingleImageViewController.swift +++ b/ImageFeed/SingleImage/SingleImageViewController.swift @@ -2,11 +2,17 @@ import UIKit import Kingfisher final class SingleImageViewController: UIViewController { + // MARK: - Outlets + @IBOutlet private weak var scrollView: UIScrollView? @IBOutlet private weak var imageView: UIImageView? + // MARK: - Properties + var imageUrl: URL? + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() @@ -16,6 +22,8 @@ final class SingleImageViewController: UIViewController { loadImage() } + // MARK: - Actions + @IBAction func didTapBackButton() { dismiss(animated: true) } @@ -30,36 +38,7 @@ final class SingleImageViewController: UIViewController { present(activityVC, animated: true) } - private func rescaleAndCenterImageInScrollView(image: UIImage) { - guard let scrollView else { return } - - view.layoutIfNeeded() - let visibleRectSize = scrollView.bounds.size - let imageSize = image.size - let hScale = visibleRectSize.width / imageSize.width - let vScale = visibleRectSize.height / imageSize.height - let scale = min(scrollView.maximumZoomScale, - max(scrollView.minimumZoomScale, min(hScale, vScale))) - scrollView.setZoomScale(scale, animated: false) - scrollView.layoutIfNeeded() - // scrollViewDidZoom вызвал centerImage() — позиционирование уже корректное - } - - private func centerImage() { - guard let scrollView else { return } - let boundsSize = scrollView.bounds.size - let contentSize = scrollView.contentSize - - let horizontalInset = max(0, (boundsSize.width - contentSize.width) / 2) - let verticalInset = max(0, (boundsSize.height - contentSize.height) / 2) - - scrollView.contentInset = UIEdgeInsets( - top: verticalInset, - left: horizontalInset, - bottom: verticalInset, - right: horizontalInset - ) - } + // MARK: - Private Methods private func loadImage() { guard let imageView else { return } @@ -89,8 +68,40 @@ final class SingleImageViewController: UIViewController { } } } + + private func rescaleAndCenterImageInScrollView(image: UIImage) { + guard let scrollView else { return } + + view.layoutIfNeeded() + let visibleRectSize = scrollView.bounds.size + let imageSize = image.size + let hScale = visibleRectSize.width / imageSize.width + let vScale = visibleRectSize.height / imageSize.height + let scale = min(scrollView.maximumZoomScale, + max(scrollView.minimumZoomScale, min(hScale, vScale))) + scrollView.setZoomScale(scale, animated: false) + scrollView.layoutIfNeeded() + } + + private func centerImage() { + guard let scrollView else { return } + let boundsSize = scrollView.bounds.size + let contentSize = scrollView.contentSize + + let horizontalInset = max(0, (boundsSize.width - contentSize.width) / 2) + let verticalInset = max(0, (boundsSize.height - contentSize.height) / 2) + + scrollView.contentInset = UIEdgeInsets( + top: verticalInset, + left: horizontalInset, + bottom: verticalInset, + right: horizontalInset + ) + } } +// MARK: - UIScrollViewDelegate + extension SingleImageViewController: UIScrollViewDelegate { func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView From f67722b731b3776c35566e5f13f3efe0e4c8b9ad Mon Sep 17 00:00:00 2001 From: Waldemar Date: Mon, 25 May 2026 20:30:03 +0400 Subject: [PATCH 2/5] refactor(mvp): adopt MVP across Auth, Profile, ImagesList with configure(_:) DI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce one presenter per screen and split protocols on both sides: - WebView: AuthHelper (URL/code logic) + WebViewPresenter + protocols. WebViewViewController shrinks to UI + KVO + segue plumbing; logger removed (duplicated URLSession+data and OAuth2Service). - AuthViewController: builds presenter once in prepare(for:) via configure(_:), drops the unused logger. - Profile: ProfilePresenter owns ProfileService / ProfileImageService / LogoutService and formats the display strings. ProfileViewController becomes a thin view conforming to ProfileViewControllerProtocol; placeholder texts removed (presenter populates immediately), avatar size centralized in a static constant, colors extracted as .subtitleGray / .logoutRed in UIColor+Extensions, logout button picks up an accessibility identifier for UI tests. - ImagesList: ImagesListPresenter owns photos array, pagination trigger and like toggling. ImagesListViewController forwards dataSource/delegate calls to the presenter and exposes setLoading / reloadRow / showLikeError / updateTableViewAnimated. TabBarController wires both Profile and ImagesList via the new configure(_:) entry point so the presenter↔view link can't be half-finished. View-side `presenter` is now a private IUO — fail-fast if anyone instantiates the VC without configure(_:). showAlert helper drops its now-unused parameter notes; the rest is just API renames flowing from MVP. --- ImageFeed/Auth/AuthHelper.swift | 61 +++++++ ImageFeed/Auth/AuthViewController.swift | 9 +- ImageFeed/Auth/WebViewPresenter.swift | 73 +++++++++ ImageFeed/Auth/WebViewViewController.swift | 89 +++++----- ImageFeed/Helpers/UIColor+Extensions.swift | 6 +- .../Helpers/UIViewController+Extensions.swift | 3 - .../ImagesList/ImagesListPresenter.swift | 76 +++++++++ .../ImagesList/ImagesListViewController.swift | 94 +++++------ ImageFeed/Profile/ProfilePresenter.swift | 82 ++++++++++ ImageFeed/Profile/ProfileViewController.swift | 153 ++++++++---------- ImageFeed/TabBar/TabBarController.swift | 9 +- 11 files changed, 459 insertions(+), 196 deletions(-) create mode 100644 ImageFeed/Auth/AuthHelper.swift create mode 100644 ImageFeed/Auth/WebViewPresenter.swift create mode 100644 ImageFeed/ImagesList/ImagesListPresenter.swift create mode 100644 ImageFeed/Profile/ProfilePresenter.swift diff --git a/ImageFeed/Auth/AuthHelper.swift b/ImageFeed/Auth/AuthHelper.swift new file mode 100644 index 0000000..11086f9 --- /dev/null +++ b/ImageFeed/Auth/AuthHelper.swift @@ -0,0 +1,61 @@ +import Foundation + +final class AuthHelper: AuthHelperProtocol { + // MARK: - Properties + + let configuration: AuthConfiguration + + // MARK: - Initialization + + init(configuration: AuthConfiguration = .standard) { + self.configuration = configuration + } + + // MARK: - AuthHelperProtocol + + func makeAuthRequest() -> URLRequest { + let url = makeAuthUrl() + + return URLRequest(url: url) + } + + func extractAuthCode(from url: URL) -> String? { + if let urlComponents = URLComponents(string: url.absoluteString), + urlComponents.path == "/oauth/authorize/native", + let items = urlComponents.queryItems, + let codeItem = items.first(where: { $0.name == "code" }) + { + return codeItem.value + } else { + return nil + } + } + + // MARK: - Internal Methods + + func makeAuthUrl() -> URL { + guard var urlComponents = URLComponents(string: configuration.unsplashAuthorizeURLString) else { + preconditionFailure("Invalid unsplashAuthorizeURLString: \(configuration.unsplashAuthorizeURLString)") + } + + urlComponents.queryItems = [ + URLQueryItem(name: "client_id", value: configuration.accessKey), + URLQueryItem(name: "redirect_uri", value: configuration.redirectURI), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: configuration.accessScope) + ] + + guard let url = urlComponents.url else { + preconditionFailure("Failed to build URL from components: \(urlComponents)") + } + + return url + } +} + +// MARK: - AuthHelperProtocol + +protocol AuthHelperProtocol { + func makeAuthRequest() -> URLRequest + func extractAuthCode(from url: URL) -> String? +} diff --git a/ImageFeed/Auth/AuthViewController.swift b/ImageFeed/Auth/AuthViewController.swift index 7a24fb4..860f2fc 100644 --- a/ImageFeed/Auth/AuthViewController.swift +++ b/ImageFeed/Auth/AuthViewController.swift @@ -1,19 +1,17 @@ import UIKit -import Logging final class AuthViewController: UIViewController { // MARK: - Properties - private let logger = Logger(label: "AuthViewController") - weak var delegate: AuthViewControllerDelegate? - // MARK: - Lifecycle + // MARK: - Navigation override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "ShowWebView", let webVC = segue.destination as? WebViewViewController { webVC.delegate = self + webVC.configure(WebViewPresenter(authHelper: AuthHelper())) } else { super.prepare(for: segue, sender: sender) } @@ -36,8 +34,7 @@ extension AuthViewController: WebViewViewControllerDelegate { switch result { case .success: self.delegate?.didAuthenticate(self) - case .failure(let error): - self.logger.error("fetchOAuthToken failed - \(error.localizedDescription)") + case .failure: showErrorAlert(message: "Не удалось войти в систему") } } diff --git a/ImageFeed/Auth/WebViewPresenter.swift b/ImageFeed/Auth/WebViewPresenter.swift new file mode 100644 index 0000000..ba059c0 --- /dev/null +++ b/ImageFeed/Auth/WebViewPresenter.swift @@ -0,0 +1,73 @@ +import Foundation + +final class WebViewPresenter: WebViewPresenterProtocol { + // MARK: - Properties + + weak var view: WebViewViewControllerProtocol? + + var authHelper: AuthHelperProtocol + + // MARK: - initializer + + init(authHelper: AuthHelperProtocol) { + self.authHelper = authHelper + } + + // MARK: - WebViewPresenterProtocol + + func viewDidLoad() { + let request = authHelper.makeAuthRequest() + view?.load(request: request) + + didUpdateProgressValue(0) + } + + func didUpdateProgressValue(_ newValue: Double) { + let newProgressValue = Float(newValue) + view?.setProgressValue(newProgressValue) + + let shouldHideProgress = shouldHideProgress(for: newProgressValue) + view?.setProgressHidden(shouldHideProgress) + } + + func extractAuthCode(from url: URL) -> String? { + authHelper.extractAuthCode(from: url) + } + + // MARK: - Internal Methods + + func shouldHideProgress(for value: Float) -> Bool { + abs(value - 1.0) <= 0.0001 + } + + // MARK: - Private Methods + + private func makeAuthRequest() -> URLRequest { + guard var urlComponents = URLComponents(string: AuthConfiguration.standard.unsplashAuthorizeURLString) else { + preconditionFailure("Invalid unsplashAuthorizeURLString: \(AuthConfiguration.standard.unsplashAuthorizeURLString)") + } + + urlComponents.queryItems = [ + URLQueryItem(name: "client_id", value: AuthConfiguration.standard.accessKey), + URLQueryItem(name: "redirect_uri", value: AuthConfiguration.standard.redirectURI), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: AuthConfiguration.standard.accessScope), + ] + + guard let url = urlComponents.url else { + preconditionFailure("Failed to build URL from components: \(urlComponents)") + } + + return URLRequest(url: url) + } +} + +// MARK: - WebViewPresenterProtocol + +protocol WebViewPresenterProtocol: AnyObject { + var view: WebViewViewControllerProtocol? { get set } + + func viewDidLoad() + func didUpdateProgressValue(_ newValue: Double) + func extractAuthCode(from url: URL) -> String? +} diff --git a/ImageFeed/Auth/WebViewViewController.swift b/ImageFeed/Auth/WebViewViewController.swift index 1133133..ad2a44a 100644 --- a/ImageFeed/Auth/WebViewViewController.swift +++ b/ImageFeed/Auth/WebViewViewController.swift @@ -1,9 +1,7 @@ import UIKit import WebKit -import Logging - -final class WebViewViewController: UIViewController { +final class WebViewViewController: UIViewController & WebViewViewControllerProtocol { // MARK: - Outlets @IBOutlet private weak var webView: WKWebView? @@ -11,57 +9,54 @@ final class WebViewViewController: UIViewController { // MARK: - Properties + private var presenter: WebViewPresenterProtocol! weak var delegate: WebViewViewControllerDelegate? - private let logger = Logger(label: "WebViewViewController") private var estimatedProgressObservation: NSKeyValueObservation? + // MARK: - Configuration + + func configure(_ presenter: WebViewPresenterProtocol) { + self.presenter = presenter + presenter.view = self + } + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() + webView?.accessibilityIdentifier = "UnsplashWebView" // для тестов webView?.navigationDelegate = self - estimatedProgressObservation = webView?.observe( - \.estimatedProgress, - options: [] - ) { [weak self] _, _ in - guard let self else { return } + setupProgressObservation() - self.updateProgress() - } - loadAuthView() + presenter.viewDidLoad() } - // MARK: - Private Methods - - private func loadAuthView() { - guard var urlComponents = URLComponents(string: Constants.unsplashAuthorizeURLString) else { - logger.error("loadAuthView: failed to create URLComponents from \(Constants.unsplashAuthorizeURLString)") - return - } + // MARK: - WebViewViewControllerProtocol - urlComponents.queryItems = [ - URLQueryItem(name: "client_id", value: Constants.accessKey), - URLQueryItem(name: "redirect_uri", value: Constants.redirectURI), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "scope", value: Constants.accessScope), - ] + func load(request: URLRequest) { + webView?.load(request) + } - guard let url = urlComponents.url else { - logger.error("loadAuthView: failed to build URL from URLComponents: \(urlComponents)") - return - } + func setProgressValue(_ newValue: Float) { + progressView?.progress = newValue + } - let request = URLRequest(url: url) - webView?.load(request) + func setProgressHidden(_ isHidden: Bool) { + progressView?.isHidden = isHidden } - private func updateProgress() { - guard let webView else { return } + // MARK: - Private Methods - progressView?.progress = Float(webView.estimatedProgress) - progressView?.isHidden = fabs(webView.estimatedProgress - 1.0) <= 0.0001 + private func setupProgressObservation() { + estimatedProgressObservation = webView?.observe( + \.estimatedProgress, + options: [] + ) { [weak self] _, _ in + guard let self else { return } + self.presenter.didUpdateProgressValue(self.webView?.estimatedProgress ?? 0) + } } } @@ -82,17 +77,9 @@ extension WebViewViewController: WKNavigationDelegate { } private func code(from navigationAction: WKNavigationAction) -> String? { - if - let url = navigationAction.request.url, - let urlComponents = URLComponents(string: url.absoluteString), - urlComponents.path == "/oauth/authorize/native", - let items = urlComponents.queryItems, - let codeItem = items.first(where: { $0.name == "code" }) - { - return codeItem.value - } else { - return nil - } + guard let url = navigationAction.request.url else { return nil } + + return presenter.extractAuthCode(from: url) } } @@ -103,3 +90,13 @@ protocol WebViewViewControllerDelegate: AnyObject { func webViewViewControllerDidCancel(_ vc: WebViewViewController) } + +// MARK: - WebViewViewControllerProtocol + +protocol WebViewViewControllerProtocol: AnyObject { + func load(request: URLRequest) + + func setProgressValue(_ newValue: Float) + + func setProgressHidden(_ isHidden: Bool) +} diff --git a/ImageFeed/Helpers/UIColor+Extensions.swift b/ImageFeed/Helpers/UIColor+Extensions.swift index 07a3a40..f4dde3f 100644 --- a/ImageFeed/Helpers/UIColor+Extensions.swift +++ b/ImageFeed/Helpers/UIColor+Extensions.swift @@ -1,6 +1,8 @@ import UIKit extension UIColor { - static let background = UIColor(red: 26/255, green: 27/255, blue: 34/255, alpha: 1) // #1A1B22 - static let backgroundTransparent = UIColor(red: 26/255, green: 27/255, blue: 34/255, alpha: 0) + static let background = UIColor(red: 26/255, green: 27/255, blue: 34/255, alpha: 1) + static let backgroundTransparent = UIColor(red: 26/255, green: 27/255, blue: 34/255, alpha: 0) + static let subtitleGray = UIColor(red: 174/255, green: 175/255, blue: 180/255, alpha: 1) + static let logoutRed = UIColor(red: 245/255, green: 107/255, blue: 108/255, alpha: 1) } diff --git a/ImageFeed/Helpers/UIViewController+Extensions.swift b/ImageFeed/Helpers/UIViewController+Extensions.swift index 1ea261c..d819a9a 100644 --- a/ImageFeed/Helpers/UIViewController+Extensions.swift +++ b/ImageFeed/Helpers/UIViewController+Extensions.swift @@ -1,13 +1,10 @@ import UIKit extension UIViewController { - /// Алерт ошибки с типовым заголовком и одной кнопкой «ОК». func showErrorAlert(message: String) { showAlert(title: "Что-то пошло не так(", message: message) } - /// Универсальный алерт. По умолчанию показывает одну кнопку «ОК»; - /// для confirmation-диалогов передайте свой набор UIAlertAction. func showAlert( title: String, message: String, diff --git a/ImageFeed/ImagesList/ImagesListPresenter.swift b/ImageFeed/ImagesList/ImagesListPresenter.swift new file mode 100644 index 0000000..cbe37ff --- /dev/null +++ b/ImageFeed/ImagesList/ImagesListPresenter.swift @@ -0,0 +1,76 @@ +import Foundation + +final class ImagesListPresenter: ImagesListPresenterProtocol { + // MARK: - Properties + + weak var view: ImagesListViewControllerProtocol? + + private(set) var photos: [Photo] = [] + private var imagesListServiceObserver: NSObjectProtocol? + + // MARK: - ImagesListPresenterProtocol + + func viewDidLoad() { + imagesListServiceObserver = NotificationCenter.default.addObserver( + forName: ImagesListService.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + + let oldCount = self.photos.count + self.photos = ImagesListService.shared.photos + self.view?.updateTableViewAnimated(oldCount: oldCount, newCount: self.photos.count) + } + + ImagesListService.shared.fetchPhotosNextPage() + } + + func photo(at indexPath: IndexPath) -> Photo { + photos[indexPath.row] + } + + func willDisplayRow(at indexPath: IndexPath) { + if indexPath.row == photos.count - 1 { + ImagesListService.shared.fetchPhotosNextPage() + } + } + + func didTapLike(at indexPath: IndexPath) { + let photo = photos[indexPath.row] + view?.setLoading(true) + ImagesListService.shared.changeLike(photoId: photo.id, isLike: !photo.isLiked) { [weak self] result in + guard let self else { return } + self.view?.setLoading(false) + + switch result { + case .success: + self.photos = ImagesListService.shared.photos + self.view?.reloadRow(at: indexPath) + case .failure: + self.view?.showLikeError() + } + } + } +} + +// MARK: - ImagesListViewControllerProtocol + +protocol ImagesListViewControllerProtocol: AnyObject { + func updateTableViewAnimated(oldCount: Int, newCount: Int) + func reloadRow(at indexPath: IndexPath) + func setLoading(_ isLoading: Bool) + func showLikeError() +} + +// MARK: - ImagesListPresenterProtocol + +protocol ImagesListPresenterProtocol: AnyObject { + var view: ImagesListViewControllerProtocol? { get set } + var photos: [Photo] { get } + + func viewDidLoad() + func photo(at indexPath: IndexPath) -> Photo + func willDisplayRow(at indexPath: IndexPath) + func didTapLike(at indexPath: IndexPath) +} diff --git a/ImageFeed/ImagesList/ImagesListViewController.swift b/ImageFeed/ImagesList/ImagesListViewController.swift index 1e69e29..278918e 100644 --- a/ImageFeed/ImagesList/ImagesListViewController.swift +++ b/ImageFeed/ImagesList/ImagesListViewController.swift @@ -1,42 +1,35 @@ import UIKit -import Logging -final class ImagesListViewController: UIViewController { +final class ImagesListViewController: UIViewController, ImagesListViewControllerProtocol { // MARK: - Outlets @IBOutlet private weak var tableView: UITableView? + // MARK: - Properties + + private var presenter: ImagesListPresenterProtocol! + // MARK: - Private Properties private static let showSingleImageSegueId = "ShowSingleImage" - private var imagesListServiceObserver: NSObjectProtocol? + // MARK: - Configuration - private var photos: [Photo] = [] + func configure(_ presenter: ImagesListPresenterProtocol) { + self.presenter = presenter + presenter.view = self + } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() - - imagesListServiceObserver = NotificationCenter.default.addObserver( - forName: ImagesListService.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - - let oldCount = self.photos.count - self.photos = ImagesListService.shared.photos - self.updateTableViewAnimated(oldCount: oldCount, newCount: self.photos.count) - } - - ImagesListService.shared.fetchPhotosNextPage() + presenter.viewDidLoad() } - // MARK: - Private Methods + // MARK: - ImagesListViewControllerProtocol - private func updateTableViewAnimated(oldCount: Int, newCount: Int) { + func updateTableViewAnimated(oldCount: Int, newCount: Int) { guard let tableView, newCount > oldCount else { return } tableView.performBatchUpdates { @@ -45,14 +38,30 @@ final class ImagesListViewController: UIViewController { } } + func reloadRow(at indexPath: IndexPath) { + tableView?.reloadRows(at: [indexPath], with: .none) + } + + func setLoading(_ isLoading: Bool) { + if isLoading { + UIBlockingProgressHUD.show() + } else { + UIBlockingProgressHUD.dismiss() + } + } + + func showLikeError() { + showErrorAlert(message: "Не удалось обновить лайк") + } + + // MARK: - Navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == Self.showSingleImageSegueId, let singleImageVC = segue.destination as? SingleImageViewController, - let cell = sender as? ImagesListCell { - - guard let indexPath = tableView?.indexPath(for: cell) else { return } - let photo = photos[indexPath.row] - singleImageVC.imageUrl = photo.largeImageURL + let cell = sender as? ImagesListCell, + let indexPath = tableView?.indexPath(for: cell) { + singleImageVC.imageUrl = presenter.photo(at: indexPath).largeImageURL } else { super.prepare(for: segue, sender: sender) } @@ -63,7 +72,7 @@ final class ImagesListViewController: UIViewController { extension ImagesListViewController { func configCell(for cell: ImagesListCell, with indexPath: IndexPath) { - let photo = photos[indexPath.row] + let photo = presenter.photo(at: indexPath) cell.setImage(photo.regularImageURL) cell.setDate(photo.createdAt) cell.setIsLiked(photo.isLiked) @@ -74,7 +83,7 @@ extension ImagesListViewController { extension ImagesListViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - photos.count + presenter.photos.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -92,46 +101,27 @@ extension ImagesListViewController: UITableViewDataSource { extension ImagesListViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if indexPath.row == photos.count - 1 { - ImagesListService.shared.fetchPhotosNextPage() - } + presenter.willDisplayRow(at: indexPath) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let photo = photos[indexPath.row] + let photo = presenter.photo(at: indexPath) let imageWidth = photo.size.width let imageHeight = photo.size.height let tableWidth = tableView.bounds.width - guard imageWidth > 0 else { - return 0 - } + guard imageWidth > 0 else { return 0 } - let scaledHeight = imageHeight * (tableWidth / imageWidth) - - return scaledHeight + return imageHeight * (tableWidth / imageWidth) } } // MARK: - ImagesListCellDelegate + extension ImagesListViewController: ImagesListCellDelegate { func imagesListCellDidTapLike(_ cell: ImagesListCell) { guard let indexPath = tableView?.indexPath(for: cell) else { return } - let photo = photos[indexPath.row] - UIBlockingProgressHUD.show() - ImagesListService.shared.changeLike(photoId: photo.id, isLike: !photo.isLiked) { [weak self] result in - UIBlockingProgressHUD.dismiss() - - guard let self else { return } - - switch result { - case .success: - self.photos = ImagesListService.shared.photos - self.tableView?.reloadRows(at: [indexPath], with: .none) - case .failure: - self.showErrorAlert(message: "Не удалось обновить лайк") - } - } + presenter.didTapLike(at: indexPath) } } diff --git a/ImageFeed/Profile/ProfilePresenter.swift b/ImageFeed/Profile/ProfilePresenter.swift new file mode 100644 index 0000000..2a42d6c --- /dev/null +++ b/ImageFeed/Profile/ProfilePresenter.swift @@ -0,0 +1,82 @@ +import Foundation +import Logging + +final class ProfilePresenter: ProfilePresenterProtocol { + // MARK: - Properties + + weak var view: ProfileViewControllerProtocol? + + private let logger = Logger(label: "ProfilePresenter") + private var profileImageServiceObserver: NSObjectProtocol? + + // MARK: - ProfilePresenterProtocol + + func viewDidLoad() { + if let profile = ProfileService.shared.profile { + renderProfile(profile) + } + + profileImageServiceObserver = NotificationCenter.default.addObserver( + forName: ProfileImageService.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateAvatar() + } + updateAvatar() + } + + func didTapLogoutButton() { + view?.showLogoutConfirmation() + } + + func didConfirmLogout() { + LogoutService.shared.logout() + } + + // MARK: - Private Methods + + private func renderProfile(_ profile: Profile) { + let name = profile.name.isEmpty + ? "Имя не указано" + : profile.name + let loginName = profile.loginName.isEmpty + ? "@неизвестный_пользователь" + : "@\(profile.loginName)" + let bio = profile.bio.isEmpty + ? "Профиль не заполнен" + : profile.bio + view?.updateProfileDetails(name: name, loginName: loginName, bio: bio) + } + + private func updateAvatar() { + guard let profileImageURL = ProfileImageService.shared.profileImageURL else { + logger.debug("updateAvatar: profileImageURL is nil, skipping") + view?.updateAvatar(url: nil) + return + } + guard let url = URL(string: profileImageURL) else { + logger.error("updateAvatar: failed to build URL from '\(profileImageURL)'") + return + } + view?.updateAvatar(url: url) + } +} + +// MARK: - ProfileViewControllerProtocol + +protocol ProfileViewControllerProtocol: AnyObject { + func updateProfileDetails(name: String, loginName: String, bio: String) + func updateAvatar(url: URL?) + func showLogoutConfirmation() +} + +// MARK: - ProfilePresenterProtocol + +protocol ProfilePresenterProtocol: AnyObject { + var view: ProfileViewControllerProtocol? { get set } + + func viewDidLoad() + func didTapLogoutButton() + func didConfirmLogout() +} diff --git a/ImageFeed/Profile/ProfileViewController.swift b/ImageFeed/Profile/ProfileViewController.swift index 3d56b5a..6876e9f 100644 --- a/ImageFeed/Profile/ProfileViewController.swift +++ b/ImageFeed/Profile/ProfileViewController.swift @@ -2,51 +2,63 @@ import UIKit import Logging import Kingfisher -final class ProfileViewController: UIViewController { +final class ProfileViewController: UIViewController, ProfileViewControllerProtocol { + // MARK: - Properties - // MARK: - Private Properties + private var presenter: ProfilePresenterProtocol! private let logger = Logger(label: "ProfileViewController") - private var profileImageServiceObserver: NSObjectProtocol? + + private static let avatarSize: CGFloat = 70 + private static let placeholderAvatar = UIImage(resource: .mockProfileAvatar) + + // MARK: - Configuration + + func configure(_ presenter: ProfilePresenterProtocol) { + self.presenter = presenter + presenter.view = self + } // MARK: - UI Elements private lazy var profileImageView: UIImageView = { - let profileImage = UIImage(resource: .mockProfileAvatar) - let profileImageView = UIImageView(image: profileImage) - return profileImageView + let view = UIImageView(image: Self.placeholderAvatar) + view.translatesAutoresizingMaskIntoConstraints = false + return view }() - private lazy var displayNameLabel: UILabel = { - let displayNameLabel = UILabel() - displayNameLabel.text = "Екатерина Новикова" - displayNameLabel.font = UIFont.systemFont(ofSize: 23, weight: .bold) - displayNameLabel.textColor = .white - return displayNameLabel + private lazy var nameLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 23, weight: .bold) + label.textColor = .white + label.translatesAutoresizingMaskIntoConstraints = false + return label }() private lazy var loginNameLabel: UILabel = { - let loginNameLabel = UILabel() - loginNameLabel.text = "@ekaterina_nov" - loginNameLabel.font = UIFont.systemFont(ofSize: 13) - loginNameLabel.textColor = UIColor(red: 174/255.0, green: 175/255.0, blue: 180/255.0, alpha: 1.0) - return loginNameLabel + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13) + label.textColor = .subtitleGray + label.translatesAutoresizingMaskIntoConstraints = false + return label }() private lazy var bioLabel: UILabel = { - let bioLabel = UILabel() - bioLabel.text = "Hello, world!" - bioLabel.font = UIFont.systemFont(ofSize: 13) - bioLabel.textColor = .white - return bioLabel + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 13) + label.textColor = .white + label.translatesAutoresizingMaskIntoConstraints = false + return label }() private lazy var logoutButton: UIButton = { let logoutButton = UIButton() logoutButton.setImage(.logout, for: .normal) - logoutButton.tintColor = UIColor(red: 0.96, green: 0.42, blue: 0.42, alpha: 1.0) + logoutButton.tintColor = .logoutRed + logoutButton.accessibilityIdentifier = "Logout button" + logoutButton.translatesAutoresizingMaskIntoConstraints = false logoutButton.addAction( - UIAction { [weak self] _ in self?.didTapLogoutButton() }, + UIAction { [weak self] _ in self?.presenter.didTapLogoutButton() }, for: .touchUpInside ) return logoutButton @@ -59,100 +71,69 @@ final class ProfileViewController: UIViewController { setupViews() setupConstraints() - guard let profile = ProfileService.shared.profile else { return } - updateProfileDetails(profile: profile) - - profileImageServiceObserver = NotificationCenter.default.addObserver( - forName: ProfileImageService.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - self.updateProfileImage() - } - updateProfileImage() + presenter.viewDidLoad() } - // MARK: - Actions - - private func didTapLogoutButton() { - showAlert( - title: "Пока, пока!", - message: "Уверены, что хотите выйти?", - actions: [ - UIAlertAction(title: "Да", style: .default) { _ in - LogoutService.shared.logout() - }, - UIAlertAction(title: "Нет", style: .cancel), - ] - ) - } + // MARK: - ProfileViewControllerProtocol - // MARK: - Private Methods - - private func updateProfileDetails(profile: Profile) { - displayNameLabel.text = profile.name.isEmpty - ? "Имя не указано" - : profile.name - loginNameLabel.text = profile.loginName.isEmpty - ? "@неизвестный_пользователь" - : "@\(profile.loginName)" - bioLabel.text = profile.bio.isEmpty - ? "Профиль не заполнен" - : profile.bio + func updateProfileDetails(name: String, loginName: String, bio: String) { + nameLabel.text = name + loginNameLabel.text = loginName + bioLabel.text = bio } - private func updateProfileImage() { - guard let profileImageURL = ProfileImageService.shared.profileImageURL else { - logger.debug("updateProfileImage: profileImageURL is nil, skipping") - return - } - guard let url = URL(string: profileImageURL) else { - logger.error("updateProfileImage: failed to build URL from '\(profileImageURL)'") + func updateAvatar(url: URL?) { + guard let url else { + profileImageView.image = Self.placeholderAvatar return } - - logger.info("updateProfileImage: loading avatar from \(url.absoluteString)") - profileImageView.kf.indicatorType = .activity profileImageView.kf.setImage( with: url, - options: [.processor(RoundCornerImageProcessor(cornerRadius: 35))] // 35, потому что длина/ширина по Фигме = 70/70 + options: [.processor(RoundCornerImageProcessor(cornerRadius: Self.avatarSize / 2))] ) { [weak self] result in - guard let self else { return } - switch result { - case .success(let value): - self.logger.debug("updateProfileImage: avatar loaded, source: \(value.cacheType)") - case .failure(let error): - self.logger.error("updateProfileImage: avatar load failed - \(error.localizedDescription)") + if case .failure(let error) = result { + self?.logger.error("avatar load failed - \(error.localizedDescription)") } } } + func showLogoutConfirmation() { + showAlert( + title: "Пока, пока!", + message: "Уверены, что хотите выйти?", + actions: [ + UIAlertAction(title: "Да", style: .default) { [weak self] _ in + self?.presenter.didConfirmLogout() + }, + UIAlertAction(title: "Нет", style: .cancel), + ] + ) + } + // MARK: - Setup UI private func setupViews() { view.backgroundColor = .background view.addSubview(profileImageView) - view.addSubview(displayNameLabel) + view.addSubview(nameLabel) view.addSubview(loginNameLabel) view.addSubview(bioLabel) view.addSubview(logoutButton) } private func setupConstraints() { - view.subviews.forEach { $0.translatesAutoresizingMaskIntoConstraints = false } NSLayoutConstraint.activate([ - profileImageView.widthAnchor.constraint(equalToConstant: 70), - profileImageView.heightAnchor.constraint(equalToConstant: 70), + profileImageView.widthAnchor.constraint(equalToConstant: Self.avatarSize), + profileImageView.heightAnchor.constraint(equalToConstant: Self.avatarSize), profileImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 32), profileImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - displayNameLabel.leadingAnchor.constraint(equalTo: profileImageView.leadingAnchor), - displayNameLabel.topAnchor.constraint(equalTo: profileImageView.bottomAnchor, constant: 8), + nameLabel.leadingAnchor.constraint(equalTo: profileImageView.leadingAnchor), + nameLabel.topAnchor.constraint(equalTo: profileImageView.bottomAnchor, constant: 8), loginNameLabel.leadingAnchor.constraint(equalTo: profileImageView.leadingAnchor), - loginNameLabel.topAnchor.constraint(equalTo: displayNameLabel.bottomAnchor, constant: 8), + loginNameLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 8), bioLabel.leadingAnchor.constraint(equalTo: profileImageView.leadingAnchor), bioLabel.topAnchor.constraint(equalTo: loginNameLabel.bottomAnchor, constant: 8), diff --git a/ImageFeed/TabBar/TabBarController.swift b/ImageFeed/TabBar/TabBarController.swift index 3c7f96f..13fc53c 100644 --- a/ImageFeed/TabBar/TabBarController.swift +++ b/ImageFeed/TabBar/TabBarController.swift @@ -5,7 +5,13 @@ final class TabBarController: UITabBarController { super.awakeFromNib() let storyboard = UIStoryboard(name: "Main", bundle: .main) - let imagesListViewController = storyboard.instantiateViewController(withIdentifier: "ImagesListViewController") + + guard let imagesListViewController = storyboard.instantiateViewController( + withIdentifier: "ImagesListViewController" + ) as? ImagesListViewController else { + preconditionFailure("Unable to instantiate ImagesListViewController from storyboard") + } + imagesListViewController.configure(ImagesListPresenter()) imagesListViewController.tabBarItem = UITabBarItem( title: "", image: UIImage(resource: .tabEditorialActive), @@ -13,6 +19,7 @@ final class TabBarController: UITabBarController { ) let profileViewController = ProfileViewController() + profileViewController.configure(ProfilePresenter()) profileViewController.tabBarItem = UITabBarItem( title: "", image: UIImage(resource: .tabProfileActive), From e32dec53cbc0f8c0b075efb13a68212903098382 Mon Sep 17 00:00:00 2001 From: Waldemar Date: Mon, 25 May 2026 20:30:18 +0400 Subject: [PATCH 3/5] test: unit tests for WebView, Profile and ImagesList MVP Wire up two new targets in the project (ImageFeedTests and ImageFeedUITests as PBXFileSystemSynchronizedRootGroups so new files in the test folders auto-attach without manual pbxproj edits) and add the first round of unit tests for the three MVP modules: - WebView: testViewControllerCallsViewDidLoad, testPresenterCallsLoadRequest, testProgressVisibleWhenLessThenOne, testProgressHiddenWhenOne, testAuthHelperAuthURL, testCodeFromURL. - Profile: testViewControllerCallsViewDidLoad, testPresenterCallsUpdateAvatarOnViewDidLoad, testPresenterShowsLogoutConfirmation. - ImagesList: testViewControllerCallsViewDidLoad, testNumberOfRowsReflectsPresenterPhotos, testWillDisplayIsForwardedToPresenter. Tests live in feature folders (WebView/, Profile/, ImagesList/) so a test sits next to its spies and stays clear of unrelated screens. Each ViewControllerSpy / PresenterSpy implements the matching MVP protocol and exposes only the flags the tests assert on. --- ImageFeed.xcodeproj/project.pbxproj | 246 +++++++++++++++++- .../ImagesList/ImagesListPresenterSpy.swift | 32 +++ .../ImagesList/ImagesListTests.swift | 69 +++++ .../ImagesListViewControllerSpy.swift | 36 +++ .../Profile/ProfilePresenterSpy.swift | 22 ++ ImageFeedTests/Profile/ProfileTests.swift | 43 +++ .../Profile/ProfileViewControllerSpy.swift | 30 +++ .../WebView/WebViewPresenterSpy.swift | 18 ++ ImageFeedTests/WebView/WebViewTests.swift | 94 +++++++ .../WebView/WebViewViewControllerSpy.swift | 15 ++ 10 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 ImageFeedTests/ImagesList/ImagesListPresenterSpy.swift create mode 100644 ImageFeedTests/ImagesList/ImagesListTests.swift create mode 100644 ImageFeedTests/ImagesList/ImagesListViewControllerSpy.swift create mode 100644 ImageFeedTests/Profile/ProfilePresenterSpy.swift create mode 100644 ImageFeedTests/Profile/ProfileTests.swift create mode 100644 ImageFeedTests/Profile/ProfileViewControllerSpy.swift create mode 100644 ImageFeedTests/WebView/WebViewPresenterSpy.swift create mode 100644 ImageFeedTests/WebView/WebViewTests.swift create mode 100644 ImageFeedTests/WebView/WebViewViewControllerSpy.swift diff --git a/ImageFeed.xcodeproj/project.pbxproj b/ImageFeed.xcodeproj/project.pbxproj index 9847820..1ae1d13 100644 --- a/ImageFeed.xcodeproj/project.pbxproj +++ b/ImageFeed.xcodeproj/project.pbxproj @@ -15,8 +15,27 @@ E7F6CAE02F6D92BA00CECCE0 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E7CA17482F6D8ECA00AFB99C /* WebKit.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + E74DEF602FC3AB7900625DAE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E75C1E3D2EB7AD1400947D37 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E75C1E442EB7AD1400947D37; + remoteInfo = ImageFeed; + }; + E7BAF71B2FC389A2009E0D80 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E75C1E3D2EB7AD1400947D37 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E75C1E442EB7AD1400947D37; + remoteInfo = ImageFeed; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + E74DEF5A2FC3AB7900625DAE /* ImageFeedUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ImageFeedUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E79FB9AC2EBA21BC00E4E049 /* ImageFeed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ImageFeed.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E7BAF7172FC389A2009E0D80 /* ImageFeedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ImageFeedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E7CA17482F6D8ECA00AFB99C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -31,6 +50,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + E74DEF5B2FC3AB7900625DAE /* ImageFeedUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ImageFeedUITests; + sourceTree = ""; + }; E75C1E822EB8425E00947D37 /* ImageFeed */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -39,9 +63,21 @@ path = ImageFeed; sourceTree = ""; }; + E7BAF7182FC389A2009E0D80 /* ImageFeedTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ImageFeedTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + E74DEF572FC3AB7900625DAE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E75C1E422EB7AD1400947D37 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -55,6 +91,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E7BAF7142FC389A2009E0D80 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -63,7 +106,11 @@ children = ( E75C1E822EB8425E00947D37 /* ImageFeed */, E79FB9AC2EBA21BC00E4E049 /* ImageFeed.app */, + E7BAF7182FC389A2009E0D80 /* ImageFeedTests */, + E74DEF5B2FC3AB7900625DAE /* ImageFeedUITests */, E7CA17472F6D8ECA00AFB99C /* Frameworks */, + E7BAF7172FC389A2009E0D80 /* ImageFeedTests.xctest */, + E74DEF5A2FC3AB7900625DAE /* ImageFeedUITests.xctest */, ); sourceTree = ""; }; @@ -78,6 +125,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + E74DEF592FC3AB7900625DAE /* ImageFeedUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E74DEF642FC3AB7900625DAE /* Build configuration list for PBXNativeTarget "ImageFeedUITests" */; + buildPhases = ( + E74DEF562FC3AB7900625DAE /* Sources */, + E74DEF572FC3AB7900625DAE /* Frameworks */, + E74DEF582FC3AB7900625DAE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E74DEF612FC3AB7900625DAE /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E74DEF5B2FC3AB7900625DAE /* ImageFeedUITests */, + ); + name = ImageFeedUITests; + packageProductDependencies = ( + ); + productName = ImageFeedUITests; + productReference = E74DEF5A2FC3AB7900625DAE /* ImageFeedUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E75C1E442EB7AD1400947D37 /* ImageFeed */ = { isa = PBXNativeTarget; buildConfigurationList = E75C1E582EB7AD1600947D37 /* Build configuration list for PBXNativeTarget "ImageFeed" */; @@ -105,6 +175,29 @@ productReference = E79FB9AC2EBA21BC00E4E049 /* ImageFeed.app */; productType = "com.apple.product-type.application"; }; + E7BAF7162FC389A2009E0D80 /* ImageFeedTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E7BAF71D2FC389A2009E0D80 /* Build configuration list for PBXNativeTarget "ImageFeedTests" */; + buildPhases = ( + E7BAF7132FC389A2009E0D80 /* Sources */, + E7BAF7142FC389A2009E0D80 /* Frameworks */, + E7BAF7152FC389A2009E0D80 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E7BAF71C2FC389A2009E0D80 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E7BAF7182FC389A2009E0D80 /* ImageFeedTests */, + ); + name = ImageFeedTests; + packageProductDependencies = ( + ); + productName = ImageFeedTests; + productReference = E7BAF7172FC389A2009E0D80 /* ImageFeedTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -112,12 +205,20 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 1640; TargetAttributes = { + E74DEF592FC3AB7900625DAE = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = E75C1E442EB7AD1400947D37; + }; E75C1E442EB7AD1400947D37 = { CreatedOnToolsVersion = 16.4; }; + E7BAF7162FC389A2009E0D80 = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = E75C1E442EB7AD1400947D37; + }; }; }; buildConfigurationList = E75C1E402EB7AD1400947D37 /* Build configuration list for PBXProject "ImageFeed" */; @@ -141,11 +242,20 @@ projectRoot = ""; targets = ( E75C1E442EB7AD1400947D37 /* ImageFeed */, + E7BAF7162FC389A2009E0D80 /* ImageFeedTests */, + E74DEF592FC3AB7900625DAE /* ImageFeedUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + E74DEF582FC3AB7900625DAE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E75C1E432EB7AD1400947D37 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -153,9 +263,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E7BAF7152FC389A2009E0D80 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + E74DEF562FC3AB7900625DAE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E75C1E412EB7AD1400947D37 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -163,9 +287,69 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E7BAF7132FC389A2009E0D80 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + E74DEF612FC3AB7900625DAE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E75C1E442EB7AD1400947D37 /* ImageFeed */; + targetProxy = E74DEF602FC3AB7900625DAE /* PBXContainerItemProxy */; + }; + E7BAF71C2FC389A2009E0D80 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E75C1E442EB7AD1400947D37 /* ImageFeed */; + targetProxy = E7BAF71B2FC389A2009E0D80 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + E74DEF622FC3AB7900625DAE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pilaabo.ImageFeedUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ImageFeed; + }; + name = Debug; + }; + E74DEF632FC3AB7900625DAE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pilaabo.ImageFeedUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ImageFeed; + }; + name = Release; + }; E75C1E592EB7AD1600947D37 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -345,9 +529,60 @@ }; name = Release; }; + E7BAF71E2FC389A2009E0D80 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pilaabo.ImageFeedTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ImageFeed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ImageFeed"; + }; + name = Debug; + }; + E7BAF71F2FC389A2009E0D80 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.pilaabo.ImageFeedTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ImageFeed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ImageFeed"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + E74DEF642FC3AB7900625DAE /* Build configuration list for PBXNativeTarget "ImageFeedUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E74DEF622FC3AB7900625DAE /* Debug */, + E74DEF632FC3AB7900625DAE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; E75C1E402EB7AD1400947D37 /* Build configuration list for PBXProject "ImageFeed" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -366,6 +601,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E7BAF71D2FC389A2009E0D80 /* Build configuration list for PBXNativeTarget "ImageFeedTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E7BAF71E2FC389A2009E0D80 /* Debug */, + E7BAF71F2FC389A2009E0D80 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/ImageFeedTests/ImagesList/ImagesListPresenterSpy.swift b/ImageFeedTests/ImagesList/ImagesListPresenterSpy.swift new file mode 100644 index 0000000..3f9a8b1 --- /dev/null +++ b/ImageFeedTests/ImagesList/ImagesListPresenterSpy.swift @@ -0,0 +1,32 @@ +import Foundation +@testable import ImageFeed +internal import UIKit + +final class ImagesListPresenterSpy: ImagesListPresenterProtocol { + var view: ImagesListViewControllerProtocol? + var photos: [Photo] = [] + + var viewDidLoadCalled = false + var willDisplayRowCalled = false + var lastWillDisplayIndexPath: IndexPath? + var didTapLikeCalled = false + var lastLikeIndexPath: IndexPath? + + func viewDidLoad() { + viewDidLoadCalled = true + } + + func photo(at indexPath: IndexPath) -> Photo { + photos[indexPath.row] + } + + func willDisplayRow(at indexPath: IndexPath) { + willDisplayRowCalled = true + lastWillDisplayIndexPath = indexPath + } + + func didTapLike(at indexPath: IndexPath) { + didTapLikeCalled = true + lastLikeIndexPath = indexPath + } +} diff --git a/ImageFeedTests/ImagesList/ImagesListTests.swift b/ImageFeedTests/ImagesList/ImagesListTests.swift new file mode 100644 index 0000000..3b254d6 --- /dev/null +++ b/ImageFeedTests/ImagesList/ImagesListTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import ImageFeed + +final class ImagesListTests: XCTestCase { + private static let samplePhoto = Photo( + id: "test", + size: CGSize(width: 100, height: 100), + createdAt: nil, + description: nil, + regularImageURL: URL(string: "https://unsplash.com")!, + largeImageURL: URL(string: "https://unsplash.com")!, + isLiked: false + ) + + func testViewControllerCallsViewDidLoad() { + // Arrange + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let viewController = storyboard.instantiateViewController( + withIdentifier: "ImagesListViewController" + ) as! ImagesListViewController + let presenter = ImagesListPresenterSpy() + viewController.configure(presenter) + + // Act + _ = viewController.view + + // Assert + XCTAssertTrue(presenter.viewDidLoadCalled) + } + + func testNumberOfRowsReflectsPresenterPhotos() { + // Arrange + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let viewController = storyboard.instantiateViewController( + withIdentifier: "ImagesListViewController" + ) as! ImagesListViewController + let presenter = ImagesListPresenterSpy() + presenter.photos = Array(repeating: Self.samplePhoto, count: 5) + viewController.configure(presenter) + + // Act + let rows = viewController.tableView(UITableView(), numberOfRowsInSection: 0) + + // Assert + XCTAssertEqual(rows, 5) + } + + func testWillDisplayIsForwardedToPresenter() { + // Arrange + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let viewController = storyboard.instantiateViewController( + withIdentifier: "ImagesListViewController" + ) as! ImagesListViewController + let presenter = ImagesListPresenterSpy() + presenter.photos = [Self.samplePhoto, Self.samplePhoto] + viewController.configure(presenter) + + // Act + viewController.tableView( + UITableView(), + willDisplay: UITableViewCell(), + forRowAt: IndexPath(row: 1, section: 0) + ) + + // Assert + XCTAssertTrue(presenter.willDisplayRowCalled) + XCTAssertEqual(presenter.lastWillDisplayIndexPath, IndexPath(row: 1, section: 0)) + } +} diff --git a/ImageFeedTests/ImagesList/ImagesListViewControllerSpy.swift b/ImageFeedTests/ImagesList/ImagesListViewControllerSpy.swift new file mode 100644 index 0000000..ce3e9d1 --- /dev/null +++ b/ImageFeedTests/ImagesList/ImagesListViewControllerSpy.swift @@ -0,0 +1,36 @@ +import Foundation +@testable import ImageFeed + +final class ImagesListViewControllerSpy: ImagesListViewControllerProtocol { + var updateTableViewAnimatedCalled = false + var lastUpdateOldCount: Int? + var lastUpdateNewCount: Int? + + var reloadRowCalled = false + var lastReloadIndexPath: IndexPath? + + var setLoadingCalled = false + var lastIsLoading: Bool? + + var showLikeErrorCalled = false + + func updateTableViewAnimated(oldCount: Int, newCount: Int) { + updateTableViewAnimatedCalled = true + lastUpdateOldCount = oldCount + lastUpdateNewCount = newCount + } + + func reloadRow(at indexPath: IndexPath) { + reloadRowCalled = true + lastReloadIndexPath = indexPath + } + + func setLoading(_ isLoading: Bool) { + setLoadingCalled = true + lastIsLoading = isLoading + } + + func showLikeError() { + showLikeErrorCalled = true + } +} diff --git a/ImageFeedTests/Profile/ProfilePresenterSpy.swift b/ImageFeedTests/Profile/ProfilePresenterSpy.swift new file mode 100644 index 0000000..f6ba2e4 --- /dev/null +++ b/ImageFeedTests/Profile/ProfilePresenterSpy.swift @@ -0,0 +1,22 @@ +import Foundation +@testable import ImageFeed + +final class ProfilePresenterSpy: ProfilePresenterProtocol { + var view: ProfileViewControllerProtocol? + + var viewDidLoadCalled = false + var didTapLogoutButtonCalled = false + var didConfirmLogoutCalled = false + + func viewDidLoad() { + viewDidLoadCalled = true + } + + func didTapLogoutButton() { + didTapLogoutButtonCalled = true + } + + func didConfirmLogout() { + didConfirmLogoutCalled = true + } +} diff --git a/ImageFeedTests/Profile/ProfileTests.swift b/ImageFeedTests/Profile/ProfileTests.swift new file mode 100644 index 0000000..3789581 --- /dev/null +++ b/ImageFeedTests/Profile/ProfileTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import ImageFeed + +final class ProfileTests: XCTestCase { + func testViewControllerCallsViewDidLoad() { + // Arrange + let viewController = ProfileViewController() + let presenter = ProfilePresenterSpy() + viewController.configure(presenter) + + // Act + _ = viewController.view + + // Assert + XCTAssertTrue(presenter.viewDidLoadCalled) + } + + func testPresenterCallsUpdateAvatarOnViewDidLoad() { + // Arrange + let viewController = ProfileViewControllerSpy() + let presenter = ProfilePresenter() + presenter.view = viewController + + // Act + presenter.viewDidLoad() + + // Assert + XCTAssertTrue(viewController.updateAvatarCalled) + } + + func testPresenterShowsLogoutConfirmation() { + // Arrange + let viewController = ProfileViewControllerSpy() + let presenter = ProfilePresenter() + presenter.view = viewController + + // Act + presenter.didTapLogoutButton() + + // Assert + XCTAssertTrue(viewController.showLogoutConfirmationCalled) + } +} diff --git a/ImageFeedTests/Profile/ProfileViewControllerSpy.swift b/ImageFeedTests/Profile/ProfileViewControllerSpy.swift new file mode 100644 index 0000000..0d258cd --- /dev/null +++ b/ImageFeedTests/Profile/ProfileViewControllerSpy.swift @@ -0,0 +1,30 @@ +import Foundation +@testable import ImageFeed + +final class ProfileViewControllerSpy: ProfileViewControllerProtocol { + var updateProfileDetailsCalled = false + var lastName: String? + var lastLoginName: String? + var lastBio: String? + + var updateAvatarCalled = false + var lastAvatarURL: URL? + + var showLogoutConfirmationCalled = false + + func updateProfileDetails(name: String, loginName: String, bio: String) { + updateProfileDetailsCalled = true + lastName = name + lastLoginName = loginName + lastBio = bio + } + + func updateAvatar(url: URL?) { + updateAvatarCalled = true + lastAvatarURL = url + } + + func showLogoutConfirmation() { + showLogoutConfirmationCalled = true + } +} diff --git a/ImageFeedTests/WebView/WebViewPresenterSpy.swift b/ImageFeedTests/WebView/WebViewPresenterSpy.swift new file mode 100644 index 0000000..82f6e26 --- /dev/null +++ b/ImageFeedTests/WebView/WebViewPresenterSpy.swift @@ -0,0 +1,18 @@ +import Foundation +@testable import ImageFeed + +final class WebViewPresenterSpy: WebViewPresenterProtocol { + var view: WebViewViewControllerProtocol? + + var viewDidLoadCalled = false + + func viewDidLoad() { + viewDidLoadCalled = true + } + + func didUpdateProgressValue(_ newValue: Double) {} + + func extractAuthCode(from url: URL) -> String? { + return nil + } +} diff --git a/ImageFeedTests/WebView/WebViewTests.swift b/ImageFeedTests/WebView/WebViewTests.swift new file mode 100644 index 0000000..895b0f0 --- /dev/null +++ b/ImageFeedTests/WebView/WebViewTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import ImageFeed + +final class WebViewTests: XCTestCase { + func testViewControllerCallsViewDidLoad() { + // Arrange + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let viewController = storyboard.instantiateViewController(withIdentifier: "WebViewViewController") as! WebViewViewController + let presenter = WebViewPresenterSpy() + viewController.configure(presenter) + + // Act + _ = viewController.view + + // Assert + XCTAssertTrue(presenter.viewDidLoadCalled) + } + + func testPresenterCallsLoadRequest() { + // Arrange + let authHelper = AuthHelper() + let presenter = WebViewPresenter(authHelper: authHelper) + let viewController = WebViewViewControllerSpy() + presenter.view = viewController + + // Act + presenter.viewDidLoad() + + // Assert + XCTAssertTrue(viewController.loadCalled) + } + + func testProgressVisibleWhenLessThenOne() { + // Arrange + let authHelper = AuthHelper() + let presenter = WebViewPresenter(authHelper: authHelper) + let progress: Float = 0.6 + + // Act + let shouldHideProgress = presenter.shouldHideProgress(for: progress) + + // Assert + XCTAssertFalse(shouldHideProgress) + } + + func testProgressHiddenWhenOne() { + // Arrange + let authHelper = AuthHelper() + let presenter = WebViewPresenter(authHelper: authHelper) + let progress: Float = 1 + + // Act + let shouldHideProgress = presenter.shouldHideProgress(for: progress) + + // Assert + XCTAssertTrue(shouldHideProgress) + } + + func testAuthHelperAuthURL() { + // Arrange + let configuration = AuthConfiguration.standard + let authHelper = AuthHelper(configuration: configuration) + + // Act + let request = authHelper.makeAuthRequest() + + // Assert + guard let urlString = request.url?.absoluteString else { + XCTFail("Auth URL is nil") + return + } + XCTAssertTrue(urlString.contains(configuration.unsplashAuthorizeURLString)) + XCTAssertTrue(urlString.contains(configuration.accessKey)) + XCTAssertTrue(urlString.contains(configuration.redirectURI)) + XCTAssertTrue(urlString.contains("code")) + XCTAssertTrue(urlString.contains(configuration.accessScope)) + } + + func testCodeFromURL() { + // Arrange + let configuration = AuthConfiguration.standard + let authHelper = AuthHelper(configuration: configuration) + + var urlComponents = URLComponents(string: "https://unsplash.com/oauth/authorize/native")! + urlComponents.queryItems = [URLQueryItem(name: "code", value: "test code")] + let url = urlComponents.url! + + // Act + let code = authHelper.extractAuthCode(from: url) + + // Assert + XCTAssertEqual(code, "test code") + } +} diff --git a/ImageFeedTests/WebView/WebViewViewControllerSpy.swift b/ImageFeedTests/WebView/WebViewViewControllerSpy.swift new file mode 100644 index 0000000..2705808 --- /dev/null +++ b/ImageFeedTests/WebView/WebViewViewControllerSpy.swift @@ -0,0 +1,15 @@ +import Foundation +@testable import ImageFeed + +final class WebViewViewControllerSpy: WebViewViewControllerProtocol { + var loadCalled = false + + func load(request: URLRequest) { + loadCalled = true + } + + func setProgressValue(_ newValue: Float) {} + + func setProgressHidden(_ isHidden: Bool) {} +} + From 0502064647c15efbd7dc5ccc6d6c1d65fbd95431 Mon Sep 17 00:00:00 2001 From: Waldemar Date: Mon, 25 May 2026 20:30:28 +0400 Subject: [PATCH 4/5] test(ui): testAuth, testFeed and testProfile + storyboard accessibility ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UI-test coverage for the three required scenarios: - testAuth — taps Authenticate, waits for the Unsplash WebView, fills the login form, submits and waits until the feed table appears. - testFeed — scrolls the feed, toggles a like twice, opens a cell full-screen, zooms in/out and returns via Back Button. - testProfile — waits for the feed, switches to the profile tab, asserts the user's name and login render, taps the logout button, confirms the "Пока, пока!" dialog and waits for the Authenticate button to come back — closing the round trip required by the spec. Storyboard gains accessibility identifiers ("Authenticate", "Like Button", "Back Button") used by these tests. --- ImageFeed/Main.storyboard | 5 +- ImageFeedUITests/ImageFeedUITests.swift | 103 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 ImageFeedUITests/ImageFeedUITests.swift diff --git a/ImageFeed/Main.storyboard b/ImageFeed/Main.storyboard index b6c100d..0be967c 100644 --- a/ImageFeed/Main.storyboard +++ b/ImageFeed/Main.storyboard @@ -42,6 +42,7 @@