From 9ff26d5a2a013f65503aeaf2d345296d02649dd7 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 17 Sep 2026 16:04:51 -0700 Subject: [PATCH] test: replace shell harnesses with native Swift 6 tests --- README.md | 9 +- ScreenSlanger.xcodeproj/project.pbxproj | 243 +++++++++++- .../xcschemes/ScreenSlanger.xcscheme | 22 +- ScreenSlanger.xctestplan | 30 ++ ScreenSlangerTests/CompilerProcessTests.swift | 44 +++ ScreenSlangerTests/ConfigSelectionTests.swift | 107 ++++++ .../Fixtures/passthrough.slang | 0 .../Fixtures/retroarch-background.slang | 0 .../Fixtures/retroarch-helper.slang | 0 ScreenSlangerTests/ShaderTests.swift | 277 ++++++++++++++ ShaderCompilerProbe/main.swift | 40 ++ Tests/ConfigSelection.swift | 96 ----- Tests/ShaderSmoke.swift | 348 ------------------ scripts/check-config.sh | 21 -- scripts/check-shaders.sh | 20 - 15 files changed, 761 insertions(+), 496 deletions(-) create mode 100644 ScreenSlanger.xctestplan create mode 100644 ScreenSlangerTests/CompilerProcessTests.swift create mode 100644 ScreenSlangerTests/ConfigSelectionTests.swift rename {Tests => ScreenSlangerTests}/Fixtures/passthrough.slang (100%) rename {Tests => ScreenSlangerTests}/Fixtures/retroarch-background.slang (100%) rename {Tests => ScreenSlangerTests}/Fixtures/retroarch-helper.slang (100%) create mode 100644 ScreenSlangerTests/ShaderTests.swift create mode 100644 ShaderCompilerProbe/main.swift delete mode 100644 Tests/ConfigSelection.swift delete mode 100644 Tests/ShaderSmoke.swift delete mode 100755 scripts/check-config.sh delete mode 100755 scripts/check-shaders.sh diff --git a/README.md b/README.md index 3c151c2..c6cda06 100644 --- a/README.md +++ b/README.md @@ -53,16 +53,15 @@ For a custom installation, `SLANG_PATH`, `GLSLANG_PATH`, and `SPIRV_CROSS_PATH` # Building from source -Use Xcode 27 with its macOS 27 SDK. Open `ScreenSlanger.xcodeproj`, select the **ScreenSlanger** scheme and **My Mac** destination, then build and run. The app retains macOS 26 as its minimum deployment version. There are no Swift package dependencies to resolve; the shader compilers above are runtime dependencies. +Use Xcode 27 with its Swift 6.4 compiler and macOS 27 SDK. All targets use Swift 6 language mode, including its strict concurrency checks. Open `ScreenSlanger.xcodeproj`, select the **ScreenSlanger** scheme and **My Mac** destination, then build and run. The app retains macOS 26 as its minimum deployment version. There are no Swift package dependencies to resolve; the shader compilers above are runtime dependencies. -Run the regression checks after updating the compilers: +Run the native Swift Testing suite after updating the compilers. In Xcode, use **Product → Test** (⌘U), or run: ```shell -./scripts/check-config.sh -./scripts/check-shaders.sh +xcodebuild test -project ScreenSlanger.xcodeproj -scheme ScreenSlanger -destination 'platform=macOS' ``` -The shader checks require a Mac with access to its Metal GPU. They compile the app's shader adapters, render known pixels through native Slang and RetroArch pipelines, and check texture loading, reloads, and compiler errors. The configuration checks verify display selection and compatibility with existing saved settings. Both scripts build in temporary directories. +The 17 tests require a Mac with access to its Metal GPU and the shader compilers installed above. They compile the app's production sources, render known pixels through native Slang and RetroArch pipelines, and check texture loading, reloads, compiler errors, and saved display selections. The test bundle runs without launching the app or reading or changing your saved settings. Xcode also builds a test-only Swift executable, `ShaderCompilerProbe`, to verify that large compiler output cannot deadlock the test runner. Fixtures are bundled, and generated files use temporary directories. # Usage diff --git a/ScreenSlanger.xcodeproj/project.pbxproj b/ScreenSlanger.xcodeproj/project.pbxproj index f95da15..eea2b09 100644 --- a/ScreenSlanger.xcodeproj/project.pbxproj +++ b/ScreenSlanger.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -18,8 +18,36 @@ 97ACF4542EF6CF50002B55FD /* config_view.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACF43F2EF6CF50002B55FD /* config_view.swift */; }; 97ACF4552EF6CF50002B55FD /* overlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACF4442EF6CF50002B55FD /* overlay.swift */; }; 97ACF4562EF6CF50002B55FD /* retroarch_shader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACF4462EF6CF50002B55FD /* retroarch_shader.swift */; }; + 97FE7FD0305CA7BE001601EC /* config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACF43E2EF6CF50002B55FD /* config.swift */; }; + 97FE7FD3305CA7DB001601EC /* renderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACF4452EF6CF50002B55FD /* renderer.swift */; }; + 97FE7FD4305CA7E9001601EC /* retroarch_shader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACF4462EF6CF50002B55FD /* retroarch_shader.swift */; }; + 97FE7FD5305CA7F9001601EC /* slang_compiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACF4492EF6CF50002B55FD /* slang_compiler.swift */; }; + 97FE7FD6305CA7F9001601EC /* slang_compiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACF4492EF6CF50002B55FD /* slang_compiler.swift */; }; + 97FE7FDF305CA843001601EC /* waves.slang in Resources */ = {isa = PBXBuildFile; fileRef = 97FE7FDE305CA843001601EC /* waves.slang */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 97FE7FE1305CA976001601EC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4159FE542D497D4400B79670 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97FE7FC6305CA6CD001601EC; + remoteInfo = ShaderCompilerProbe; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 97FE7FC5305CA6CD001601EC /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 4159FE5C2D497D4400B79670 /* ScreenSlanger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ScreenSlanger.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97ACF43D2EF6CF50002B55FD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -34,8 +62,17 @@ 97ACF4472EF6CF50002B55FD /* screen_capture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = screen_capture.swift; sourceTree = ""; }; 97ACF4492EF6CF50002B55FD /* slang_compiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = slang_compiler.swift; sourceTree = ""; }; 97ACF4572EF6D115002B55FD /* ScreenSlanger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ScreenSlanger.entitlements; sourceTree = ""; }; + 97FE7FBA305CA699001601EC /* ScreenSlangerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ScreenSlangerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 97FE7FC7305CA6CD001601EC /* ShaderCompilerProbe */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = ShaderCompilerProbe; sourceTree = BUILT_PRODUCTS_DIR; }; + 97FE7FDE305CA843001601EC /* waves.slang */ = {isa = PBXFileReference; lastKnownFileType = text; name = waves.slang; path = assets/waves.slang; sourceTree = SOURCE_ROOT; }; + 97FE7FE0305CA879001601EC /* ScreenSlanger.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = ScreenSlanger.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 97FE7FBB305CA699001601EC /* ScreenSlangerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ScreenSlangerTests; sourceTree = ""; }; + 97FE7FC8305CA6CD001601EC /* ShaderCompilerProbe */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ShaderCompilerProbe; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 4159FE592D497D4400B79670 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -44,13 +81,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 97FE7FB7305CA699001601EC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97FE7FC4305CA6CD001601EC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 4159FE532D497D4400B79670 = { isa = PBXGroup; children = ( + 97FE7FE0305CA879001601EC /* ScreenSlanger.xctestplan */, 97ACF44A2EF6CF50002B55FD /* ScreenSlanger */, + 97FE7FBB305CA699001601EC /* ScreenSlangerTests */, + 97FE7FC8305CA6CD001601EC /* ShaderCompilerProbe */, 4159FE5D2D497D4400B79670 /* Products */, ); indentWidth = 2; @@ -61,6 +115,8 @@ isa = PBXGroup; children = ( 4159FE5C2D497D4400B79670 /* ScreenSlanger.app */, + 97FE7FBA305CA699001601EC /* ScreenSlangerTests.xctest */, + 97FE7FC7305CA6CD001601EC /* ShaderCompilerProbe */, ); name = Products; sourceTree = ""; @@ -80,6 +136,7 @@ 97ACF4462EF6CF50002B55FD /* retroarch_shader.swift */, 97ACF4472EF6CF50002B55FD /* screen_capture.swift */, 97ACF4492EF6CF50002B55FD /* slang_compiler.swift */, + 97FE7FDE305CA843001601EC /* waves.slang */, ); path = ScreenSlanger; sourceTree = ""; @@ -106,6 +163,51 @@ productReference = 4159FE5C2D497D4400B79670 /* ScreenSlanger.app */; productType = "com.apple.product-type.application"; }; + 97FE7FB9305CA699001601EC /* ScreenSlangerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97FE7FC0305CA699001601EC /* Build configuration list for PBXNativeTarget "ScreenSlangerTests" */; + buildPhases = ( + 97FE7FB6305CA699001601EC /* Sources */, + 97FE7FB7305CA699001601EC /* Frameworks */, + 97FE7FB8305CA699001601EC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 97FE7FE2305CA976001601EC /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 97FE7FBB305CA699001601EC /* ScreenSlangerTests */, + ); + name = ScreenSlangerTests; + packageProductDependencies = ( + ); + productName = ScreenSlangerTests; + productReference = 97FE7FBA305CA699001601EC /* ScreenSlangerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97FE7FC6305CA6CD001601EC /* ShaderCompilerProbe */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97FE7FCB305CA6CD001601EC /* Build configuration list for PBXNativeTarget "ShaderCompilerProbe" */; + buildPhases = ( + 97FE7FC3305CA6CD001601EC /* Sources */, + 97FE7FC4305CA6CD001601EC /* Frameworks */, + 97FE7FC5305CA6CD001601EC /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 97FE7FC8305CA6CD001601EC /* ShaderCompilerProbe */, + ); + name = ShaderCompilerProbe; + packageProductDependencies = ( + ); + productName = ShaderCompilerProbe; + productReference = 97FE7FC7305CA6CD001601EC /* ShaderCompilerProbe */; + productType = "com.apple.product-type.tool"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -113,12 +215,18 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1520; + LastSwiftUpdateCheck = 2700; LastUpgradeCheck = 1520; TargetAttributes = { 4159FE5B2D497D4400B79670 = { CreatedOnToolsVersion = 15.2; }; + 97FE7FB9305CA699001601EC = { + CreatedOnToolsVersion = 27.0; + }; + 97FE7FC6305CA6CD001601EC = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = 4159FE572D497D4400B79670 /* Build configuration list for PBXProject "ScreenSlanger" */; @@ -137,6 +245,8 @@ projectRoot = ""; targets = ( 4159FE5B2D497D4400B79670 /* ScreenSlanger */, + 97FE7FB9305CA699001601EC /* ScreenSlangerTests */, + 97FE7FC6305CA6CD001601EC /* ShaderCompilerProbe */, ); }; /* End PBXProject section */ @@ -150,6 +260,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 97FE7FB8305CA699001601EC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97FE7FDF305CA843001601EC /* waves.slang in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -170,8 +288,35 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 97FE7FB6305CA699001601EC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97FE7FD3305CA7DB001601EC /* renderer.swift in Sources */, + 97FE7FD0305CA7BE001601EC /* config.swift in Sources */, + 97FE7FD4305CA7E9001601EC /* retroarch_shader.swift in Sources */, + 97FE7FD5305CA7F9001601EC /* slang_compiler.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97FE7FC3305CA6CD001601EC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97FE7FD6305CA7F9001601EC /* slang_compiler.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 97FE7FE2305CA976001601EC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97FE7FC6305CA6CD001601EC /* ShaderCompilerProbe */; + targetProxy = 97FE7FE1305CA976001601EC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 4159FE672D497D4600B79670 /* Debug */ = { isa = XCBuildConfiguration; @@ -320,7 +465,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -353,7 +498,79 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + 97FE7FBE305CA699001601EC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.github.crutchcorn.ScreenSlangerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = macosx; + SWIFT_APPROACHABLE_CONCURRENCY = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = NO; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 27.0; + }; + name = Debug; + }; + 97FE7FBF305CA699001601EC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.github.crutchcorn.ScreenSlangerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = macosx; + SWIFT_APPROACHABLE_CONCURRENCY = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = NO; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 27.0; + }; + name = Release; + }; + 97FE7FCC305CA6CD001601EC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = NO; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 97FE7FCD305CA6CD001601EC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = NO; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -378,6 +595,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 97FE7FC0305CA699001601EC /* Build configuration list for PBXNativeTarget "ScreenSlangerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97FE7FBE305CA699001601EC /* Debug */, + 97FE7FBF305CA699001601EC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97FE7FCB305CA6CD001601EC /* Build configuration list for PBXNativeTarget "ShaderCompilerProbe" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97FE7FCC305CA6CD001601EC /* Debug */, + 97FE7FCD305CA6CD001601EC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 4159FE542D497D4400B79670 /* Project object */; diff --git a/ScreenSlanger.xcodeproj/xcshareddata/xcschemes/ScreenSlanger.xcscheme b/ScreenSlanger.xcodeproj/xcshareddata/xcschemes/ScreenSlanger.xcscheme index 3f4609a..d22fd40 100644 --- a/ScreenSlanger.xcodeproj/xcshareddata/xcschemes/ScreenSlanger.xcscheme +++ b/ScreenSlanger.xcodeproj/xcshareddata/xcschemes/ScreenSlanger.xcscheme @@ -21,14 +21,32 @@ ReferencedContainer = "container:ScreenSlanger.xcodeproj"> + + + + + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + Config { + // Match the version 4 format, before displaySelectionIsExplicit existed. + let data = try JSONSerialization.data(withJSONObject: [ + "configVersion": 4, + "shaderPath": "/tmp/example.slangp", + "active": true, + "targetFPS": 120, + "shaderParameters": ["/tmp/example.slangp": ["GAIN": 0.75]], + "enabledDisplayIDs": displayIDs + ]) + return try JSONDecoder().decode(Config.self, from: data) + } + + private func roundTrip(_ config: Config) throws -> Config { + // Exercise persistence entirely in memory, without touching saved user settings. + try JSONDecoder().decode(Config.self, from: JSONEncoder().encode(config)) + } +} diff --git a/Tests/Fixtures/passthrough.slang b/ScreenSlangerTests/Fixtures/passthrough.slang similarity index 100% rename from Tests/Fixtures/passthrough.slang rename to ScreenSlangerTests/Fixtures/passthrough.slang diff --git a/Tests/Fixtures/retroarch-background.slang b/ScreenSlangerTests/Fixtures/retroarch-background.slang similarity index 100% rename from Tests/Fixtures/retroarch-background.slang rename to ScreenSlangerTests/Fixtures/retroarch-background.slang diff --git a/Tests/Fixtures/retroarch-helper.slang b/ScreenSlangerTests/Fixtures/retroarch-helper.slang similarity index 100% rename from Tests/Fixtures/retroarch-helper.slang rename to ScreenSlangerTests/Fixtures/retroarch-helper.slang diff --git a/ScreenSlangerTests/ShaderTests.swift b/ScreenSlangerTests/ShaderTests.swift new file mode 100644 index 0000000..ed0f41f --- /dev/null +++ b/ScreenSlangerTests/ShaderTests.swift @@ -0,0 +1,277 @@ +import CoreGraphics +import Foundation +import ImageIO +import Metal +import Testing + +private final class ShaderFixtureBundle: NSObject {} + +@Suite("Shader rendering and reloads", .serialized) +@MainActor +struct ShaderTests { + // BGRA bytes for red, green, blue, and white. Distinct texels catch both + // incorrect texture coordinates and missing texture bindings. + private let inputPixels: [UInt8] = [ + 0, 0, 255, 255, 0, 255, 0, 255, + 255, 0, 0, 255, 255, 255, 255, 255, + ] + + @Test("waves.slang compiles to a Metal render pipeline") + func wavesCompiles() throws { + let device = try #require(MTLCreateSystemDefaultDevice(), "A Metal device is required") + _ = try MetalRenderer.buildRenderPipeline(device: device, effectSource: fixtureSource("waves")) + } + + @Test("Slang texture sampling preserves all four texels") + func slangTextureSampling() throws { + let device = try #require(MTLCreateSystemDefaultDevice(), "A Metal device is required") + let pipeline = try MetalRenderer.buildRenderPipeline( + device: device, effectSource: fixtureSource("passthrough")) + let output = try render(device: device, pipeline: pipeline, pixels: inputPixels) { encoder in + var uniforms = SlangUniforms(screenSize: SIMD2(2, 2), mousePosition: .zero, time: 0) + encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0) + } + try expectPixels(output, expected: inputPixels) + } + + @Test("RetroArch helper, texture binding, and push parameter render correctly") + func retroArchParametersAndSampling() throws { + let device = try #require(MTLCreateSystemDefaultDevice(), "A Metal device is required") + let fixture = try fixtureURL("retroarch-helper") + let source = try String(contentsOf: fixture, encoding: .utf8) + let (pipeline, parameters, samplers) = try MetalRenderer.buildRetroArchPipeline( + device: device, effectSource: source, shaderDirectory: fixture.deletingLastPathComponent()) + try #require(parameters.count == 1, "Expected one GAIN parameter") + #expect(parameters[0].name == "GAIN") + #expect(parameters[0].defaultValue == 0.5) + let sourceSampler = try #require( + samplers.first(where: { $0.name == "Source" }), "Generated Metal lost the Source sampler binding") + let output = try render( + device: device, pipeline: pipeline, pixels: inputPixels, textureIndex: sourceSampler.binding + ) { encoder in + // Match the fixture's std430 layout: a float, padding, then three vec4s. + let push: [Float] = [parameters[0].defaultValue, 0, 0, 0, + 2, 2, 0.5, 0.5, 2, 2, 0.5, 0.5, 2, 2, 0.5, 0.5] + push.withUnsafeBytes { bytes in + encoder.setFragmentBytes(bytes.baseAddress!, length: bytes.count, index: 0) + } + } + let expected: [UInt8] = [ + 0, 0, 128, 255, 0, 128, 0, 255, + 128, 0, 0, 255, 128, 128, 128, 255, + ] + try expectPixels(output, expected: expected) + } + + @Test("Preset grayscale textures sample as opaque grayscale") + func grayscalePresetTexture() throws { + try expectPresetTexture(grayscale: true) + } + + @Test("Preset RGB textures preserve their color channels") + func colorPresetTexture() throws { + try expectPresetTexture(grayscale: false) + } + + @Test("Preset reload recompiles an edited shader at the same path") + func presetReload() throws { + try expectPresetTexture(grayscale: true, reload: true) + } + + @Test("Failed shader replacement clears the pipeline and retries still fail") + func failedShaderReplacementAndRecovery() throws { + let shared = SharedMetalResources.shared + defer { try? shared.setEffectSource(nil) } + let fixture = try fixtureURL("passthrough") + let source = try String(contentsOf: fixture, encoding: .utf8) + try shared.setEffectSource(source, shaderPath: fixture.path) + try #require(shared.renderPipeline != nil, "Valid shader did not create a pipeline") + for attempt in 1...2 { + #expect(throws: (any Error).self, "Invalid shader attempt \(attempt) must not reuse a cached pipeline") { + try shared.setEffectSource( + "float4 shaderFunction(ShaderInput input) { return missingSmokeSymbol; }", + shaderPath: fixture.path) + } + #expect(shared.renderPipeline == nil, "Failed shader left a stale render pipeline active") + } + try shared.setEffectSource(source, shaderPath: fixture.path) + #expect(shared.renderPipeline != nil, "Valid shader could not recover after compilation failed") + } + + @Test("Changed shader content at the same path produces a new pipeline") + func changedShaderContent() throws { + let shared = SharedMetalResources.shared + defer { try? shared.setEffectSource(nil) } + let fixture = try fixtureURL("passthrough") + let source = try String(contentsOf: fixture, encoding: .utf8) + try shared.setEffectSource(source, shaderPath: fixture.path) + let previous = try #require(shared.renderPipeline) + try shared.setEffectSource( + "float4 shaderFunction(ShaderInput input) { return float4(0.0, 1.0, 0.0, 1.0); }", + shaderPath: fixture.path) + let pipeline = try #require(shared.renderPipeline, "Edited shader did not create a pipeline") + #expect(pipeline !== previous, "Edited shader reused the previous pipeline") + let output = try render(device: shared.device, pipeline: pipeline, pixels: inputPixels) { _ in } + let green: [UInt8] = [0, 255, 0, 255] + try expectPixels(output, expected: Array(repeating: green, count: 4).flatMap { $0 }) + } + + @Test("Invalid Slang returns a compiler diagnostic") + func invalidSlangDiagnostic() throws { + do { + _ = try SlangCompiler.compileToMetal(slangSource: SlangCompiler.wrapEffectSource( + "float4 shaderFunction(ShaderInput input) { return missingSmokeSymbol; }")) + Issue.record("Invalid Slang unexpectedly compiled") + } catch SlangCompilerError.compilationFailed(let diagnostic) { + #expect(diagnostic.contains("missingSmokeSymbol"), "Diagnostic did not identify the invalid symbol: \(diagnostic)") + } + } + + @Test("Invalid RetroArch GLSL returns a compiler diagnostic") + func invalidRetroArchDiagnostic() throws { + let source = try fixtureSource("retroarch-helper") + do { + _ = try RetroArchShaderCompiler.compileToMetal( + source: source.replacingOccurrences(of: "color * gain", with: "missingSmokeSymbol"), + shaderDirectory: nil) + Issue.record("Invalid GLSL unexpectedly compiled") + } catch RetroArchShaderError.glslCompilationFailed(let diagnostic) { + #expect(diagnostic.contains("missingSmokeSymbol"), "Diagnostic did not identify the invalid symbol: \(diagnostic)") + } + } + + private func fixtureURL(_ name: String) throws -> URL { + let bundle = Bundle(for: ShaderFixtureBundle.self) + return try #require( + bundle.url(forResource: name, withExtension: "slang", subdirectory: "Fixtures") + ?? bundle.url(forResource: name, withExtension: "slang"), + "Missing bundled shader fixture: \(name).slang") + } + + private func fixtureSource(_ name: String) throws -> String { + try String(contentsOf: fixtureURL(name), encoding: .utf8) + } + + private func expectPresetTexture(grayscale: Bool, reload: Bool = false) throws { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temporary) } + try FileManager.default.copyItem( + at: fixtureURL("retroarch-background"), to: temporary.appendingPathComponent("background.slang")) + + // Tiny PNGs exercise MTKTextureLoader and the app's preset texture handling, + // including its single-channel texture view. + let data = grayscale ? Data(repeating: 128, count: 4) + : Data([255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255]) + let colorSpace = grayscale ? CGColorSpaceCreateDeviceGray() : CGColorSpaceCreateDeviceRGB() + let bitmapInfo = grayscale ? CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + : CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).union(.byteOrder32Big) + let provider = try #require(CGDataProvider(data: data as CFData)) + let image = try #require(CGImage( + width: 2, height: 2, bitsPerComponent: 8, + bitsPerPixel: grayscale ? 8 : 32, bytesPerRow: grayscale ? 2 : 8, + space: colorSpace, bitmapInfo: bitmapInfo, provider: provider, + decode: nil, shouldInterpolate: false, intent: .defaultIntent)) + let destination = try #require(CGImageDestinationCreateWithURL( + temporary.appendingPathComponent("background.png") as CFURL, "public.png" as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + try #require(CGImageDestinationFinalize(destination), "Could not write PNG texture fixture") + + let preset = """ + shaders = 1 + shader0 = "background.slang" + textures = "BACKGROUND" + BACKGROUND = "background.png" + """ + let presetURL = temporary.appendingPathComponent("background.slangp") + try preset.write(to: presetURL, atomically: true, encoding: .utf8) + let shared = SharedMetalResources.shared + defer { try? shared.setEffectSource(nil) } + try shared.setEffectSource(preset, shaderPath: presetURL.path) + let texture = try #require(shared.getTexture(named: "BACKGROUND"), "Preset did not load its texture") + let pipeline = try #require(shared.renderPipeline, "Preset did not create its pipeline") + let binding = try #require( + shared.getTextureSamplers().first(where: { $0.name == "BACKGROUND" })?.binding, + "Preset did not create its texture sampler") + let output = try render( + device: shared.device, pipeline: pipeline, pixels: [], textureIndex: binding, sourceTexture: texture + ) { _ in } + let pixel: [UInt8] = grayscale ? [128, 128, 128, 255] : [0, 0, 255, 255] + try expectPixels(output, expected: Array(repeating: pixel, count: 4).flatMap { $0 }) + + if reload { + let shaderURL = temporary.appendingPathComponent("background.slang") + let originalSource = try String(contentsOf: shaderURL, encoding: .utf8) + try originalSource.replacingOccurrences( + of: "texture(BACKGROUND, vTexCoord)", with: "vec4(0.0, 1.0, 0.0, 1.0)" + ).write(to: shaderURL, atomically: true, encoding: .utf8) + shared.invalidateEffect() + try shared.setEffectSource(preset, shaderPath: presetURL.path) + let reloadedPipeline = try #require(shared.renderPipeline, "Preset reload did not create a pipeline") + #expect(reloadedPipeline !== pipeline, "Preset reload reused the previous shader pipeline") + let reloadedOutput = try render( + device: shared.device, pipeline: reloadedPipeline, pixels: [], sourceTexture: texture + ) { _ in } + let green: [UInt8] = [0, 255, 0, 255] + try expectPixels(reloadedOutput, expected: Array(repeating: green, count: 4).flatMap { $0 }) + } + } + + private func expectPixels(_ actual: [UInt8], expected: [UInt8]) throws { + try #require(actual.count == expected.count, "Pixel output length mismatch") + for index in actual.indices { + #expect(abs(Int(actual[index]) - Int(expected[index])) <= 1, + "Pixel bytes differ at channel \(index): got \(actual), expected \(expected)") + } + } + + private func render( + device: MTLDevice, + pipeline: MTLRenderPipelineState, + pixels: [UInt8], + textureIndex: Int = 0, + sourceTexture: MTLTexture? = nil, + uniforms: (MTLRenderCommandEncoder) -> Void + ) throws -> [UInt8] { + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .bgra8Unorm, width: 2, height: 2, mipmapped: false) + descriptor.storageMode = .shared + descriptor.usage = [.shaderRead, .renderTarget] + let source = try #require(sourceTexture ?? device.makeTexture(descriptor: descriptor)) + let destination = try #require(device.makeTexture(descriptor: descriptor)) + let queue = try #require(device.makeCommandQueue()) + let commands = try #require(queue.makeCommandBuffer()) + let region = MTLRegionMake2D(0, 0, 2, 2) + if sourceTexture == nil { + try #require(pixels.count == 16, "Rendering a 2×2 BGRA texture requires 16 bytes") + pixels.withUnsafeBytes { bytes in + source.replace(region: region, mipmapLevel: 0, withBytes: bytes.baseAddress!, bytesPerRow: 8) + } + } + let samplerDescriptor = MTLSamplerDescriptor() + samplerDescriptor.minFilter = .nearest + samplerDescriptor.magFilter = .nearest + samplerDescriptor.sAddressMode = .clampToEdge + samplerDescriptor.tAddressMode = .clampToEdge + let sampler = try #require(device.makeSamplerState(descriptor: samplerDescriptor)) + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = destination + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].storeAction = .store + let encoder = try #require(commands.makeRenderCommandEncoder(descriptor: pass)) + encoder.setRenderPipelineState(pipeline) + encoder.setFragmentTexture(source, index: textureIndex) + encoder.setFragmentSamplerState(sampler, index: textureIndex) + uniforms(encoder) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + encoder.endEncoding() + commands.commit() + commands.waitUntilCompleted() + if let error = commands.error { throw error } + var output = [UInt8](repeating: 0, count: 16) + output.withUnsafeMutableBytes { bytes in + destination.getBytes(bytes.baseAddress!, bytesPerRow: 8, from: region, mipmapLevel: 0) + } + return output + } +} diff --git a/ShaderCompilerProbe/main.swift b/ShaderCompilerProbe/main.swift new file mode 100644 index 0000000..e3b709b --- /dev/null +++ b/ShaderCompilerProbe/main.swift @@ -0,0 +1,40 @@ +import Darwin +import Foundation + +let streamLength = 262_144 +let sentinel = "largeDiagnosticSentinel" + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("\(message)\n".utf8)) + exit(1) +} + +if CommandLine.arguments.dropFirst().first == "--verify" { + // Keep the verifier and its compiler child in one group so the test can + // clean up both if compiler output handling ever blocks again. + guard getpgrp() == getpid() || setpgid(0, 0) == 0 else { + fail("Could not isolate the compiler probe process group") + } + guard setenv("SLANG_PATH", CommandLine.arguments[0], 1) == 0 else { + fail("Could not configure the probe compiler") + } + do { + _ = try SlangCompiler.compileToMetal(slangSource: "deliberately invalid shader") + fail("Probe compiler unexpectedly succeeded") + } catch SlangCompilerError.compilationFailed(let diagnostic) { + guard diagnostic.contains(String(repeating: "o", count: streamLength)), + diagnostic.contains(String(repeating: "e", count: streamLength)), + diagnostic.contains(sentinel) else { + fail("Compiler diagnostic lost or truncated an output stream") + } + } catch { + fail("Unexpected compiler failure: \(error.localizedDescription)") + } +} else { + // The production compiler invokes this executable as its mock slangc. + // Both streams exceed a pipe's capacity, exposing wait-before-drain bugs. + FileHandle.standardOutput.write(Data(repeating: Character("o").asciiValue!, count: streamLength)) + FileHandle.standardError.write(Data(repeating: Character("e").asciiValue!, count: streamLength)) + FileHandle.standardError.write(Data("\n\(sentinel)\n".utf8)) + exit(1) +} diff --git a/Tests/ConfigSelection.swift b/Tests/ConfigSelection.swift deleted file mode 100644 index e4fa41c..0000000 --- a/Tests/ConfigSelection.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Foundation - -private struct SelectionFailure: Error, LocalizedError { - let message: String - var errorDescription: String? { message } -} - -private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { - if !condition() { throw SelectionFailure(message: message) } -} - -@main -struct ConfigSelection { - static func main() { - do { - try run() - } catch { - FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) - exit(1) - } - } - - private static func legacyConfig(displayIDs: [UInt32]) throws -> Config { - // Match the version 4 format, before displaySelectionIsExplicit existed. - let data = try JSONSerialization.data(withJSONObject: [ - "configVersion": 4, - "shaderPath": "/tmp/example.slangp", - "active": true, - "targetFPS": 120, - "shaderParameters": ["/tmp/example.slangp": ["GAIN": 0.75]], - "enabledDisplayIDs": displayIDs - ]) - return try JSONDecoder().decode(Config.self, from: data) - } - - private static func roundTrip(_ config: Config) throws -> Config { - try JSONDecoder().decode(Config.self, from: JSONEncoder().encode(config)) - } - - private static func run() throws { - let defaults = Config() - try require(defaults.isDisplayEnabled(1) && defaults.isDisplayEnabled(2), - "A new configuration should enable every display") - - let legacyAll = try legacyConfig(displayIDs: []) - try require(legacyAll.isDisplayEnabled(1) && legacyAll.isDisplayEnabled(2), - "A legacy empty selection should still enable every display") - let legacyAllReloaded = try roundTrip(legacyAll) - try require(legacyAllReloaded.isDisplayEnabled(3), - "Saving an untouched legacy default should still enable future displays") - try require(legacyAllReloaded.shaderPath == "/tmp/example.slangp" - && legacyAllReloaded.active && legacyAllReloaded.targetFPS == 120 - && legacyAllReloaded.getParameterValue(name: "GAIN") == 0.75, - "Reading and writing a legacy configuration changed unrelated preferences") - print("PASS: new and legacy default selections enable all displays without changing preferences") - - let legacySelected = try legacyConfig(displayIDs: [1]) - try require(legacySelected.isDisplayEnabled(1) && !legacySelected.isDisplayEnabled(2), - "A legacy nonempty selection should remain restricted to its saved displays") - let legacySelectedReloaded = try roundTrip(legacySelected) - try require(legacySelectedReloaded.isDisplayEnabled(1) && !legacySelectedReloaded.isDisplayEnabled(2), - "Saving a legacy explicit selection changed its enabled displays") - print("PASS: legacy nonempty selections retain their saved displays") - - defaults.toggleDisplay(1, availableDisplayIDs: [1]) - try require(!defaults.isDisplayEnabled(1) && !defaults.isDisplayEnabled(2), - "Disabling the only display should leave every display disabled") - let disabledReloaded = try roundTrip(defaults) - try require(!disabledReloaded.isDisplayEnabled(1) && !disabledReloaded.isDisplayEnabled(2), - "An explicit empty selection should survive saving and reloading") - disabledReloaded.toggleDisplay(1, availableDisplayIDs: [1]) - try require(disabledReloaded.isDisplayEnabled(1) && !disabledReloaded.isDisplayEnabled(2), - "Re-enabling one display should not enable every display") - print("PASS: the only display can be disabled, persisted, and re-enabled") - - legacySelected.toggleDisplay(1, availableDisplayIDs: [1, 2]) - try require(!legacySelected.isDisplayEnabled(1) && !legacySelected.isDisplayEnabled(2), - "Removing the last legacy-selected display should not restore all displays") - let legacyDisabledReloaded = try roundTrip(legacySelected) - try require(!legacyDisabledReloaded.isDisplayEnabled(1) && !legacyDisabledReloaded.isDisplayEnabled(2), - "A legacy selection explicitly cleared by the user should remain empty on reload") - print("PASS: clearing the last legacy-selected display persists an empty selection") - - legacyAll.toggleDisplay(1, availableDisplayIDs: [1, 2]) - try require(!legacyAll.isDisplayEnabled(1) && legacyAll.isDisplayEnabled(2), - "Disabling one of two default displays should leave the other enabled") - legacyAll.toggleDisplay(2, availableDisplayIDs: [1, 2]) - try require(!legacyAll.isDisplayEnabled(1) && !legacyAll.isDisplayEnabled(2), - "Disabling the second display should leave both disabled") - legacyAll.toggleDisplay(1, availableDisplayIDs: [1, 2]) - try require(legacyAll.isDisplayEnabled(1) && !legacyAll.isDisplayEnabled(2), - "Re-enabling one of two displays should leave the other disabled") - print("PASS: multiple displays toggle independently through an empty selection") - print("All configuration selection checks passed.") - } -} diff --git a/Tests/ShaderSmoke.swift b/Tests/ShaderSmoke.swift deleted file mode 100644 index 6476eab..0000000 --- a/Tests/ShaderSmoke.swift +++ /dev/null @@ -1,348 +0,0 @@ -import Foundation -import CoreGraphics -import ImageIO -import Metal - -private struct SmokeFailure: Error, LocalizedError { - let message: String - var errorDescription: String? { message } -} - -private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { - if !condition() { throw SmokeFailure(message: message) } -} - -@main -struct ShaderSmoke { - static func main() { - do { - try run() - } catch { - FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) - exit(1) - } - } - - private static func run() throws { - if CommandLine.arguments.dropFirst().first == "--large-diagnostic-child" { - do { - _ = try SlangCompiler.compileToMetal(slangSource: "deliberately invalid shader") - throw SmokeFailure(message: "Mock compiler unexpectedly succeeded") - } catch SlangCompilerError.compilationFailed(let diagnostic) { - try require(diagnostic.count > 262_144 && diagnostic.contains("largeDiagnosticSentinel"), - "Compiler output was truncated or lost") - } - return - } - guard CommandLine.arguments.count == 2 else { - throw SmokeFailure(message: "Usage: check-shaders ") - } - let root = URL(fileURLWithPath: CommandLine.arguments[1]) - guard let device = MTLCreateSystemDefaultDevice() else { - throw SmokeFailure(message: "A Metal device is required for the shader checks.") - } - print("Metal device: \(device.name)") - print("Slang compiler: \(SlangCompiler.findSlangc() ?? "missing")") - print("glslang: \(RetroArchShaderCompiler.findGlslang() ?? "missing")") - print("SPIRV-Cross: \(RetroArchShaderCompiler.findSpirvCross() ?? "missing")") - - func read(_ path: String) throws -> String { - try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) - } - - var failures: [String] = [] - func check(_ name: String, _ body: () throws -> Void) { - do { - try body() - print("PASS: \(name)") - } catch { - failures.append(name) - print("FAIL: \(name): \(error.localizedDescription)") - } - } - - check("waves.slang compiles to a Metal render pipeline") { - _ = try MetalRenderer.buildRenderPipeline(device: device, effectSource: read("assets/waves.slang")) - } - - // BGRA bytes: red, green, blue, white. The four different texels detect - // incorrect texture coordinates as well as missing texture bindings. - let input: [UInt8] = [0, 0, 255, 255, 0, 255, 0, 255, 255, 0, 0, 255, 255, 255, 255, 255] - check("Slang texture sampling preserves all four texels") { - let pipeline = try MetalRenderer.buildRenderPipeline( - device: device, effectSource: read("Tests/Fixtures/passthrough.slang")) - let output = try render(device: device, pipeline: pipeline, pixels: input) { encoder in - var uniforms = SlangUniforms(screenSize: SIMD2(2, 2), mousePosition: .zero, time: 0) - encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0) - } - try checkPixels(output, expected: input) - } - - let retroSource = try read("Tests/Fixtures/retroarch-helper.slang") - check("RetroArch helper, texture binding, and push parameter render correctly") { - let (pipeline, parameters, samplers) = try MetalRenderer.buildRetroArchPipeline( - device: device, effectSource: retroSource, shaderDirectory: root.appendingPathComponent("Tests/Fixtures")) - try require(parameters.count == 1 && parameters[0].name == "GAIN", "Missing GAIN parameter") - try require(parameters[0].defaultValue == 0.5, "Wrong GAIN default") - guard let sourceSampler = samplers.first(where: { $0.name == "Source" }) else { - throw SmokeFailure(message: "Generated Metal lost the Source sampler binding") - } - let output = try render(device: device, pipeline: pipeline, pixels: input, textureIndex: sourceSampler.binding) { encoder in - // Match the fixture's std430 layout: a float, padding, then three vec4s. - let push: [Float] = [parameters[0].defaultValue, 0, 0, 0, - 2, 2, 0.5, 0.5, 2, 2, 0.5, 0.5, 2, 2, 0.5, 0.5] - push.withUnsafeBytes { bytes in - encoder.setFragmentBytes(bytes.baseAddress!, length: bytes.count, index: 0) - } - } - let expected: [UInt8] = [0, 0, 128, 255, 0, 128, 0, 255, 128, 0, 0, 255, 128, 128, 128, 255] - try checkPixels(output, expected: expected) - } - - check("preset grayscale textures sample as opaque grayscale") { - try checkPresetTexture(root: root, grayscale: true) - } - - check("preset RGB textures preserve their color channels") { - try checkPresetTexture(root: root, grayscale: false) - } - - check("preset reload recompiles an edited shader at the same path") { - try checkPresetTexture(root: root, grayscale: true, reload: true) - } - - check("failed shader replacement clears the pipeline and retries still fail") { - let shared = SharedMetalResources.shared - defer { try? shared.setEffectSource(nil) } - let path = root.appendingPathComponent("Tests/Fixtures/passthrough.slang").path - try shared.setEffectSource(read("Tests/Fixtures/passthrough.slang"), shaderPath: path) - try require(shared.renderPipeline != nil, "Valid shader did not create a pipeline") - for attempt in 1...2 { - var rejected = false - do { - try shared.setEffectSource("float4 shaderFunction(ShaderInput input) { return missingSmokeSymbol; }", shaderPath: path) - } catch { - rejected = true - } - try require(rejected, "Invalid shader attempt \(attempt) reused a cached pipeline") - try require(shared.renderPipeline == nil, "Failed shader left a stale render pipeline active") - } - try shared.setEffectSource(read("Tests/Fixtures/passthrough.slang"), shaderPath: path) - try require(shared.renderPipeline != nil, "Valid shader could not recover after compilation failed") - } - - check("changed shader content at the same path produces a new pipeline") { - let shared = SharedMetalResources.shared - defer { try? shared.setEffectSource(nil) } - let path = root.appendingPathComponent("Tests/Fixtures/passthrough.slang").path - try shared.setEffectSource(read("Tests/Fixtures/passthrough.slang"), shaderPath: path) - let previous = shared.renderPipeline - try shared.setEffectSource( - "float4 shaderFunction(ShaderInput input) { return float4(0.0, 1.0, 0.0, 1.0); }", shaderPath: path) - guard let pipeline = shared.renderPipeline else { - throw SmokeFailure(message: "Edited shader did not create a pipeline") - } - try require(pipeline !== previous, "Edited shader reused the previous pipeline") - let output = try render(device: shared.device, pipeline: pipeline, pixels: input) { _ in } - let green: [UInt8] = [0, 255, 0, 255] - try checkPixels(output, expected: Array(repeating: green, count: 4).flatMap { $0 }) - } - - check("invalid Slang returns a compiler diagnostic") { - do { - _ = try SlangCompiler.compileToMetal(slangSource: SlangCompiler.wrapEffectSource( - "float4 shaderFunction(ShaderInput input) { return missingSmokeSymbol; }")) - throw SmokeFailure(message: "Invalid Slang unexpectedly compiled") - } catch SlangCompilerError.compilationFailed(let diagnostic) { - try require(diagnostic.contains("missingSmokeSymbol"), "Diagnostic did not identify the invalid symbol: \(diagnostic)") - } - } - - check("invalid RetroArch GLSL returns a compiler diagnostic") { - do { - _ = try RetroArchShaderCompiler.compileToMetal( - source: retroSource.replacingOccurrences(of: "color * gain", with: "missingSmokeSymbol"), shaderDirectory: nil) - throw SmokeFailure(message: "Invalid GLSL unexpectedly compiled") - } catch RetroArchShaderError.glslCompilationFailed(let diagnostic) { - try require(diagnostic.contains("missingSmokeSymbol"), "Diagnostic did not identify the invalid symbol: \(diagnostic)") - } - } - - check("large compiler output returns without a pipe deadlock") { - try checkLargeDiagnostic() - } - - if !failures.isEmpty { - throw SmokeFailure(message: "\(failures.count) shader smoke check(s) failed: \(failures.joined(separator: ", "))") - } - print("All shader smoke checks passed.") - } - - private static func checkPresetTexture(root: URL, grayscale: Bool, reload: Bool = false) throws { - let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: temporary) } - try FileManager.default.copyItem( - at: root.appendingPathComponent("Tests/Fixtures/retroarch-background.slang"), - to: temporary.appendingPathComponent("background.slang")) - - // Create tiny PNGs so this test exercises MTKTextureLoader and the app's - // preset texture handling, including its single-channel texture view. - let data = grayscale ? Data(repeating: 128, count: 4) : Data([255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255]) - let colorSpace = grayscale ? CGColorSpaceCreateDeviceGray() : CGColorSpaceCreateDeviceRGB() - let bitmapInfo = grayscale ? CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) - : CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).union(.byteOrder32Big) - guard let provider = CGDataProvider(data: data as CFData), - let image = CGImage(width: 2, height: 2, bitsPerComponent: 8, - bitsPerPixel: grayscale ? 8 : 32, bytesPerRow: grayscale ? 2 : 8, - space: colorSpace, bitmapInfo: bitmapInfo, provider: provider, - decode: nil, shouldInterpolate: false, intent: .defaultIntent), - let destination = CGImageDestinationCreateWithURL( - temporary.appendingPathComponent("background.png") as CFURL, "public.png" as CFString, 1, nil) else { - throw SmokeFailure(message: "Could not create PNG texture fixture") - } - CGImageDestinationAddImage(destination, image, nil) - try require(CGImageDestinationFinalize(destination), "Could not write PNG texture fixture") - - let preset = """ - shaders = 1 - shader0 = "background.slang" - textures = "BACKGROUND" - BACKGROUND = "background.png" - """ - let presetURL = temporary.appendingPathComponent("background.slangp") - try preset.write(to: presetURL, atomically: true, encoding: .utf8) - let shared = SharedMetalResources.shared - defer { try? shared.setEffectSource(nil) } - try shared.setEffectSource(preset, shaderPath: presetURL.path) - guard let texture = shared.getTexture(named: "BACKGROUND"), let pipeline = shared.renderPipeline, - let binding = shared.getTextureSamplers().first(where: { $0.name == "BACKGROUND" })?.binding else { - throw SmokeFailure(message: "Preset failed to load its background texture and pipeline") - } - print("Preset texture format: \(texture.pixelFormat.rawValue), grayscale: \(grayscale)") - let output = try render(device: shared.device, pipeline: pipeline, pixels: [], - textureIndex: binding, sourceTexture: texture) { _ in } - let pixel: [UInt8] = grayscale ? [128, 128, 128, 255] : [0, 0, 255, 255] - try checkPixels(output, expected: Array(repeating: pixel, count: 4).flatMap { $0 }) - - if reload { - let shaderURL = temporary.appendingPathComponent("background.slang") - let originalSource = try String(contentsOf: shaderURL, encoding: .utf8) - try originalSource.replacingOccurrences(of: "texture(BACKGROUND, vTexCoord)", with: "vec4(0.0, 1.0, 0.0, 1.0)") - .write(to: shaderURL, atomically: true, encoding: .utf8) - shared.invalidateEffect() - try shared.setEffectSource(preset, shaderPath: presetURL.path) - guard let reloadedPipeline = shared.renderPipeline else { - throw SmokeFailure(message: "Preset reload did not create a pipeline") - } - try require(reloadedPipeline !== pipeline, "Preset reload reused the previous shader pipeline") - let reloadedOutput = try render(device: shared.device, pipeline: reloadedPipeline, - pixels: [], sourceTexture: texture) { _ in } - let green: [UInt8] = [0, 255, 0, 255] - try checkPixels(reloadedOutput, expected: Array(repeating: green, count: 4).flatMap { $0 }) - } - } - - private static func checkLargeDiagnostic() throws { - let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: temporary) } - let compiler = temporary.appendingPathComponent("mock-slangc") - let script = #""" - #!/bin/sh - /usr/bin/head -c 262144 /dev/zero | /usr/bin/tr '\000' 'o' - /usr/bin/head -c 262144 /dev/zero | /usr/bin/tr '\000' 'e' >&2 - printf '\nlargeDiagnosticSentinel\n' >&2 - exit 1 - """# - try script.write(to: compiler, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: compiler.path) - let outputFile = temporary.appendingPathComponent("output.txt") - FileManager.default.createFile(atPath: outputFile.path, contents: nil) - let output = try FileHandle(forWritingTo: outputFile) - defer { try? output.close() } - let process = Process() - process.executableURL = URL(fileURLWithPath: CommandLine.arguments[0]) - process.arguments = ["--large-diagnostic-child"] - var environment = ProcessInfo.processInfo.environment - environment["SLANG_PATH"] = compiler.path - process.environment = environment - process.standardOutput = output - process.standardError = output - try process.run() - let deadline = Date().addingTimeInterval(5) - while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.01) - } - if process.isRunning { - process.terminate() - process.waitUntilExit() - throw SmokeFailure(message: "Compiler handling blocked after 5 seconds with output larger than a pipe buffer") - } - process.waitUntilExit() - let diagnostic = try String(contentsOf: outputFile, encoding: .utf8) - try require(process.terminationStatus == 0, "Large-output subprocess failed: \(diagnostic)") - } - - private static func checkPixels(_ actual: [UInt8], expected: [UInt8]) throws { - try require(actual.count == expected.count, "Pixel output length mismatch") - for index in actual.indices { - try require(abs(Int(actual[index]) - Int(expected[index])) <= 1, - "Pixel bytes differ at channel \(index): got \(actual), expected \(expected)") - } - } - - private static func render( - device: MTLDevice, - pipeline: MTLRenderPipelineState, - pixels: [UInt8], - textureIndex: Int = 0, - sourceTexture: MTLTexture? = nil, - uniforms: (MTLRenderCommandEncoder) -> Void - ) throws -> [UInt8] { - let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: 2, height: 2, mipmapped: false) - descriptor.storageMode = .shared - descriptor.usage = [.shaderRead, .renderTarget] - guard let source = sourceTexture ?? device.makeTexture(descriptor: descriptor), - let destination = device.makeTexture(descriptor: descriptor), - let queue = device.makeCommandQueue(), - let commands = queue.makeCommandBuffer() else { - throw SmokeFailure(message: "Could not allocate offscreen rendering resources") - } - let region = MTLRegionMake2D(0, 0, 2, 2) - if sourceTexture == nil { - pixels.withUnsafeBytes { bytes in - source.replace(region: region, mipmapLevel: 0, withBytes: bytes.baseAddress!, bytesPerRow: 8) - } - } - let samplerDescriptor = MTLSamplerDescriptor() - samplerDescriptor.minFilter = .nearest - samplerDescriptor.magFilter = .nearest - samplerDescriptor.sAddressMode = .clampToEdge - samplerDescriptor.tAddressMode = .clampToEdge - guard let sampler = device.makeSamplerState(descriptor: samplerDescriptor) else { - throw SmokeFailure(message: "Could not create sampler") - } - let pass = MTLRenderPassDescriptor() - pass.colorAttachments[0].texture = destination - pass.colorAttachments[0].loadAction = .clear - pass.colorAttachments[0].storeAction = .store - guard let encoder = commands.makeRenderCommandEncoder(descriptor: pass) else { - throw SmokeFailure(message: "Could not create render encoder") - } - encoder.setRenderPipelineState(pipeline) - encoder.setFragmentTexture(source, index: textureIndex) - encoder.setFragmentSamplerState(sampler, index: textureIndex) - uniforms(encoder) - encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) - encoder.endEncoding() - commands.commit() - commands.waitUntilCompleted() - if let error = commands.error { throw error } - var output = [UInt8](repeating: 0, count: 16) - output.withUnsafeMutableBytes { bytes in - destination.getBytes(bytes.baseAddress!, bytesPerRow: 8, from: region, mipmapLevel: 0) - } - return output - } -} diff --git a/scripts/check-config.sh b/scripts/check-config.sh deleted file mode 100755 index 750c3f8..0000000 --- a/scripts/check-config.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -set -euo pipefail - -project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -build_directory="$(mktemp -d "${TMPDIR:-/tmp}/screenslanger-config.XXXXXX")" -trap 'rm -rf "$build_directory"' EXIT - -# Compile the real configuration and its shader-state dependencies. The checks -# only use in-memory JSON and supplied display IDs; saved user settings are untouched. -xcrun --sdk macosx swiftc \ - -sdk "$(xcrun --sdk macosx --show-sdk-path)" \ - -module-cache-path "$build_directory/ModuleCache" \ - -parse-as-library \ - "$project_dir/ScreenSlanger/config.swift" \ - "$project_dir/ScreenSlanger/slang_compiler.swift" \ - "$project_dir/ScreenSlanger/retroarch_shader.swift" \ - "$project_dir/ScreenSlanger/renderer.swift" \ - "$project_dir/Tests/ConfigSelection.swift" \ - -o "$build_directory/check-config" - -"$build_directory/check-config" diff --git a/scripts/check-shaders.sh b/scripts/check-shaders.sh deleted file mode 100755 index 64921eb..0000000 --- a/scripts/check-shaders.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -set -euo pipefail - -project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -build_directory="$(mktemp -d "${TMPDIR:-/tmp}/screenslanger-shaders.XXXXXX")" -trap 'rm -rf "$build_directory"' EXIT - -# Compile the application's actual shader adapters and pipeline builders. -# A temporary module cache keeps this check independent of Xcode build products. -xcrun --sdk macosx swiftc \ - -sdk "$(xcrun --sdk macosx --show-sdk-path)" \ - -module-cache-path "$build_directory/ModuleCache" \ - -parse-as-library \ - "$project_dir/ScreenSlanger/slang_compiler.swift" \ - "$project_dir/ScreenSlanger/retroarch_shader.swift" \ - "$project_dir/ScreenSlanger/renderer.swift" \ - "$project_dir/Tests/ShaderSmoke.swift" \ - -o "$build_directory/check-shaders" - -"$build_directory/check-shaders" "$project_dir" -- 2.51.2