ImageOrientationTest.swift 117 lignes · 5326 octets
import XCTest
import UIKit
@testable import Card2vcf

/// Port of android `scan/ImageOrientationTest.kt` (pure logic parts):
/// EXIF rotation, camera fallback, and no-double-rotation guarantees.
/// The real-photo fixture tests (dimo card) stay Android-only.
final class ImageOrientationTest: XCTestCase {

    func testExif6Rotates90AndSwapsDimensions() {
        let image = solidImage(width: 200, height: 100, orientation: .right) // EXIF 6
        let oriented = ImageOrientation.orient(image)
        XCTAssertTrue(oriented.fromExif)
        XCTAssertEqual(oriented.appliedRotationDegrees, 90)
        XCTAssertEqual(pixelSize(oriented.bitmap).width, 100)
        XCTAssertEqual(pixelSize(oriented.bitmap).height, 200)
    }

    func testExif3Rotates180WithoutChangingDimensions() {
        let image = solidImage(width: 160, height: 80, orientation: .down) // EXIF 3
        let oriented = ImageOrientation.orient(image)
        XCTAssertTrue(oriented.fromExif)
        XCTAssertEqual(oriented.appliedRotationDegrees, 180)
        XCTAssertEqual(pixelSize(oriented.bitmap).width, 160)
        XCTAssertEqual(pixelSize(oriented.bitmap).height, 80)
    }

    func testExif8Rotates270() {
        let image = solidImage(width: 120, height: 60, orientation: .left) // EXIF 8
        let oriented = ImageOrientation.orient(image)
        XCTAssertEqual(oriented.appliedRotationDegrees, 270)
        XCTAssertEqual(pixelSize(oriented.bitmap).width, 60)
        XCTAssertEqual(pixelSize(oriented.bitmap).height, 120)
    }

    func testWithoutExifUsesCameraRotation() {
        let image = solidImage(width: 200, height: 100, orientation: .up)
        let oriented = ImageOrientation.orient(image, cameraRotationDegrees: 90)
        XCTAssertFalse(oriented.fromExif)
        XCTAssertEqual(oriented.appliedRotationDegrees, 90)
        XCTAssertEqual(pixelSize(oriented.bitmap).width, 100)
        XCTAssertEqual(pixelSize(oriented.bitmap).height, 200)
    }

    func testExifWinsOverCameraNoDoubleRotation() {
        let image = solidImage(width: 200, height: 100, orientation: .right)
        // If EXIF and camera rotations were cumulated we would get 180° and the
        // original dimensions back. That must never happen.
        let oriented = ImageOrientation.orient(image, cameraRotationDegrees: 90)
        XCTAssertTrue(oriented.fromExif)
        XCTAssertEqual(oriented.appliedRotationDegrees, 90)
        XCTAssertEqual(pixelSize(oriented.bitmap).width, 100)
        XCTAssertEqual(pixelSize(oriented.bitmap).height, 200)
    }

    func testNormalizeDegreesQuantizesToQuarterTurns() {
        XCTAssertEqual(ImageOrientation.normalizeDegrees(10), 0)
        XCTAssertEqual(ImageOrientation.normalizeDegrees(95), 90)
        XCTAssertEqual(ImageOrientation.normalizeDegrees(190), 180)
        XCTAssertEqual(ImageOrientation.normalizeDegrees(-90), 270)
    }

    func testExifRotationDegreesMapping() {
        XCTAssertEqual(ImageOrientation.exifRotationDegrees(.right), 90)
        XCTAssertEqual(ImageOrientation.exifRotationDegrees(.leftMirrored), 90)
        XCTAssertEqual(ImageOrientation.exifRotationDegrees(.down), 180)
        XCTAssertEqual(ImageOrientation.exifRotationDegrees(.downMirrored), 180)
        XCTAssertEqual(ImageOrientation.exifRotationDegrees(.left), 270)
        XCTAssertEqual(ImageOrientation.exifRotationDegrees(.rightMirrored), 270)
        XCTAssertEqual(ImageOrientation.exifRotationDegrees(.up), 0)
        XCTAssertEqual(ImageOrientation.exifRotationDegrees(.upMirrored), 0)
    }

    func testJpegBytesWithoutExifUseCameraFallback() throws {
        let jpeg = try XCTUnwrap(
            solidImage(width: 200, height: 100, orientation: .up).jpegData(compressionQuality: 0.9)
        )
        let oriented = try XCTUnwrap(
            ImageOrientation.fromJpegBytes(jpeg, cameraRotationDegrees: 90)
        )
        XCTAssertFalse(oriented.fromExif)
        XCTAssertEqual(oriented.appliedRotationDegrees, 90)
        XCTAssertEqual(pixelSize(oriented.bitmap).width, 100)
        XCTAssertEqual(pixelSize(oriented.bitmap).height, 200)
    }

    // MARK: - Helpers

    /// Solid image whose *pixel* buffer is width x height, tagged with the given
    /// orientation (equivalent of the Kotlin JPEG + EXIF tag fixture).
    private func solidImage(
        width: Int,
        height: Int,
        orientation: UIImage.Orientation
    ) -> UIImage {
        let format = UIGraphicsImageRendererFormat.default()
        format.scale = 1
        let size = CGSize(width: width, height: height)
        let base = UIGraphicsImageRenderer(size: size, format: format).image { context in
            UIColor.red.setFill()
            context.fill(CGRect(origin: .zero, size: size))
            UIColor.black.setFill()
            context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) // corner marker
        }
        guard let cgImage = base.cgImage else {
            preconditionFailure("failed to build test image")
        }
        return UIImage(cgImage: cgImage, scale: 1, orientation: orientation)
    }

    private func pixelSize(_ image: UIImage) -> (width: Int, height: Int) {
        guard let cgImage = image.cgImage else {
            return (Int(image.size.width), Int(image.size.height))
        }
        return (cgImage.width, cgImage.height)
    }
}