This commit implements profile image cropping and optimization, as well as a major refactor on EditPictureControl. It now employs the following techniques: - Users can now crop their profile pictures to fit a square aspect ratio nicely and avoid issues with automatic resizing/cropping - Profile images are resized to a 400px by 400px image before sending it over the wire for better bandwidth usage - Profile pictures are now tagged as such to the media uploaders, to enable media optimization or special care on their end. Integrating the cropping step was very difficult with the previous structures, so `EditPictureControl` was heavily refactored to have improved state handling and better testability: 1. Enums with associated values are being used to capture all of the state in the picture selection process, as that helps ensure the needed info in each step is there and more clearly delianeate different steps — all at compile-time 2. The view was split into a view-model architecture, with almost all of the view logic ported to the new view-model class, making the view and the logic more clear to read as concerns are separated. This also enables better testabilty Several automated tests were added to cover EditPictureControl logic and looks. Closes: https://github.com/damus-io/damus/issues/2643 Changelog-Added: Profile image cropping tools Changelog-Changed: Improved profile image bandwidth optimization Changelog-Changed: Improved reliability of picture selector Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
369 lines
14 KiB
Swift
369 lines
14 KiB
Swift
//
|
|
// EditPictureControlTests.swift
|
|
// damus
|
|
//
|
|
// Created by Daniel D'Aquino on 2024-12-28.
|
|
//
|
|
|
|
import XCTest
|
|
import SnapshotTesting
|
|
@testable import damus
|
|
import SwiftUI
|
|
|
|
final class EditPictureControlTests: XCTestCase {
|
|
typealias ViewModel = EditPictureControlViewModel<MockImageUploadModel>
|
|
typealias SelectionState = ViewModel.PictureSelectionState
|
|
|
|
let mock_uploader = MockMediaUploader()
|
|
let mock_url = URL(string: get_test_uploaded_url())!
|
|
let test_image = UIImage(named: "bitcoin-p2p")!
|
|
let mock_keypair = test_keypair
|
|
let mock_pubkey = test_keypair.pubkey
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
}
|
|
|
|
@MainActor
|
|
func testPFPLibrarySelection() async {
|
|
let expectation = XCTestExpectation(description: "Received URL")
|
|
let view_model = ViewModel(
|
|
context: .profile_picture,
|
|
pubkey: mock_pubkey,
|
|
current_image_url: .constant(mock_url),
|
|
state: .ready,
|
|
keypair: mock_keypair,
|
|
uploader: mock_uploader,
|
|
callback: { url in
|
|
XCTAssertEqual(url, URL(string: get_test_uploaded_url()))
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
view_model.select_image_from_library()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_library)
|
|
|
|
view_model.request_upload_authorization(.uiimage(test_image))
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.confirming_upload)
|
|
|
|
view_model.confirm_upload_authorization()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.cropping)
|
|
|
|
view_model.finished_cropping(croppedImage: test_image.resized(to: CGSize(width: 10, height: 10)))
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.uploading)
|
|
|
|
// Wait to receive URL
|
|
await fulfillment(of: [expectation], timeout: 5)
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
}
|
|
|
|
@MainActor
|
|
func testBannerLibrarySelection() async {
|
|
let expectation = XCTestExpectation(description: "Received URL")
|
|
let view_model = ViewModel(
|
|
context: .normal,
|
|
pubkey: mock_pubkey,
|
|
current_image_url: .constant(mock_url),
|
|
state: .ready,
|
|
keypair: mock_keypair,
|
|
uploader: mock_uploader,
|
|
callback: { url in
|
|
XCTAssertEqual(url, URL(string: get_test_uploaded_url()))
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
view_model.select_image_from_library()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_library)
|
|
|
|
let test_image = UIImage(named: "bitcoin-p2p")!
|
|
view_model.request_upload_authorization(.uiimage(test_image))
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.confirming_upload)
|
|
|
|
view_model.confirm_upload_authorization()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.uploading)
|
|
|
|
// Wait to receive URL
|
|
await fulfillment(of: [expectation], timeout: 5)
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
}
|
|
|
|
@MainActor
|
|
func testPFPCameraSelection() async {
|
|
let expectation = XCTestExpectation(description: "Received URL")
|
|
let view_model = ViewModel(
|
|
context: .profile_picture,
|
|
pubkey: mock_pubkey,
|
|
current_image_url: .constant(mock_url),
|
|
state: .ready,
|
|
keypair: mock_keypair,
|
|
uploader: mock_uploader,
|
|
callback: { url in
|
|
XCTAssertEqual(url, URL(string: get_test_uploaded_url()))
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
// Ready
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
// Take picture
|
|
view_model.select_image_from_camera()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_camera)
|
|
XCTAssertEqual(view_model.state.show_camera, true)
|
|
|
|
// Confirm upload
|
|
view_model.request_upload_authorization(.uiimage(test_image))
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.confirming_upload)
|
|
XCTAssertEqual(view_model.state.is_confirming_upload, true)
|
|
XCTAssertEqual(view_model.state.show_camera, false)
|
|
|
|
// Confirm and crop
|
|
view_model.confirm_upload_authorization()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.cropping)
|
|
XCTAssertEqual(view_model.state.show_image_cropper, true)
|
|
XCTAssertEqual(view_model.state.is_confirming_upload, false)
|
|
|
|
// Finish cropping and upload
|
|
view_model.finished_cropping(croppedImage: test_image.resized(to: CGSize(width: 10, height: 10)))
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.uploading)
|
|
XCTAssertEqual(view_model.state.show_image_cropper, false)
|
|
|
|
// Wait to receive URL
|
|
await fulfillment(of: [expectation], timeout: 5)
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
}
|
|
|
|
@MainActor
|
|
func testBannerCameraSelection() async {
|
|
let expectation = XCTestExpectation(description: "Received URL")
|
|
let view_model = ViewModel(
|
|
context: .normal,
|
|
pubkey: mock_pubkey,
|
|
current_image_url: .constant(mock_url),
|
|
state: .ready,
|
|
keypair: mock_keypair,
|
|
uploader: mock_uploader,
|
|
callback: { url in
|
|
XCTAssertEqual(url, URL(string: get_test_uploaded_url()))
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
// Ready
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
// Take picture
|
|
view_model.select_image_from_camera()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_camera)
|
|
XCTAssertEqual(view_model.state.show_camera, true)
|
|
|
|
// Confirm upload
|
|
view_model.request_upload_authorization(.uiimage(test_image))
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.confirming_upload)
|
|
XCTAssertEqual(view_model.state.is_confirming_upload, true)
|
|
XCTAssertEqual(view_model.state.show_camera, false)
|
|
|
|
// Confirm and upload
|
|
view_model.confirm_upload_authorization()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.uploading)
|
|
XCTAssertEqual(view_model.state.show_image_cropper, false)
|
|
XCTAssertEqual(view_model.state.is_confirming_upload, false)
|
|
|
|
// Wait to receive URL
|
|
await fulfillment(of: [expectation], timeout: 5)
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
}
|
|
|
|
@MainActor
|
|
func testPFPUrlSelection() async {
|
|
let expectation = XCTestExpectation(description: "Received URL")
|
|
let view_model = ViewModel(
|
|
context: .profile_picture,
|
|
pubkey: mock_pubkey,
|
|
current_image_url: .constant(mock_url),
|
|
state: .ready,
|
|
keypair: mock_keypair,
|
|
uploader: mock_uploader,
|
|
callback: { url in
|
|
if url == self.mock_url {
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
view_model.select_image_from_url()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_url)
|
|
|
|
view_model.choose_url(mock_url)
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
let current_image_url = view_model.current_image_url
|
|
XCTAssertEqual(current_image_url, mock_url)
|
|
|
|
// Wait to receive URL
|
|
await fulfillment(of: [expectation], timeout: 5)
|
|
}
|
|
|
|
@MainActor
|
|
func testPFPSelectionWithCancellation() async {
|
|
let expectation = XCTestExpectation(description: "Received URL")
|
|
let view_model = ViewModel(
|
|
context: .profile_picture,
|
|
pubkey: mock_pubkey,
|
|
current_image_url: .constant(mock_url),
|
|
state: .ready,
|
|
keypair: mock_keypair,
|
|
uploader: mock_uploader,
|
|
callback: { url in
|
|
XCTAssertEqual(url, URL(string: get_test_uploaded_url()))
|
|
expectation.fulfill()
|
|
}
|
|
)
|
|
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
// Open camera
|
|
view_model.select_image_from_camera()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_camera)
|
|
XCTAssertTrue(view_model.show_camera.wrappedValue)
|
|
|
|
// Dismiss camera
|
|
view_model.show_camera.wrappedValue = false
|
|
XCTAssertFalse(view_model.show_camera.wrappedValue)
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
// Open library
|
|
view_model.select_image_from_library()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_library)
|
|
XCTAssertTrue(view_model.show_library.wrappedValue)
|
|
|
|
// Dismiss library
|
|
view_model.show_library.wrappedValue = false
|
|
XCTAssertFalse(view_model.show_library.wrappedValue)
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
// Select from URL
|
|
view_model.select_image_from_url()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.selecting_picture_from_url)
|
|
XCTAssertTrue(view_model.show_url_sheet.wrappedValue)
|
|
|
|
// Dismiss URL sheet
|
|
view_model.show_url_sheet.wrappedValue = false
|
|
XCTAssertFalse(view_model.show_url_sheet.wrappedValue)
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
|
|
// Select from library and start cropping
|
|
view_model.select_image_from_library()
|
|
view_model.request_upload_authorization(.uiimage(test_image))
|
|
view_model.confirm_upload_authorization()
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.cropping)
|
|
XCTAssertTrue(view_model.show_image_cropper.wrappedValue)
|
|
|
|
// Cancel during cropping
|
|
view_model.show_image_cropper.wrappedValue = false
|
|
XCTAssertEqual(view_model.state.step, SelectionState.Step.ready)
|
|
}
|
|
|
|
@MainActor
|
|
func testEditPictureControlFirstTimeSetup() async {
|
|
var current_image_url: URL? = nil
|
|
|
|
let view_model = EditPictureControl.Model(
|
|
context: .profile_picture,
|
|
pubkey: mock_pubkey,
|
|
current_image_url: Binding(get: { return current_image_url }, set: { current_image_url = $0 }),
|
|
state: .ready,
|
|
keypair: mock_keypair,
|
|
uploader: mock_uploader,
|
|
callback: { url in
|
|
return
|
|
}
|
|
)
|
|
|
|
// Setup the test view
|
|
let test_view = EditPictureControl(
|
|
model: view_model,
|
|
style: .init(size: 25, first_time_setup: true),
|
|
callback: { url in return }
|
|
)
|
|
let hostView = UIHostingController(rootView: test_view)
|
|
|
|
sleep(2) // Wait a bit for things to load
|
|
assertSnapshot(matching: hostView, as: .image(on: .iPhoneSe(.portrait)))
|
|
}
|
|
|
|
@MainActor
|
|
func testEditPictureControlNotFirstTimeSetup() async {
|
|
var current_image_url: URL? = nil
|
|
|
|
let view_model = EditPictureControl.Model(
|
|
context: .profile_picture,
|
|
pubkey: mock_pubkey,
|
|
current_image_url: Binding(get: { return current_image_url }, set: { current_image_url = $0 }),
|
|
state: .ready,
|
|
keypair: mock_keypair,
|
|
uploader: mock_uploader,
|
|
callback: { url in
|
|
return
|
|
}
|
|
)
|
|
|
|
// Setup the test view
|
|
let test_view = EditPictureControl(
|
|
model: view_model,
|
|
style: .init(size: nil, first_time_setup: false),
|
|
callback: { url in return }
|
|
)
|
|
let hostView = UIHostingController(rootView: test_view)
|
|
|
|
sleep(2) // Wait a bit for things to load
|
|
assertSnapshot(matching: hostView, as: .image(on: .iPhoneSe(.portrait)))
|
|
}
|
|
|
|
// MARK: Mock classes
|
|
|
|
class MockMediaUploader: MediaUploaderProtocol {
|
|
var nameParam: String { return "name_param" }
|
|
var mediaTypeParam: String { return "media_type_param" }
|
|
var supportsVideo: Bool { return true }
|
|
var requiresNip98: Bool { return true }
|
|
var postAPI: String { return "http://localhost:8000" }
|
|
|
|
func getMediaURL(from data: Data) -> String? {
|
|
return "http://localhost:8000"
|
|
}
|
|
|
|
func mediaTypeValue(for mediaType: damus.ImageUploadMediaType) -> String? {
|
|
return "media_type_value"
|
|
}
|
|
|
|
var uploadCalled = false
|
|
var uploadCompletion: (() -> Void)?
|
|
}
|
|
|
|
class MockImageUploadModel: ImageUploadModelProtocol {
|
|
required init() {}
|
|
|
|
func start(media: damus.MediaUpload, uploader: any damus.MediaUploaderProtocol, mediaType: damus.ImageUploadMediaType, keypair: damus.Keypair?) async -> damus.ImageUploadResult {
|
|
return damus.ImageUploadResult.success(get_test_uploaded_url())
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate func get_test_uploaded_url() -> String {
|
|
return "https://example.com/newimage.jpg"
|
|
}
|
|
|
|
fileprivate extension UIImage {
|
|
static func from(url: URL) throws -> UIImage? {
|
|
let data = try Data(contentsOf: url)
|
|
return UIImage(data: data)
|
|
}
|
|
}
|