import XCTest
import Metal
@testable import PhysarumEngine
/// Regression guard for the Metal feedback-echo VERTICAL FLIP (iPad-reported): the
/// fullscreen-triangle VS inverts V per Metal render→sample hop, so the ping-pong echo
/// recurrence accumulated a vertical flip and the echo ghost showed up mirrored relative
/// to the live scene. `feedback.metal` samples the scene at flipped V to cancel it
/// (Metal-only — NOT in the shared WGSL). Renders a static scene with feedback off vs on,
/// isolates the accumulated echo, and asserts it correlates with the scene, not its
/// vertical mirror. Robust to the random spawn (compares orientation, not absolute position).
final class FeedbackOrientationTests: XCTestCase {
private func lumField(_ tex: MTLTexture) -> ([Double], Int, Int) {
let w = tex.width, h = tex.height, bpr = w * 4
var raw = [UInt8](repeating: 0, count: bpr * h)
tex.getBytes(&raw, bytesPerRow: bpr, from: MTLRegionMake2D(0, 0, w, h), mipmapLevel: 0)
var lum = [Double](repeating: 0, count: w * h)
for p in 0..<(w * h) {
let i = p * 4
lum[p] = 0.114 * Double(raw[i]) + 0.587 * Double(raw[i + 1]) + 0.299 * Double(raw[i + 2])
}
return (lum, w, h)
}
func testFeedbackEchoAlignsWithSceneNotItsVerticalMirror() throws {
guard let device = MTLCreateSystemDefaultDevice() else { throw XCTSkip("no GPU") }
let lib = ShaderLibrary(device: device)
var preset = try TestFixtures.minimalPreset().params
preset.bloomIntensity = 0 // isolate the feedback echo
let field = 192
let r = try Renderer(device: device, library: lib, width: field, height: field)
let s = try SimEngine(device: device, library: lib, width: field, height: field, preset: preset)
let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: 384, height: 384, mipmapped: false)
d.usage = [.renderTarget, .shaderRead]; d.storageMode = .shared
let target = try XCTUnwrap(device.makeTexture(descriptor: d))
for _ in 0..<120 { s.step() } // evolve a pattern, then keep it static
r.resetFeedback()
preset.feedbackAmount = 0
try r.renderOffscreen(sim: s, preset: preset, into: target, cover: true)
let (scene, w, h) = lumField(target)
r.resetFeedback()
preset.feedbackAmount = 0.6
for _ in 0..<40 { try r.renderOffscreen(sim: s, preset: preset, into: target, cover: true) }
let (withFb, _, _) = lumField(target)
// Orientation is only testable if the scene is vertically ASYMMETRIC — a
// near-symmetric random pattern makes identity ≈ vflip and can't discriminate.
var asym = 0.0, energy = 0.0
for y in 0..<h { for x in 0..<w {
asym += abs(scene[y * w + x] - scene[(h - 1 - y) * w + x])
energy += scene[y * w + x]
} }
try XCTSkipIf(energy < 1 || asym / energy < 0.4,
"scene too vertically symmetric this run to test echo orientation")
func corr(_ map: (Int, Int) -> Int) -> Double {
var acc = 0.0
for y in 0..<h { for x in 0..<w {
let echo = max(0, withFb[y * w + x] - scene[y * w + x])
acc += echo * scene[map(x, y)]
} }
return acc
}
let identity = corr { x, y in y * w + x }
let vflip = corr { x, y in (h - 1 - y) * w + x }
XCTAssertGreaterThan(identity, vflip * 1.2,
"feedback echo correlates with the vertical MIRROR of the scene (identity=\(identity) vflip=\(vflip)) — feedback.metal scene-V flip regressed?")
}
}