import XCTest
import Metal
@testable import PhysarumEngine
/// Exercises the native simulation + render pipeline on a real Metal device.
/// Skipped where no GPU is available (e.g. a headless CI box without Metal).
final class SimEngineTests: XCTestCase {
private func makeDevice() throws -> MTLDevice {
guard let device = MTLCreateSystemDefaultDevice() else {
throw XCTSkip("no Metal device available")
}
return device
}
func testSimEvolvesPheromoneField() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
var p = try TestFixtures.minimalPreset().params
p.agentCount = 20000
p.spawnPattern = .center
p.fadeStrength = 0.1
p.species[0].depositAmount = 5
let engine = try SimEngine(device: device, library: lib, width: 256, height: 256, preset: p)
for _ in 0..<120 { engine.step() }
let s = engine.pheromoneStats()
XCTAssertFalse(s.hasNaN, "pheromone field must stay finite")
XCTAssertGreaterThan(s.sum, 0, "agents must deposit a trail (sum > 0)")
XCTAssertGreaterThan(s.maxValue, 0, "pheromone max must be > 0")
}
func testFrameCountTracksSubSteps() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
var p = try TestFixtures.minimalPreset().params
p.agentCount = 1000
p.simSpeed = 2 // each step() runs simSpeed physics sub-steps
let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p)
XCTAssertEqual(engine.frame, 0)
for _ in 0..<5 { engine.step() }
XCTAssertEqual(engine.frame, 10, "5 steps × simSpeed 2 = 10 sub-step frames")
}
func testUpdateLiveParamsAppliesRespawnFreeFields() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
var p = try TestFixtures.minimalPreset().params
p.simSpeed = 1
let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p)
var edited = p
edited.simSpeed = 4 // respawn-free → must take effect
edited.agentCount = 999_999 // respawn-only → must be ignored (no realloc/crash)
engine.updateLiveParams(edited)
engine.step()
XCTAssertEqual(engine.frame, 4, "simSpeed 4 applied → one step advances 4 sub-step frames")
}
/// Renders a feedback-heavy preset to PNG, exercising the full pipeline
/// including the temporal feedback pass. Verifies it completes without a GPU
/// error and writes a non-empty file.
func testRenderPNGWithFeedbackPass() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
var p = try TestFixtures.minimalPreset().params
p.agentCount = 10000
p.spawnPattern = .center
p.feedbackAmount = 0.6 // drives the echo / feedback pass
p.bloomIntensity = 2.0
let engine = try SimEngine(device: device, library: lib, width: 128, height: 128, preset: p)
for _ in 0..<60 { engine.step() }
let renderer = try Renderer(device: device, library: lib, width: 128, height: 128)
let path = NSTemporaryDirectory() + "physarum-test-\(UUID().uuidString).png"
defer { try? FileManager.default.removeItem(atPath: path) }
let ok = try renderer.renderPNG(sim: engine, preset: p, to: path)
XCTAssertTrue(ok, "renderPNG should report success")
XCTAssertTrue(FileManager.default.fileExists(atPath: path), "PNG file should exist")
let size = (try FileManager.default.attributesOfItem(atPath: path)[.size] as? Int) ?? 0
XCTAssertGreaterThan(size, 0, "PNG file should be non-empty")
}
/// resetFeedback() must be callable repeatedly without error and not perturb
/// a subsequent render (the path the live viewer uses on every preset switch).
func testResetFeedbackIsSafeBetweenRenders() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
let p = try TestFixtures.minimalPreset().params
let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p)
for _ in 0..<10 { engine.step() }
let renderer = try Renderer(device: device, library: lib, width: 64, height: 64)
renderer.resetFeedback()
renderer.resetFeedback()
XCTAssertNoThrow(try renderer.renderOffscreen(sim: engine, preset: p))
}
}