import CoreMedia import XCTest @testable import DropTwoCore final class CropEngineTests: XCTestCase { private var tempDirectory: URL! override func setUpWithError() throws { tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) } override func tearDownWithError() throws { try? FileManager.default.removeItem(at: tempDirectory) } // MARK: - Geometrie (rein arithmetisch, kein Video nötig) func testTargetSizeMatchesReadmeExamples() { XCTAssertEqual( CropEngine.targetSize(forSourceSize: CGSize(width: 1080, height: 1920)), CGSize(width: 1080, height: 1350) ) XCTAssertEqual( CropEngine.targetSize(forSourceSize: CGSize(width: 2160, height: 3840)), CGSize(width: 2160, height: 2700) ) } func testTargetSizeRoundsHeightToEvenNumber() { let size = CropEngine.targetSize(forSourceSize: CGSize(width: 217, height: 386)) XCTAssertEqual(size.width, 217) XCTAssertEqual(size.height.truncatingRemainder(dividingBy: 2), 0) } func testCropRectMapsOffsetToTopCenterBottom() { let source = CGSize(width: 1080, height: 1920) XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: -1).minY, 0) XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 0).minY, 286) XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 1).maxY, 1920) XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 5).maxY, 1920) } func testCropRectTopEdgeIsEven() { let rect = CropEngine.cropRect(forSourceSize: CGSize(width: 1080, height: 1920), offset: 0.3333) XCTAssertEqual(rect.minY.truncatingRemainder(dividingBy: 2), 0) } // MARK: - End-to-End gegen einen synthetischen Clip /// Quelle: obere Hälfte weiß, untere schwarz. Ausschnitt ganz oben muss /// also deutlich heller sein als ganz unten — prüft die Richtung des /// Offsets im tatsächlichen Render, nicht nur in der Geometrie. func testOffsetMovesRenderedCropUpAndDown() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, pattern: .brightTopDarkBottom, in: tempDirectory) let top = tempDirectory.appendingPathComponent("top.mp4") let bottom = tempDirectory.appendingPathComponent("bottom.mp4") try await CropEngine.crop(sourceURL: clip.url, destinationURL: top, options: .init(cropPlan: CropPlan(offset: -1))) try await CropEngine.crop(sourceURL: clip.url, destinationURL: bottom, options: .init(cropPlan: CropPlan(offset: 1))) let topBrightness = try await TestVideoFactory.averageBrightness(of: top, at: 0.5) let bottomBrightness = try await TestVideoFactory.averageBrightness(of: bottom, at: 0.5) // 384er Quelle, 270er Ausschnitt: oben 192/270 ≈ 0.71 weiß, unten 78/270 ≈ 0.29. XCTAssertEqual(topBrightness, 0.71, accuracy: 0.08) XCTAssertEqual(bottomBrightness, 0.29, accuracy: 0.08) } /// iPhone-Fall: Container quer (384×216), per 90°-Transformation hochkant. /// Links im gespeicherten Bild ist nach der Drehung oben — der Ausschnitt /// "ganz oben" muss also hell sein, und das Ergebnis darf nicht quer sein. func testRotatedSourceIsCroppedUpright() async throws { let clip = try TestVideoFactory.makeClip( size: CGSize(width: 384, height: 216), includeAudio: false, pattern: .brightLeftDarkRight, transform: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 216, ty: 0), in: tempDirectory ) let top = tempDirectory.appendingPathComponent("top.mp4") let bottom = tempDirectory.appendingPathComponent("bottom.mp4") try await CropEngine.crop(sourceURL: clip.url, destinationURL: top, options: .init(cropPlan: CropPlan(offset: -1))) try await CropEngine.crop(sourceURL: clip.url, destinationURL: bottom, options: .init(cropPlan: CropPlan(offset: 1))) let resultInfo = try await VideoInspector.inspect(top) XCTAssertEqual(resultInfo.pixelSize, CGSize(width: 216, height: 270)) let topBrightness = try await TestVideoFactory.averageBrightness(of: top, at: 0.5) let bottomBrightness = try await TestVideoFactory.averageBrightness(of: bottom, at: 0.5) XCTAssertEqual(topBrightness, 0.71, accuracy: 0.08) XCTAssertEqual(bottomBrightness, 0.29, accuracy: 0.08) } func testOrientationFromTrackTransform() { XCTAssertEqual(CropEngine.orientation(for: .identity), .up) XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 1080, ty: 0)), .right) XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: 0, b: -1, c: 1, d: 0, tx: 0, ty: 1920)), .left) XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 1080, ty: 1920)), .down) } func testKeyframeSwitchesCropMidClip() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, pattern: .brightTopDarkBottom, in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mp4") var plan = CropPlan(offset: -1) plan.addKeyframe(at: 1) plan.setOffset(1, at: 1) try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination, options: .init(cropPlan: plan)) let before = try await TestVideoFactory.averageBrightness(of: destination, at: 0.5) let after = try await TestVideoFactory.averageBrightness(of: destination, at: 1.5) XCTAssertEqual(before, 0.71, accuracy: 0.08) XCTAssertEqual(after, 0.29, accuracy: 0.08) } func testCropsToTargetSizeAndKeepsAudioAndDuration() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mp4") try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) let resultInfo = try await VideoInspector.inspect(destination) let expectedSize = CropEngine.targetSize(forSourceSize: clip.size) XCTAssertEqual(resultInfo.pixelSize, expectedSize) XCTAssertEqual(resultInfo.codec, .h264) XCTAssertTrue(resultInfo.hasAudioTrack) XCTAssertEqual(CMTimeGetSeconds(resultInfo.duration), clip.seconds, accuracy: 0.3) } func testCropsWithoutAudioTrack() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mp4") try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) let resultInfo = try await VideoInspector.inspect(destination) XCTAssertFalse(resultInfo.hasAudioTrack) } func testReportsProgress() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mp4") var reportedValues: [Double] = [] try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) { value in reportedValues.append(value) } XCTAssertFalse(reportedValues.isEmpty) XCTAssertEqual(reportedValues.last ?? 0, 1.0, accuracy: 0.15) } // MARK: - Ablehnungen func testRejectsNonNineBySixteenSource() async throws { let clip = try TestVideoFactory.makeClip(size: CGSize(width: 400, height: 400), in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mp4") do { try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) XCTFail("Erwartete Ablehnung für eine Nicht-9:16-Quelle") } catch CropEngineError.sourceNotNineBySixteen { // erwartet } } func testRejectsExistingDestinationWithoutOverwrite() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mp4") FileManager.default.createFile(atPath: destination.path, contents: Data()) do { try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) XCTFail("Erwartete Ablehnung wegen bestehender Zieldatei") } catch CropEngineError.destinationAlreadyExists { // erwartet } } func testOverwritesExistingDestinationWhenExplicitlyAllowed() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mp4") FileManager.default.createFile(atPath: destination.path, contents: Data("platzhalter".utf8)) try await CropEngine.crop( sourceURL: clip.url, destinationURL: destination, options: .init(overwrite: true) ) let resultInfo = try await VideoInspector.inspect(destination) XCTAssertEqual(resultInfo.pixelSize, CropEngine.targetSize(forSourceSize: clip.size)) } func testRejectsUnsupportedContainer() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mxf") do { try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) XCTFail("Erwartete Ablehnung für ein MXF-Ziel") } catch CropEngineError.unsupportedContainer { // erwartet } } func testRejectsMovAsDestinationBecauseTargetIsFixedToMp4() async throws { let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory) let destination = tempDirectory.appendingPathComponent("out.mov") do { try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination) XCTFail("Erwartete Ablehnung für ein MOV-Ziel — Zielformat ist fest auf MP4 gesetzt") } catch CropEngineError.unsupportedContainer { // erwartet } } }