Files
damus/damusTests/EditPictureControlTests.swift
Daniel D’Aquino bb0ad18913 Implement profile image cropping and optimization
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>
2025-01-06 15:45:57 +09:00

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)
}
}