import XCTest
import Metal
@testable import PhysarumEngine
/// Pass gating: bloom/feedback passes that contribute exactly zero to the final
/// image must be skippable without changing output — and re-enabling feedback
/// must not blend in a stale echo from before it was disabled.
final class PostFXGatingTests: XCTestCase {
private func makeDevice() throws -> MTLDevice {
guard let device = MTLCreateSystemDefaultDevice() else {
throw XCTSkip("no Metal device available")
}
return device
}
private func pixels(_ renderer: Renderer, _ engine: SimEngine, _ p: PresetParams,
_ target: MTLTexture) throws -> [UInt8] {
try renderer.renderOffscreen(sim: engine, preset: p, into: target, cover: true)
var raw = [UInt8](repeating: 0, count: target.width * target.height * 4)
raw.withUnsafeMutableBytes { _ = $0 } // keep alloc out of getBytes timing
target.getBytes(&raw, bytesPerRow: target.width * 4,
from: MTLRegionMake2D(0, 0, target.width, target.height), mipmapLevel: 0)
return raw
}
private func brightness(_ px: [UInt8]) -> Double {
var sum = 0.0
for (i, v) in px.enumerated() where i % 4 != 3 { sum += Double(v) }
return sum / Double(px.count / 4 * 3)
}
private func makeTarget(_ device: MTLDevice, size: Int) throws -> MTLTexture {
let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: size, height: size, mipmapped: false)
d.usage = [.renderTarget, .shaderRead]
d.storageMode = .shared
guard let t = device.makeTexture(descriptor: d) else { throw ShaderError.compile("target") }
return t
}
/// With feedback and bloom both zero, repeated renders of the SAME sim state
/// are pixel-identical — nothing may accumulate or leak across frames.
func testZeroedPostFXRendersAreIdempotent() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
var p = try TestFixtures.minimalPreset().params
p.agentCount = 5000
p.spawnPattern = .center
p.species[0].depositAmount = 5
p.feedbackAmount = 0
p.bloomIntensity = 0
let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p)
for _ in 0..<30 { engine.step() }
let renderer = try Renderer(device: device, library: lib, width: 64, height: 64)
let target = try makeTarget(device, size: 64)
let a = try pixels(renderer, engine, p, target)
let b = try pixels(renderer, engine, p, target)
XCTAssertGreaterThan(brightness(a), 0.5, "scene must be visible")
XCTAssertEqual(a, b, "feedback=0/bloom=0 renders of the same sim state must be identical")
}
/// Disable feedback, render, re-enable: the first re-enabled frame must look
/// like the disabled frame (echo starts blank), NOT brightened by a stale
/// echo accumulated before the disable.
func testReenabledFeedbackStartsFromBlankEcho() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
var p = try TestFixtures.minimalPreset().params
p.agentCount = 5000
p.spawnPattern = .center
p.species[0].depositAmount = 5
let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p)
for _ in 0..<30 { engine.step() }
let renderer = try Renderer(device: device, library: lib, width: 64, height: 64)
let target = try makeTarget(device, size: 64)
// Build up a bright echo, then disable feedback.
var pOn = p; pOn.feedbackAmount = 0.85
for _ in 0..<10 { _ = try pixels(renderer, engine, pOn, target) }
var pOff = p; pOff.feedbackAmount = 0
let off = try pixels(renderer, engine, pOff, target)
// First frame after re-enable: echo contribution must be ~zero.
let on1 = try pixels(renderer, engine, pOn, target)
let bOff = brightness(off), bOn1 = brightness(on1)
XCTAssertLessThan(bOn1, bOff * 1.15,
"first re-enabled frame must not blend a stale echo (off \(bOff) vs re-enabled \(bOn1))")
}
}