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")
}
}