import XCTest
import Metal
@testable import PhysarumEngine
/// Tests for the non-blocking live encode path: sim sub-steps + render share one
/// command buffer on one queue, with no CPU-GPU round-trip per sub-step.
final class LiveEncodeTests: XCTestCase {
private func makeDevice() throws -> MTLDevice {
guard let device = MTLCreateSystemDefaultDevice() else {
throw XCTSkip("no Metal device available")
}
return device
}
private func headsSum(_ engine: SimEngine, cells: Int) -> Double {
let ptr = engine.headsBuffer.contents().bindMemory(to: SIMD4<Float>.self, capacity: cells)
var sum = 0.0
for i in 0..<cells { sum += Double(ptr[i].x + ptr[i].y + ptr[i].z) }
return sum
}
/// Characterization: after step(), `heads` holds only the LAST sub-step's
/// deposits — running 4 sub-steps must not accumulate ~4x the single-step sum.
/// (Deposit's read-modify-write loses some adds on cell collisions, so we
/// compare magnitudes, not exact counts.)
func testHeadsHoldOnlyLastSubStepDeposits() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
var p = try TestFixtures.minimalPreset().params
p.agentCount = 2000
p.spawnPattern = .random
var p1 = p; p1.simSpeed = 1
var p4 = p; p4.simSpeed = 4
let e1 = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p1)
let e4 = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p4)
e1.step()
e4.step()
let s1 = headsSum(e1, cells: 64 * 64)
let s4 = headsSum(e4, cells: 64 * 64)
XCTAssertGreaterThan(s1, 0)
XCTAssertGreaterThan(s4, 0)
XCTAssertLessThan(s4, s1 * 2.0, "heads must not accumulate across sub-steps (got \(s4) vs single-step \(s1))")
}
/// The live path encodes sub-steps into a caller-owned command buffer on a
/// shared queue — no commit, no waitUntilCompleted inside the engine.
func testEncodeStepOnSharedQueueEvolvesField() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
guard let queue = device.makeCommandQueue() else { return XCTFail("no queue") }
var p = try TestFixtures.minimalPreset().params
p.agentCount = 5000
p.spawnPattern = .center
p.simSpeed = 3
let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p, queue: queue)
var last: MTLCommandBuffer?
for _ in 0..<40 {
guard let cb = queue.makeCommandBuffer() else { return XCTFail("no cb") }
engine.encodeStep(into: cb)
cb.commit()
last = cb
}
last?.waitUntilCompleted()
XCTAssertEqual(engine.frame, 120, "40 frames × simSpeed 3")
let s = engine.pheromoneStats()
XCTAssertFalse(s.hasNaN)
XCTAssertGreaterThan(s.sum, 0)
}
/// One frame = sim sub-steps + full render encoded into a SINGLE command
/// buffer (the live MTKView path), producing non-empty output.
func testSimAndRenderShareOneCommandBuffer() throws {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
guard let queue = device.makeCommandQueue() else { return XCTFail("no queue") }
var p = try TestFixtures.minimalPreset().params
p.agentCount = 5000
p.spawnPattern = .center
p.species[0].depositAmount = 5
p.simSpeed = 2
let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p, queue: queue)
let renderer = try Renderer(device: device, library: lib, width: 64, height: 64, queue: queue)
let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: 64, height: 64, mipmapped: false)
d.usage = [.renderTarget, .shaderRead]
d.storageMode = .shared
guard let target = device.makeTexture(descriptor: d) else { return XCTFail("no texture") }
var lastCB: MTLCommandBuffer?
for _ in 0..<30 {
guard let cb = queue.makeCommandBuffer() else { return XCTFail("no cb") }
engine.encodeStep(into: cb)
let rp = MTLRenderPassDescriptor()
rp.colorAttachments[0].texture = target
rp.colorAttachments[0].loadAction = .clear
rp.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1)
rp.colorAttachments[0].storeAction = .store
renderer.encode(into: cb, sim: engine, preset: p, descriptor: rp, cover: true)
cb.commit()
lastCB = cb
}
lastCB?.waitUntilCompleted()
XCTAssertNil(lastCB?.error, "GPU error: \(String(describing: lastCB?.error))")
var raw = [UInt8](repeating: 0, count: 64 * 64 * 4)
target.getBytes(&raw, bytesPerRow: 64 * 4, from: MTLRegionMake2D(0, 0, 64, 64), mipmapLevel: 0)
let lit = raw.enumerated().contains { ($0.offset % 4) != 3 && $0.element > 8 }
XCTAssertTrue(lit, "rendered frame must contain non-background pixels")
}
}