// apple/PhysarumApp/Tests/VideoPipelineIntegrationTests.swift
//
// Integration test: exercises the real AVAssetWriter + CVMetalTextureCache render-into-
// pixel-buffer path on the host GPU. The whole point is to verify that the
// pixel-buffer-backed MTLTexture supports .renderTarget usage and that each frame
// actually makes it through the hardware encode pipeline.
import XCTest
import AVFoundation
import Metal
@testable import Physarum
import PhysarumEngine
final class VideoPipelineIntegrationTests: XCTestCase {
// MARK: - shared helpers
/// Returns (device, library, renderer, sim, preset) or nil when no Metal GPU.
private func makeStack(fieldSize: Int = 128) throws -> (MTLDevice, ShaderLibrary, Renderer, SimEngine, PresetParams)? {
guard let device = MTLCreateSystemDefaultDevice() else { return nil }
let preset = AppState().edited
let lib = ShaderLibrary(device: device)
let renderer = try Renderer(device: device, library: lib, width: fieldSize, height: fieldSize)
let sim = try SimEngine(device: device, library: lib,
width: fieldSize, height: fieldSize,
preset: preset)
return (device, lib, renderer, sim, preset)
}
/// Warm up the feedback buffer so early frames are not blank.
private func warmUp(renderer: Renderer, sim: SimEngine, preset: PresetParams, steps: Int = 10) throws {
renderer.resetFeedback()
for _ in 0..<steps {
sim.step()
try renderer.renderOffscreen(sim: sim, preset: preset)
}
}
// MARK: - HEVC (.mov) test
func testVideoPipelineHEVC() async throws {
guard let (device, _, renderer, sim, preset) = try makeStack() else {
throw XCTSkip("No Metal device — skipping GPU pipeline test")
}
try warmUp(renderer: renderer, sim: sim, preset: preset)
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("pipeline-test-hevc-\(UUID().uuidString).mov")
let recorder = VideoClipRecorder(
device: device,
url: outputURL,
width: 256, height: 256,
fps: 60,
codec: .hevc,
renderInto: { tex in
sim.step()
try renderer.renderOffscreen(sim: sim, preset: preset, into: tex, cover: true)
}
)
let resultURL = try await runClip(
frameCount: 12,
fps: 60,
recorder: recorder,
renderFrame: { _ in },
isCancelled: { false },
progress: { _ in }
)
defer { try? FileManager.default.removeItem(at: resultURL) }
// File must exist and be non-trivial.
XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path),
"Output file should exist at \(resultURL.path)")
let attrs = try FileManager.default.attributesOfItem(atPath: resultURL.path)
let fileSize = (attrs[.size] as? Int) ?? 0
XCTAssertGreaterThan(fileSize, 2000,
"HEVC output (\(fileSize) bytes) should be non-trivial")
// Verify AVFoundation metadata: video track + dimensions + duration.
let asset = AVURLAsset(url: resultURL)
let tracks = try await asset.loadTracks(withMediaType: .video)
let track = try XCTUnwrap(tracks.first, "Expected at least one video track")
let naturalSize = try await track.load(.naturalSize)
// Allow either dimension order (portrait vs landscape orientation tag).
let dims = Set([Int(naturalSize.width), Int(naturalSize.height)])
XCTAssertTrue(dims.contains(256),
"naturalSize \(naturalSize) should be 256×256")
let duration = try await asset.load(.duration)
let seconds = CMTimeGetSeconds(duration)
// 12 frames at 60 fps = 0.2 s; allow generous tolerance.
XCTAssertEqual(seconds, 0.2, accuracy: 0.15,
"HEVC clip duration \(seconds)s should be ≈0.2s (12/60)")
}
// MARK: - H.264 (.mp4) test
func testVideoPipelineH264() async throws {
guard let (device, _, renderer, sim, preset) = try makeStack() else {
throw XCTSkip("No Metal device — skipping GPU pipeline test")
}
try warmUp(renderer: renderer, sim: sim, preset: preset, steps: 6)
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("pipeline-test-h264-\(UUID().uuidString).mp4")
let recorder = VideoClipRecorder(
device: device,
url: outputURL,
width: 256, height: 256,
fps: 60,
codec: .h264,
renderInto: { tex in
sim.step()
try renderer.renderOffscreen(sim: sim, preset: preset, into: tex, cover: true)
}
)
let resultURL = try await runClip(
frameCount: 6,
fps: 60,
recorder: recorder,
renderFrame: { _ in },
isCancelled: { false },
progress: { _ in }
)
defer { try? FileManager.default.removeItem(at: resultURL) }
XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path),
"Output file should exist at \(resultURL.path)")
let attrs = try FileManager.default.attributesOfItem(atPath: resultURL.path)
let fileSize = (attrs[.size] as? Int) ?? 0
XCTAssertGreaterThan(fileSize, 2000,
"H.264 output (\(fileSize) bytes) should be non-trivial")
let asset = AVURLAsset(url: resultURL)
let tracks = try await asset.loadTracks(withMediaType: .video)
let track = try XCTUnwrap(tracks.first, "Expected at least one video track")
let naturalSize = try await track.load(.naturalSize)
let dims = Set([Int(naturalSize.width), Int(naturalSize.height)])
XCTAssertTrue(dims.contains(256),
"naturalSize \(naturalSize) should be 256×256")
let duration = try await asset.load(.duration)
let seconds = CMTimeGetSeconds(duration)
// 6 frames at 60 fps = 0.1 s; allow generous tolerance.
XCTAssertEqual(seconds, 0.1, accuracy: 0.15,
"H.264 clip duration \(seconds)s should be ≈0.1s (6/60)")
}
}