Zum Inhalt

apple/PhysarumEngine/Tests/PhysarumEngineTests/SimEngineTests.swift

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