diff --git a/MonitorControl.xcodeproj/project.pbxproj b/MonitorControl.xcodeproj/project.pbxproj index 4f9029c..7c69fae 100644 --- a/MonitorControl.xcodeproj/project.pbxproj +++ b/MonitorControl.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ F0A489C4279C71B200BEDFD6 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A489C3279C71B200BEDFD6 /* OnboardingViewController.swift */; }; FE4E0896249D584C003A50BB /* OSDUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4E0895249D584C003A50BB /* OSDUtils.swift */; }; E77C0DE000000000000000A3 /* XDRBrightnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E77C0DE000000000000000A1 /* XDRBrightnessTests.swift */; }; + E77C0DE000000000000000B3 /* SettingsPanesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E77C0DE000000000000000B1 /* SettingsPanesTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -185,6 +186,7 @@ FB5DB2902AD54C4600306223 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; FE4E0895249D584C003A50BB /* OSDUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDUtils.swift; sourceTree = ""; }; E77C0DE000000000000000A1 /* XDRBrightnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDRBrightnessTests.swift; sourceTree = ""; }; + E77C0DE000000000000000B1 /* SettingsPanesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPanesTests.swift; sourceTree = ""; }; E77C0DE000000000000000A2 /* MonitorControlTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = MonitorControlTests.xctest; path = MonitorControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -390,6 +392,7 @@ isa = PBXGroup; children = ( E77C0DE000000000000000A1 /* XDRBrightnessTests.swift */, + E77C0DE000000000000000B1 /* SettingsPanesTests.swift */, ); path = MonitorControlTests; sourceTree = ""; @@ -745,6 +748,7 @@ buildActionMask = 2147483647; files = ( E77C0DE000000000000000A3 /* XDRBrightnessTests.swift in Sources */, + E77C0DE000000000000000B3 /* SettingsPanesTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -996,7 +1000,6 @@ PRODUCT_NAME = LumaControl; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "MonitorControl/Support/Bridging-Header.h"; - SWIFT_MODULE_NAME = XDRMonitorControl; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.5; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( @@ -1035,7 +1038,6 @@ PRODUCT_NAME = LumaControl; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "MonitorControl/Support/Bridging-Header.h"; - SWIFT_MODULE_NAME = XDRMonitorControl; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.5; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( diff --git a/MonitorControlTests/SettingsPanesTests.swift b/MonitorControlTests/SettingsPanesTests.swift new file mode 100644 index 0000000..0247980 --- /dev/null +++ b/MonitorControlTests/SettingsPanesTests.swift @@ -0,0 +1,75 @@ +// Copyright © MonitorControl. @JoniVR, @theOneyouseek, @waydabber and others + +import Cocoa +import XCTest + +@testable import LumaControl + +// The Settings window is assembled at launch from five storyboard scenes, and when that +// goes wrong it goes wrong silently. +// +// `NSStoryboard.instantiateController(withIdentifier:)` does not raise when a scene's +// `customModule` names a Swift module that does not exist at runtime. AppKit only logs +// "Unknown class … in Interface Builder file" to the console, hands back a plain +// `NSViewController`, and the `as?` cast in `main.swift` yields nil. `AppDelegate` +// then drops every pane, so the gear button and reopening the app both show +// "Settings could not be opened" with nothing on screen to say why. +// +// That is exactly what happened when the app target was renamed to LumaControl. Every +// scene is declared `customModuleProvider="target"`, so ibtool bakes `PRODUCT_MODULE_NAME` +// into the compiled storyboard — and `PRODUCT_MODULE_NAME` follows `PRODUCT_NAME`, which +// had become `LumaControl`. The app target still carried an explicit +// `SWIFT_MODULE_NAME = XDRMonitorControl`, and that is the string the Swift compiler uses +// for `-module-name` and therefore for the runtime class names. The storyboard asked for +// `LumaControl.MainPrefsViewController`, the binary only had +// `XDRMonitorControl.MainPrefsViewController`, and all five panes vanished. +// +// These tests load the same scenes the same way `main.swift` does, so they fail loudly the +// moment the two names drift apart again. They need no display and touch no hardware. +final class SettingsPanesTests: XCTestCase { + func testEverySettingsPaneLoadsFromTheStoryboard() { + let storyboard = NSStoryboard(name: "Main", bundle: Bundle.main) + + let main = storyboard.instantiateController(withIdentifier: "MainPrefsVC") as? MainPrefsViewController + XCTAssertNotNil(main, "The MainPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + + let menusliders = storyboard.instantiateController(withIdentifier: "MenuslidersPrefsVC") as? MenuslidersPrefsViewController + XCTAssertNotNil(menusliders, "The MenuslidersPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + + let keyboard = storyboard.instantiateController(withIdentifier: "KeyboardPrefsVC") as? KeyboardPrefsViewController + XCTAssertNotNil(keyboard, "The KeyboardPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + + let displays = storyboard.instantiateController(withIdentifier: "DisplaysPrefsVC") as? DisplaysPrefsViewController + XCTAssertNotNil(displays, "The DisplaysPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + + let about = storyboard.instantiateController(withIdentifier: "AboutPrefsVC") as? AboutPrefsViewController + XCTAssertNotNil(about, "The AboutPrefsVC scene did not load — see the note at the top of SettingsPanesTests.") + } + + /// The storyboard resolves its scenes against `PRODUCT_MODULE_NAME`, and for an app + /// target that is `PRODUCT_NAME` — the same value `CFBundleName` carries. So the runtime + /// name of a pane class has to start with the app's own name. Comparing against + /// `CFBundleName` rather than a literal means a future rename updates this check for + /// free while still catching the case where the Swift module is pinned to an old name. + func testPaneClassesLiveInTheModuleTheStoryboardResolvesAgainst() { + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + XCTAssertEqual(appName, "LumaControl", "CFBundleName should follow PRODUCT_NAME.") + + let paneClasses: [AnyClass] = [ + MainPrefsViewController.self, + MenuslidersPrefsViewController.self, + KeyboardPrefsViewController.self, + DisplaysPrefsViewController.self, + AboutPrefsViewController.self, + ] + + for paneClass in paneClasses { + let runtimeName = NSStringFromClass(paneClass) + XCTAssertEqual( + runtimeName.split(separator: ".").first.map(String.init), + appName, + "\(runtimeName) is compiled into a different module than the storyboard resolves against." + ) + } + } +} diff --git a/MonitorControlTests/XDRBrightnessTests.swift b/MonitorControlTests/XDRBrightnessTests.swift index bce33aa..0c4f55d 100644 --- a/MonitorControlTests/XDRBrightnessTests.swift +++ b/MonitorControlTests/XDRBrightnessTests.swift @@ -2,7 +2,7 @@ import XCTest -@testable import XDRMonitorControl +@testable import LumaControl // AppleDisplay.getBrightness() short-circuits to a constant for dummy displays, so // calcNewBrightness tests use this stub to drive the current brightness directly.