~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
228 lines · 9.9 KB Raw History
@testable import Grain
import ImageIO
import UIKit
import UniformTypeIdentifiers
import XCTest

/// Everything a photo goes through between the picker and the PDS. The resize
/// is the app's only real CPU work outside layout, and its byte ceiling is a
/// hard requirement — a blob over the limit is rejected by the server after the
/// upload has already been paid for.
@MainActor
final class ImageProcessingTests: GrainTestCase {
    // MARK: - Fixtures

    /// High-frequency detail, so JPEG can't compress it down to nothing and the
    /// byte ceiling is actually exercised. Derived from the coordinates rather
    /// than a random source, so a failure here reproduces.
    private func noisyImage(width: Int, height: Int) -> UIImage {
        let format = UIGraphicsImageRendererFormat()
        format.scale = 1
        let step = 4
        return UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format).image { context in
            for y in stride(from: 0, to: height, by: step) {
                for x in stride(from: 0, to: width, by: step) {
                    context.cgContext.setFillColor(
                        red: CGFloat((x &* 7 &+ y &* 13) % 256) / 255,
                        green: CGFloat((x &* 31 &+ y &* 3) % 256) / 255,
                        blue: CGFloat((x &* 17 &+ y &* 29) % 256) / 255,
                        alpha: 1
                    )
                    context.cgContext.fill(CGRect(x: x, y: y, width: step, height: step))
                }
            }
        }
    }

    /// A smooth gradient — compressible the way a real photograph is, so the
    /// quality search can actually reach a byte ceiling.
    private func gradientImage(width: Int, height: Int) -> UIImage {
        let format = UIGraphicsImageRendererFormat()
        format.scale = 1
        return UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format).image { context in
            let colours = [
                UIColor(red: 0.9, green: 0.4, blue: 0.2, alpha: 1).cgColor,
                UIColor(red: 0.1, green: 0.3, blue: 0.6, alpha: 1).cgColor,
            ]
            guard let gradient = CGGradient(
                colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: colours as CFArray, locations: nil
            ) else { return }
            context.cgContext.drawLinearGradient(
                gradient, start: .zero, end: CGPoint(x: width, y: height), options: []
            )
        }
    }

    private func flatImage(width: Int, height: Int) -> UIImage {
        let format = UIGraphicsImageRendererFormat()
        format.scale = 1
        return UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format).image { context in
            context.cgContext.setFillColor(red: 0.2, green: 0.6, blue: 0.7, alpha: 1)
            context.cgContext.fill(CGRect(x: 0, y: 0, width: width, height: height))
        }
    }

    // MARK: - resizeImage

    func testAnImageAlreadyUnderTheLimitIsNotEnlarged() {
        let (data, size) = ImageProcessing.resizeImage(
            flatImage(width: 800, height: 600), maxDimension: 2000, maxBytes: 900_000
        )

        XCTAssertEqual(size, CGSize(width: 800, height: 600))
        XCTAssertFalse(data.isEmpty)
    }

    func testAnOversizedImageIsScaledToFitTheLongEdge() {
        let (_, size) = ImageProcessing.resizeImage(
            flatImage(width: 4000, height: 3000), maxDimension: 2000, maxBytes: 900_000
        )

        XCTAssertEqual(max(size.width, size.height), 2000, accuracy: 1)
        XCTAssertEqual(size.width / size.height, 4.0 / 3.0, accuracy: 0.01, "Resizing must not distort the photo")
    }

    func testAPortraitImageIsScaledOnItsLongEdgeToo() {
        let (_, size) = ImageProcessing.resizeImage(
            flatImage(width: 3000, height: 4000), maxDimension: 2000, maxBytes: 900_000
        )

        XCTAssertEqual(max(size.width, size.height), 2000, accuracy: 1)
        XCTAssertEqual(size.height / size.width, 4.0 / 3.0, accuracy: 0.01)
    }

    /// The byte ceiling is the point of the whole routine: the PDS rejects a
    /// blob over its limit only after the upload has been paid for.
    func testAPhotographStaysUnderTheByteCeiling() {
        let (data, _) = ImageProcessing.resizeImage(
            gradientImage(width: 3000, height: 2000), maxDimension: 2000, maxBytes: 200_000
        )

        XCTAssertLessThanOrEqual(data.count, 200_000)
    }

    /// A lower ceiling must never produce a bigger file.
    func testTighterCeilingsNeverProduceLargerFiles() {
        let source = gradientImage(width: 2400, height: 1600)
        let sizes = [900_000, 400_000, 120_000].map {
            ImageProcessing.resizeImage(source, maxDimension: 2000, maxBytes: $0).0.count
        }

        XCTAssertEqual(sizes, sizes.sorted(by: >), "Byte counts should fall as the ceiling does: \(sizes)")
        for (size, ceiling) in zip(sizes, [900_000, 400_000, 120_000]) {
            XCTAssertLessThanOrEqual(size, ceiling)
        }
    }

    /// Detail JPEG can't compress is the case that used to break the ceiling:
    /// the quality search bottomed out, the routine scaled down once, re-encoded
    /// at a fixed quality and shipped it unchecked — landing five times over the
    /// limit. It now keeps scaling down until the encoding actually fits.
    func testAnIncompressibleImageStaysUnderTheCeilingToo() {
        let source = noisyImage(width: 3000, height: 2000)

        let (data, size) = ImageProcessing.resizeImage(source, maxDimension: 2000, maxBytes: 200_000)

        XCTAssertLessThanOrEqual(data.count, 200_000)
        XCTAssertLessThan(size.width, 2000, "Quality alone couldn't get there, so it had to scale down")
    }

    /// Resolution is given up only after quality has been, since softening is
    /// less visible than shrinking.
    func testQualityIsGivenUpBeforeResolution() {
        let source = gradientImage(width: 3000, height: 2000)

        let (_, size) = ImageProcessing.resizeImage(source, maxDimension: 2000, maxBytes: 200_000)

        XCTAssertEqual(size.width, 2000, accuracy: 1, "A compressible photo shouldn't lose any resolution")
    }

    /// A ceiling nothing could ever meet has to end somewhere rather than
    /// grinding the image down to a thumbnail or looping forever.
    func testAnUnreachableCeilingStopsAtAFloorRatherThanLooping() {
        let source = noisyImage(width: 2000, height: 2000)

        let (data, size) = ImageProcessing.resizeImage(source, maxDimension: 2000, maxBytes: 1)

        XCTAssertFalse(data.isEmpty, "It still has to return something postable")
        XCTAssertGreaterThanOrEqual(min(size.width, size.height), 240)
        XCTAssertLessThan(size.width, 2000)
    }

    func testTheResultIsDecodableJPEG() throws {
        let (data, size) = ImageProcessing.resizeImage(
            flatImage(width: 1200, height: 900), maxDimension: 600, maxBytes: 900_000
        )

        let decoded = try XCTUnwrap(UIImage(data: data))
        XCTAssertEqual(decoded.size.width, size.width, accuracy: 1)
        XCTAssertEqual(decoded.size.height, size.height, accuracy: 1)
    }

    // MARK: - extractGPS

    /// Builds a JPEG carrying the GPS tags a camera would write.
    private func jpegWithGPS(
        latitude: Double, latRef: String, longitude: Double, lonRef: String
    ) -> Data {
        let source = flatImage(width: 40, height: 40)
        let jpeg = source.jpegData(compressionQuality: 0.8)!
        let imageSource = CGImageSourceCreateWithData(jpeg as CFData, nil)!

        let gps: [String: Any] = [
            kCGImagePropertyGPSLatitude as String: latitude,
            kCGImagePropertyGPSLatitudeRef as String: latRef,
            kCGImagePropertyGPSLongitude as String: longitude,
            kCGImagePropertyGPSLongitudeRef as String: lonRef,
        ]

        let output = NSMutableData()
        let destination = CGImageDestinationCreateWithData(
            output, UTType.jpeg.identifier as CFString, 1, nil
        )!
        CGImageDestinationAddImageFromSource(
            destination, imageSource, 0,
            [kCGImagePropertyGPSDictionary as String: gps] as CFDictionary
        )
        XCTAssertTrue(CGImageDestinationFinalize(destination), "Failed to build the GPS fixture")
        return output as Data
    }

    func testGPSIsReadFromAPhotoThatCarriesIt() {
        let data = jpegWithGPS(latitude: 38.72, latRef: "N", longitude: 9.14, lonRef: "W")

        let coordinate = ImageProcessing.extractGPS(from: data)

        XCTAssertEqual(coordinate?.latitude ?? 0, 38.72, accuracy: 0.0001)
        XCTAssertEqual(coordinate?.longitude ?? 0, -9.14, accuracy: 0.0001, "A western longitude must come back negative")
    }

    /// EXIF stores magnitudes with a hemisphere letter beside them, so both
    /// southern and western references have to flip the sign.
    func testSouthernAndWesternHemispheresAreNegated() {
        let southWest = ImageProcessing.extractGPS(
            from: jpegWithGPS(latitude: 33.86, latRef: "S", longitude: 151.2, lonRef: "W")
        )
        XCTAssertEqual(southWest?.latitude ?? 0, -33.86, accuracy: 0.0001)
        XCTAssertEqual(southWest?.longitude ?? 0, -151.2, accuracy: 0.0001)

        let northEast = ImageProcessing.extractGPS(
            from: jpegWithGPS(latitude: 33.86, latRef: "N", longitude: 151.2, lonRef: "E")
        )
        XCTAssertEqual(northEast?.latitude ?? 0, 33.86, accuracy: 0.0001)
        XCTAssertEqual(northEast?.longitude ?? 0, 151.2, accuracy: 0.0001)
    }

    /// Most photos in a library have no GPS at all — screenshots, scans,
    /// anything stripped by another app.
    func testAPhotoWithNoGPSReturnsNothing() throws {
        let plain = try XCTUnwrap(flatImage(width: 40, height: 40).jpegData(compressionQuality: 0.8))

        XCTAssertNil(ImageProcessing.extractGPS(from: plain))
    }

    func testDataThatIsNotAnImageReturnsNothing() {
        XCTAssertNil(ImageProcessing.extractGPS(from: Data("not an image".utf8)))
        XCTAssertNil(ImageProcessing.extractGPS(from: Data()))
    }
}