import XCTest
import Metal
@testable import PhysarumEngine
/// Aspect fidelity of the composite viewport: a centre-spawned blob is isotropic
/// in FIELD pixels (agent motion has no aspect term), so after rendering into a
/// differently-shaped target its weighted pixel distribution must stay round —
/// σx/σy ≈ 1. Any field-to-target stretch shows up as that ratio.
final class AspectFidelityTests: XCTestCase {
private func makeDevice() throws -> MTLDevice {
guard let device = MTLCreateSystemDefaultDevice() else {
throw XCTSkip("no Metal device available")
}
return device
}
/// Render a centre blob from a fieldW×fieldH sim into a targetW×targetH
/// texture and return the brightness-weighted σx/σy of the lit pixels.
private func blobAspect(fieldW: Int, fieldH: Int, targetW: Int, targetH: Int,
cover: Bool, kaleidoscope: Int = 0) throws -> Double {
let device = try makeDevice()
let lib = ShaderLibrary(device: device)
var p = try TestFixtures.minimalPreset().params
p.agentCount = 8000
p.spawnPattern = .center
p.species[0].depositAmount = 5
p.trailSaturation = 10
p.exposure = 4
p.bloomIntensity = 0
p.feedbackAmount = 0
p.kaleidoscope = kaleidoscope
let engine = try SimEngine(device: device, library: lib, width: fieldW, height: fieldH, preset: p)
for _ in 0..<15 { engine.step() }
let renderer = try Renderer(device: device, library: lib, width: fieldW, height: fieldH)
let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm,
width: targetW, height: targetH, mipmapped: false)
d.usage = [.renderTarget, .shaderRead]
d.storageMode = .shared
guard let target = device.makeTexture(descriptor: d) else { throw ShaderError.compile("target") }
try renderer.renderOffscreen(sim: engine, preset: p, into: target, cover: cover)
var raw = [UInt8](repeating: 0, count: targetW * targetH * 4)
target.getBytes(&raw, bytesPerRow: targetW * 4,
from: MTLRegionMake2D(0, 0, targetW, targetH), mipmapLevel: 0)
var wSum = 0.0, mx = 0.0, my = 0.0
for y in 0..<targetH {
for x in 0..<targetW {
let i = (y * targetW + x) * 4
let lum = Double(raw[i]) + Double(raw[i + 1]) + Double(raw[i + 2])
if lum > 24 { wSum += lum; mx += lum * Double(x); my += lum * Double(y) }
}
}
XCTAssertGreaterThan(wSum, 0, "blob must be visible in the target")
mx /= wSum; my /= wSum
var vx = 0.0, vy = 0.0
for y in 0..<targetH {
for x in 0..<targetW {
let i = (y * targetW + x) * 4
let lum = Double(raw[i]) + Double(raw[i + 1]) + Double(raw[i + 2])
if lum > 24 {
vx += lum * (Double(x) - mx) * (Double(x) - mx)
vy += lum * (Double(y) - my) * (Double(y) - my)
}
}
}
return (vx / wSum).squareRoot() / (vy / wSum).squareRoot()
}
func testLandscapeFieldFitIntoTallTargetIsUndistorted() throws {
// .landscape phone field (910x512) fitted into a 2x iPhone drawable shape.
let r = try blobAspect(fieldW: 910, fieldH: 512, targetW: 860, targetH: 1864, cover: false)
XCTAssertEqual(r, 1.0, accuracy: 0.25, "fit must keep field pixels square (σx/σy = \(r))")
}
func testLandscapeFieldCoverIntoTallTargetIsUndistorted() throws {
let r = try blobAspect(fieldW: 910, fieldH: 512, targetW: 860, targetH: 1864, cover: true)
XCTAssertEqual(r, 1.0, accuracy: 0.25, "cover must keep field pixels square (σx/σy = \(r))")
}
func testPortraitFieldFitIntoWideTargetIsUndistorted() throws {
// .portrait field (512x910) fitted into a landscape drawable shape.
let r = try blobAspect(fieldW: 512, fieldH: 910, targetW: 1864, targetH: 860, cover: false)
XCTAssertEqual(r, 1.0, accuracy: 0.25, "fit must keep field pixels square (σx/σy = \(r))")
}
/// Kaleidoscope folds polar coordinates — on a non-square field a UV-space
/// fold renders every source circle as an ellipse stretched by the field
/// aspect (the "deutlich verzerrt" report: square looks right, portrait is
/// stretched tall, landscape wide). The fold must be aspect-corrected so a
/// centre blob mirrored through the kaleidoscope stays round on screen.
func testKaleidoscopeOnNonSquareFieldStaysRound() throws {
let r = try blobAspect(fieldW: 910, fieldH: 512, targetW: 860, targetH: 1864,
cover: false, kaleidoscope: 6)
XCTAssertEqual(r, 1.0, accuracy: 0.25,
"kaleidoscope fold must be isotropic in field pixels (σx/σy = \(r))")
}
}