Zum Inhalt

apple/PhysarumEngine/Tests/PhysarumEngineTests/RendererCaptureTests.swift

import XCTest
import Metal
@testable import PhysarumEngine

final class RendererCaptureTests: XCTestCase {
    private func makeRendererAndSim(field: Int = 64) throws -> (Renderer, SimEngine, MTLDevice)? {
        guard let device = MTLCreateSystemDefaultDevice() else { return nil }   // headless CI: no GPU → skip
        let lib = ShaderLibrary(device: device)
        let preset = try TestFixtures.minimalPreset().params
        let r = try Renderer(device: device, library: lib, width: field, height: field)
        let s = try SimEngine(device: device, library: lib, width: field, height: field, preset: preset)
        return (r, s, device)
    }

    func testRenderOffscreenIntoLargerTargetProducesNonBlankPNG() throws {
        guard let (r, s, device) = try makeRendererAndSim() else { throw XCTSkip("No Metal device") }
        let preset = try TestFixtures.minimalPreset().params
        // A target larger than the field (the composite must upscale into it).
        let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: 256, height: 192, mipmapped: false)
        d.usage = [.renderTarget, .shaderRead]
        d.storageMode = .shared
        let target = try XCTUnwrap(device.makeTexture(descriptor: d))
        r.resetFeedback()
        for _ in 0..<30 { s.step(); try r.renderOffscreen(sim: s, preset: preset, into: target, cover: true) }
        let url = FileManager.default.temporaryDirectory.appendingPathComponent("cap-test.png")
        XCTAssertTrue(try r.savePNG(target, to: url))
        let data = try Data(contentsOf: url)
        XCTAssertGreaterThan(data.count, 1000, "PNG of a 256×192 target should be non-trivial")
        try? FileManager.default.removeItem(at: url)
    }

    /// The snapshot-still capture path: build a fresh engine, `restore()` a snapshot,
    /// then render ONE frame with NO `step()` (skipStep). The scene pass reads
    /// `sim.paramsBuffer`, which is only populated by `step()` — so a never-stepped
    /// restored engine must still render the field, not black.
    func testRenderAfterRestoreWithoutStepIsNotBlack() throws {
        guard let (r, s, device) = try makeRendererAndSim() else { throw XCTSkip("No Metal device") }
        let preset = try TestFixtures.minimalPreset().params
        // Build real pheromone content in the source, then snapshot it.
        for _ in 0..<40 { s.step() }
        let snap = s.snapshot()
        // Fresh capture engine — restore, NO step, single offscreen render (the still path).
        let cap = try SimEngine(device: device, library: ShaderLibrary(device: device),
                                width: 64, height: 64, preset: preset)
        try cap.restore(snap)
        let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: 64, height: 64, mipmapped: false)
        d.usage = [.renderTarget, .shaderRead]
        d.storageMode = .shared
        let target = try XCTUnwrap(device.makeTexture(descriptor: d))
        r.resetFeedback()
        try r.renderOffscreen(sim: cap, preset: preset, into: target, cover: true)
        // Read back, ignoring the alpha channel (clear alpha is 1.0 = 255).
        let w = 64, h = 64, bpr = w * 4
        var raw = [UInt8](repeating: 0, count: bpr * h)
        target.getBytes(&raw, bytesPerRow: bpr, from: MTLRegionMake2D(0, 0, w, h), mipmapLevel: 0)
        var maxColor: UInt8 = 0
        for i in 0..<raw.count where i % 4 != 3 { maxColor = max(maxColor, raw[i]) }
        XCTAssertGreaterThan(maxColor, 8, "still rendered from a restored snapshot WITHOUT a step must show the field, not black")
    }
}