diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9266d1..d588215 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,13 @@ jobs: -scheme ViewMonitorSwiftUIExample \ -destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" + - name: UI test UIKit example + run: | + xcodebuild test \ + -project Example/ViewMonitorExample/ViewMonitorExample.xcodeproj \ + -scheme ViewMonitorExample \ + -destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" + pod-lint: name: pod lib lint runs-on: macos-latest diff --git a/.swiftlint.yml b/.swiftlint.yml index 23f6069..54f63d0 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,6 +2,7 @@ included: - Sources - Tests - Example/ViewMonitorExample/ViewMonitorExample + - Example/ViewMonitorExample/ViewMonitorExampleUITests - Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample - Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f0770..8dcbdba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- 実行ボタン・計測ボタン・InfoView に accessibilityIdentifier(`ViewMonitor.launcher` / `ViewMonitor.monitorButton` / `ViewMonitor.infoView`)。UI テストなどの自動化から ViewMonitor の UI を特定できる + +### Changed + +- 計測中に回転やプログラム起因の画面遷移が起きても、計測 OFF に戻さず新しい画面を再スキャンして計測を続ける(従来は無言で OFF に戻っていた)。選択状態(赤枠・距離計測の参照)は旧画面のビューと結びついているため引き継がない + +### Fixed + +- 横向きから縦に戻すと実行ボタンが画面外に出ることがある不具合。orientationDidChange はウィンドウのリサイズ完了前に届くため、貼り直しを次の runloop に遅らせてリサイズ後のジオメトリで配置するようにした + ## [2.3.0] - 2026-08-09 ### Added diff --git a/Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/project.pbxproj b/Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/project.pbxproj index da1a488..7afb817 100644 --- a/Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/project.pbxproj +++ b/Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/project.pbxproj @@ -7,27 +7,47 @@ objects = { /* Begin PBXBuildFile section */ - 19296B5B3F8099C79B044A59 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122D762A0620F5ADFCA627CF /* AppDelegate.swift */; }; - 4E5AF725F0DB6FAE68781043 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92851FB049E927CD148B10C9 /* ViewController.swift */; }; - 50A86A772F94CD5E7735145F /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70163DF708688093F0E594F1 /* SceneDelegate.swift */; }; - 9CCD5BF4C5D894DF2F8EE354 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 280BD7EE42786024C9F55A23 /* Main.storyboard */; }; - 9CF3B1B0FFA17EB5C639FBE9 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 923070980D47350CA6EFA928 /* LaunchScreen.xib */; }; - E249DA9468B0DA197F4F9449 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 68EEF5077242107C1658ACF5 /* Images.xcassets */; }; + 03DE6245CC70CF942E038913 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 20709032E141789582F415BE /* Main.storyboard */; }; + 0CD1CF5837995477D42AF4E9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2471DE321E416909C5F7DFD0 /* AppDelegate.swift */; }; + 48F46C4F83A6F8D395AA97F1 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7A63F2701572FDD7BB1103A7 /* LaunchScreen.xib */; }; + 56DADA0C077BDE4CB202FF7D /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 39F3E6EBB575324610A0D971 /* Images.xcassets */; }; + C3F562CAB4B3F68F97926694 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E350F045710DE75B04CA937B /* SceneDelegate.swift */; }; + F1384DB066F43D977A3B7611 /* ViewMonitorExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DF4D1FC6A88A9D1ED51E37B /* ViewMonitorExampleUITests.swift */; }; + F3D5B628B3A11A4CC605EB65 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32AFC691C8680132C373E539 /* ViewController.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + EE5E31E65B78E4AF66E1E107 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E1B896ACFE76D3FD7B79C320 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B869FD1851D87A93CF44B03E; + remoteInfo = ViewMonitorExample; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ - 122D762A0620F5ADFCA627CF /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 280BD7EE42786024C9F55A23 /* Main.storyboard */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file.storyboard; name = Main.storyboard; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 6406DBF04D6774C23435CD4F /* ViewMonitorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ViewMonitorExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 68EEF5077242107C1658ACF5 /* Images.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; - 70163DF708688093F0E594F1 /* SceneDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 923070980D47350CA6EFA928 /* LaunchScreen.xib */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file.xib; name = LaunchScreen.xib; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; - 92851FB049E927CD148B10C9 /* ViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - C1F06B4A8C38CD6178270ABE /* Signing.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; + 20709032E141789582F415BE /* Main.storyboard */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file.storyboard; name = Main.storyboard; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 2471DE321E416909C5F7DFD0 /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 25CAD740EF9671A1C6ABF885 /* ViewMonitorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ViewMonitorExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 32AFC691C8680132C373E539 /* ViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 39F3E6EBB575324610A0D971 /* Images.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; + 40B6B3B85246C49F38D9ADE4 /* ViewMonitorExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ViewMonitorExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7A63F2701572FDD7BB1103A7 /* LaunchScreen.xib */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file.xib; name = LaunchScreen.xib; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; + 8DF4D1FC6A88A9D1ED51E37B /* ViewMonitorExampleUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewMonitorExampleUITests.swift; sourceTree = ""; }; + E350F045710DE75B04CA937B /* SceneDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + E49D3A238F0156662F9704BD /* Signing.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 4622B8B0ADB7932F213C4C65 /* Frameworks */ = { + 5B75396539A18C873AE2009D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 73C65B8309B23A07DD5EF725 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -37,55 +57,66 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 152E75EAE84C501F32F851C4 /* Frameworks */ = { + 3060E433DA9748BB9F7FF8CB /* Products */ = { isa = PBXGroup; children = ( + 25CAD740EF9671A1C6ABF885 /* ViewMonitorExample.app */, + 40B6B3B85246C49F38D9ADE4 /* ViewMonitorExampleUITests.xctest */, ); - name = Frameworks; + name = Products; sourceTree = ""; }; - 73181E7BD17AB7A54F115E1C /* ViewMonitorExample */ = { + 87B75F22FDBFE2C21DF0ED6C /* ViewMonitorExample */ = { isa = PBXGroup; children = ( - 122D762A0620F5ADFCA627CF /* AppDelegate.swift */, - 70163DF708688093F0E594F1 /* SceneDelegate.swift */, - 92851FB049E927CD148B10C9 /* ViewController.swift */, - 280BD7EE42786024C9F55A23 /* Main.storyboard */, - 923070980D47350CA6EFA928 /* LaunchScreen.xib */, - 68EEF5077242107C1658ACF5 /* Images.xcassets */, + 2471DE321E416909C5F7DFD0 /* AppDelegate.swift */, + E350F045710DE75B04CA937B /* SceneDelegate.swift */, + 32AFC691C8680132C373E539 /* ViewController.swift */, + 20709032E141789582F415BE /* Main.storyboard */, + 7A63F2701572FDD7BB1103A7 /* LaunchScreen.xib */, + 39F3E6EBB575324610A0D971 /* Images.xcassets */, ); name = ViewMonitorExample; path = ViewMonitorExample; sourceTree = ""; }; - 7AAB288DAE5026573C3CF872 = { + BB46DED36C8F690821D76F46 = { isa = PBXGroup; children = ( - C765CA5CAE9603C4767E8101 /* Products */, - 152E75EAE84C501F32F851C4 /* Frameworks */, - 73181E7BD17AB7A54F115E1C /* ViewMonitorExample */, - C1F06B4A8C38CD6178270ABE /* Signing.xcconfig */, + 3060E433DA9748BB9F7FF8CB /* Products */, + E3462807DAC6A77FD7BBE564 /* Frameworks */, + 87B75F22FDBFE2C21DF0ED6C /* ViewMonitorExample */, + D0DC6FD7C8EAD1CDD5806A4C /* ViewMonitorExampleUITests */, + E49D3A238F0156662F9704BD /* Signing.xcconfig */, ); sourceTree = ""; }; - C765CA5CAE9603C4767E8101 /* Products */ = { + D0DC6FD7C8EAD1CDD5806A4C /* ViewMonitorExampleUITests */ = { isa = PBXGroup; children = ( - 6406DBF04D6774C23435CD4F /* ViewMonitorExample.app */, + 8DF4D1FC6A88A9D1ED51E37B /* ViewMonitorExampleUITests.swift */, ); - name = Products; + name = ViewMonitorExampleUITests; + path = ViewMonitorExampleUITests; + sourceTree = ""; + }; + E3462807DAC6A77FD7BBE564 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 59B23151407E1BFFD154790A /* ViewMonitorExample */ = { + B869FD1851D87A93CF44B03E /* ViewMonitorExample */ = { isa = PBXNativeTarget; - buildConfigurationList = 69753F51D5308827A81FC6BD /* Build configuration list for PBXNativeTarget "ViewMonitorExample" */; + buildConfigurationList = E4BDDAFA4DEEFAE7B448E1EA /* Build configuration list for PBXNativeTarget "ViewMonitorExample" */; buildPhases = ( - 556E40562D92DCC656E08FB9 /* Sources */, - 4622B8B0ADB7932F213C4C65 /* Frameworks */, - 436416AA6DCBFC8052B391D3 /* Resources */, + 7A7F86A371D7C1773D985D40 /* Sources */, + 5B75396539A18C873AE2009D /* Frameworks */, + DB147C0EF41DBB4E18862C2F /* Resources */, ); buildRules = ( ); @@ -93,22 +124,40 @@ ); name = ViewMonitorExample; packageProductDependencies = ( - 51EA3B05E8A76D53857B0E82 /* ViewMonitor */, + 63953F2082834C139062098A /* ViewMonitor */, ); productName = ViewMonitorExample; - productReference = 6406DBF04D6774C23435CD4F /* ViewMonitorExample.app */; + productReference = 25CAD740EF9671A1C6ABF885 /* ViewMonitorExample.app */; productType = "com.apple.product-type.application"; }; + C852A3795846A1020EAEED26 /* ViewMonitorExampleUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2EBE5E2342CB2BFCA5CF3D02 /* Build configuration list for PBXNativeTarget "ViewMonitorExampleUITests" */; + buildPhases = ( + BC956265E2118BDFF86DA68E /* Sources */, + 73C65B8309B23A07DD5EF725 /* Frameworks */, + 54FB99EACA50B52557BB56A1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 97E92DDEBAFB576D7EA2DDC9 /* PBXTargetDependency */, + ); + name = ViewMonitorExampleUITests; + productName = ViewMonitorExampleUITests; + productReference = 40B6B3B85246C49F38D9ADE4 /* ViewMonitorExampleUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - BDCC1EC46D073AF0812FD0BF /* Project object */ = { + E1B896ACFE76D3FD7B79C320 /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1600; }; - buildConfigurationList = C30E33890EC84F6088852E0A /* Build configuration list for PBXProject "ViewMonitorExample" */; + buildConfigurationList = 1F0BFEEC725005B6D6AA652E /* Build configuration list for PBXProject "ViewMonitorExample" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; @@ -116,49 +165,74 @@ en, Base, ); - mainGroup = 7AAB288DAE5026573C3CF872; + mainGroup = BB46DED36C8F690821D76F46; minimizedProjectReferenceProxies = 0; packageReferences = ( - 7361106BBFBE5338C8C3CFC0 /* XCLocalSwiftPackageReference ".." */, + 7DAAB28F15081BA98349F885 /* XCLocalSwiftPackageReference ".." */, ); preferredProjectObjectVersion = 100; - productRefGroup = C765CA5CAE9603C4767E8101 /* Products */; + productRefGroup = 3060E433DA9748BB9F7FF8CB /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 59B23151407E1BFFD154790A /* ViewMonitorExample */, + B869FD1851D87A93CF44B03E /* ViewMonitorExample */, + C852A3795846A1020EAEED26 /* ViewMonitorExampleUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 436416AA6DCBFC8052B391D3 /* Resources */ = { + 54FB99EACA50B52557BB56A1 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9CCD5BF4C5D894DF2F8EE354 /* Main.storyboard in Resources */, - 9CF3B1B0FFA17EB5C639FBE9 /* LaunchScreen.xib in Resources */, - E249DA9468B0DA197F4F9449 /* Images.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DB147C0EF41DBB4E18862C2F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 03DE6245CC70CF942E038913 /* Main.storyboard in Resources */, + 48F46C4F83A6F8D395AA97F1 /* LaunchScreen.xib in Resources */, + 56DADA0C077BDE4CB202FF7D /* Images.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 556E40562D92DCC656E08FB9 /* Sources */ = { + 7A7F86A371D7C1773D985D40 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0CD1CF5837995477D42AF4E9 /* AppDelegate.swift in Sources */, + C3F562CAB4B3F68F97926694 /* SceneDelegate.swift in Sources */, + F3D5B628B3A11A4CC605EB65 /* ViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BC956265E2118BDFF86DA68E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 19296B5B3F8099C79B044A59 /* AppDelegate.swift in Sources */, - 50A86A772F94CD5E7735145F /* SceneDelegate.swift in Sources */, - 4E5AF725F0DB6FAE68781043 /* ViewController.swift in Sources */, + F1384DB066F43D977A3B7611 /* ViewMonitorExampleUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 97E92DDEBAFB576D7EA2DDC9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ViewMonitorExample; + target = B869FD1851D87A93CF44B03E /* ViewMonitorExample */; + targetProxy = EE5E31E65B78E4AF66E1E107 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ - 01E28483649283B5637F2DB3 /* Debug */ = { + 1E649930D1CB45B0551E00DA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -192,17 +266,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -210,56 +278,16 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 6.0; - }; - name = Debug; - }; - 07FB940FC8A772D11313F0C9 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = C1F06B4A8C38CD6178270ABE /* Signing.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; - CODE_SIGN_STYLE = Automatic; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = ViewMonitorExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = daisuke.ViewMonitorExample; - SDKROOT = iphoneos; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 5780D0D64EC29A2CA228075F /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = C1F06B4A8C38CD6178270ABE /* Signing.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; - CODE_SIGN_STYLE = Automatic; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = ViewMonitorExample/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = daisuke.ViewMonitorExample; - SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; }; name = Release; }; - 5C63553D7589DE9CFA43B0BF /* Release */ = { + 1E65B5CDC646DA5483FBAC94 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -293,11 +321,17 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; + DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -305,32 +339,112 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = NO; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 504A38856BF3AF92AF79A94D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E49D3A238F0156662F9704BD /* Signing.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ViewMonitorExample/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = daisuke.ViewMonitorExample; + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5903E8ACDD3AF8C7B775C795 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = daisuke.ViewMonitorExampleUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TEST_TARGET_NAME = ViewMonitorExample; + }; + name = Debug; + }; + 74117A1059A1C38C1F575D53 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = daisuke.ViewMonitorExampleUITests; + SDKROOT = iphoneos; SWIFT_VERSION = 6.0; + TEST_TARGET_NAME = ViewMonitorExample; + VALIDATE_PRODUCT = YES; }; name = Release; }; + E944CD38FB2884EF947CBF98 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E49D3A238F0156662F9704BD /* Signing.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ViewMonitorExample/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = daisuke.ViewMonitorExample; + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 69753F51D5308827A81FC6BD /* Build configuration list for PBXNativeTarget "ViewMonitorExample" */ = { + 1F0BFEEC725005B6D6AA652E /* Build configuration list for PBXProject "ViewMonitorExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1E65B5CDC646DA5483FBAC94 /* Debug */, + 1E649930D1CB45B0551E00DA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2EBE5E2342CB2BFCA5CF3D02 /* Build configuration list for PBXNativeTarget "ViewMonitorExampleUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 5780D0D64EC29A2CA228075F /* Release */, - 07FB940FC8A772D11313F0C9 /* Debug */, + 74117A1059A1C38C1F575D53 /* Release */, + 5903E8ACDD3AF8C7B775C795 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - C30E33890EC84F6088852E0A /* Build configuration list for PBXProject "ViewMonitorExample" */ = { + E4BDDAFA4DEEFAE7B448E1EA /* Build configuration list for PBXNativeTarget "ViewMonitorExample" */ = { isa = XCConfigurationList; buildConfigurations = ( - 01E28483649283B5637F2DB3 /* Debug */, - 5C63553D7589DE9CFA43B0BF /* Release */, + 504A38856BF3AF92AF79A94D /* Release */, + E944CD38FB2884EF947CBF98 /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -338,18 +452,18 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 7361106BBFBE5338C8C3CFC0 /* XCLocalSwiftPackageReference ".." */ = { + 7DAAB28F15081BA98349F885 /* XCLocalSwiftPackageReference ".." */ = { isa = XCLocalSwiftPackageReference; relativePath = ../..; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 51EA3B05E8A76D53857B0E82 /* ViewMonitor */ = { + 63953F2082834C139062098A /* ViewMonitor */ = { isa = XCSwiftPackageProductDependency; productName = ViewMonitor; }; /* End XCSwiftPackageProductDependency section */ }; - rootObject = BDCC1EC46D073AF0812FD0BF /* Project object */; + rootObject = E1B896ACFE76D3FD7B79C320 /* Project object */; } diff --git a/Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/xcshareddata/xcschemes/ViewMonitorExample.xcscheme b/Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/xcshareddata/xcschemes/ViewMonitorExample.xcscheme index 65574c4..fe6c526 100644 --- a/Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/xcshareddata/xcschemes/ViewMonitorExample.xcscheme +++ b/Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/xcshareddata/xcschemes/ViewMonitorExample.xcscheme @@ -14,7 +14,7 @@ buildForAnalyzing = "YES"> @@ -30,13 +30,23 @@ + + + + @@ -70,7 +80,7 @@ runnableDebuggingMode = "0"> diff --git a/Example/ViewMonitorExample/ViewMonitorExampleUITests/ViewMonitorExampleUITests.swift b/Example/ViewMonitorExample/ViewMonitorExampleUITests/ViewMonitorExampleUITests.swift new file mode 100644 index 0000000..d5499b8 --- /dev/null +++ b/Example/ViewMonitorExample/ViewMonitorExampleUITests/ViewMonitorExampleUITests.swift @@ -0,0 +1,64 @@ +import XCTest + +/// UIKit ライフサイクルのサンプルに対する実タッチ経路の回帰テスト。 +/// 検証の観点は SwiftUI 側(ViewMonitorUITests)と同じで、UIKit だけの +/// 経路(storyboard のビュー階層、SceneDelegate からの起動)でも +/// 計測と回転時の挙動が成り立つことを押さえる。 +final class ViewMonitorExampleUITests: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + // 回転テスト後もシミュレータの向きは残るため、各テストを縦から始める。 + XCUIDevice.shared.orientation = .portrait + } + + /// アプリを起動し、実行ボタンをタップして計測を開始する。 + private func launchAndStartMeasuring(_ app: XCUIApplication) { + app.launch() + let launcher = app.buttons["ViewMonitor.launcher"] + XCTAssertTrue(launcher.waitForExistence(timeout: 10), "実行ボタンが表示されない") + launcher.tap() + let monitorButton = app.buttons.matching(identifier: "ViewMonitor.monitorButton").firstMatch + XCTAssertTrue(monitorButton.waitForExistence(timeout: 5), "トグルONで計測ボタンが出ない") + } + + func testMeasuresLabelViaRealTap() { + let app = XCUIApplication() + launchAndStartMeasuring(app) + + let label = app.staticTexts["ViewMonitor Library"] + XCTAssertTrue(label.exists, "ラベルが見つからない") + label.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + + XCTAssertTrue( + app.staticTexts["class: UILabel"].waitForExistence(timeout: 5), + "UILabel の計測結果が InfoView に出ない" + ) + } + + func testRotationKeepsMeasuringAndLauncherStaysOnScreen() { + let app = XCUIApplication() + launchAndStartMeasuring(app) + + // 横 → 縦の順で確認する。orientationDidChange はウィンドウのリサイズ + // 完了前に届くため、旧 bounds で配置すると縦復帰時に実行ボタンが + // 画面外へ出る(SwiftUI 側と共通の退行検出)。 + for orientation in [UIDeviceOrientation.landscapeLeft, .portrait] { + XCUIDevice.shared.orientation = orientation + + let launcher = app.buttons["ViewMonitor.launcher"] + XCTAssertTrue(launcher.waitForExistence(timeout: 5)) + XCTAssertTrue(launcher.isSelected, "回転で計測が OFF に戻ってしまった (\(orientation.rawValue))") + + let window = app.windows.firstMatch.frame + XCTAssertTrue( + window.contains(launcher.frame), + "実行ボタンが画面外に出た (\(orientation.rawValue)): launcher=\(launcher.frame) window=\(window)" + ) + XCTAssertTrue( + app.buttons.matching(identifier: "ViewMonitor.monitorButton").firstMatch.exists, + "回転後に再スキャンされていない (\(orientation.rawValue))" + ) + } + } +} diff --git a/Example/ViewMonitorExample/generate_project.rb b/Example/ViewMonitorExample/generate_project.rb index 2fc699f..7dc7976 100755 --- a/Example/ViewMonitorExample/generate_project.rb +++ b/Example/ViewMonitorExample/generate_project.rb @@ -18,6 +18,8 @@ PROJECT_PATH = File.join(EXAMPLE_DIR, 'ViewMonitorExample.xcodeproj') TARGET_NAME = 'ViewMonitorExample' BUNDLE_ID = 'daisuke.ViewMonitorExample' +UITEST_TARGET_NAME = 'ViewMonitorExampleUITests' +UITEST_DIR = File.join(EXAMPLE_DIR, UITEST_TARGET_NAME) DEPLOYMENT_TARGET = '15.0' XCCONFIG_NAME = 'Signing.xcconfig' @@ -29,6 +31,8 @@ end target = project.new_target(:application, TARGET_NAME, :ios, DEPLOYMENT_TARGET) +uitest_target = project.new_target(:ui_test_bundle, UITEST_TARGET_NAME, :ios, DEPLOYMENT_TARGET) +uitest_target.add_dependency(target) # `new_target` links Foundation.framework by default, but its file reference # hardcodes the version of the installed SDK into the path (e.g. @@ -38,6 +42,7 @@ # spurious diff. Drop it from the build phase and also remove the file # reference and its parent group (Frameworks/iOS) from the project entirely. target.frameworks_build_phase.clear +uitest_target.frameworks_build_phase.clear if (ios_frameworks_group = project.frameworks_group['iOS']) ios_frameworks_group.children.each(&:remove_from_project) ios_frameworks_group.remove_from_project @@ -61,6 +66,13 @@ target.add_resources([file]) end +uitest_group = project.new_group(UITEST_TARGET_NAME, UITEST_TARGET_NAME) +uitest_sources = %w[ViewMonitorExampleUITests.swift] +uitest_sources.each do |name| + file = uitest_group.new_reference(File.join(UITEST_DIR, name)) + uitest_target.add_file_references([file]) +end + # Reference the root Package.swift locally and link ViewMonitor from it. package_ref = project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageReference) package_ref.relative_path = '../..' @@ -98,12 +110,27 @@ settings.delete('ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME') end +uitest_target.build_configurations.each do |config| + settings = config.build_settings + settings['PRODUCT_BUNDLE_IDENTIFIER'] = "#{BUNDLE_ID}UITests" + settings['IPHONEOS_DEPLOYMENT_TARGET'] = DEPLOYMENT_TARGET + settings['SWIFT_VERSION'] = '6.0' + settings['GENERATE_INFOPLIST_FILE'] = 'YES' + settings['TEST_TARGET_NAME'] = TARGET_NAME + # The UI test runner only ever runs on the simulator in this repo + # (CI has no signing certificate), same reasoning as the app target. + settings['CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]'] = 'NO' + settings['CODE_SIGN_STYLE'] = 'Automatic' +end + project.save # Create a shared scheme so CI can refer to it with -scheme ViewMonitorExample. +# The test action carries the UI test bundle so `xcodebuild test` runs it. scheme = Xcodeproj::XCScheme.new scheme.add_build_target(target) scheme.set_launch_target(target) +scheme.add_test_target(uitest_target) scheme.save_as(PROJECT_PATH, TARGET_NAME, true) puts "Generated #{PROJECT_PATH}" diff --git a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests/ViewMonitorUITests.swift b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests/ViewMonitorUITests.swift index ada5401..5c4b3da 100644 --- a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests/ViewMonitorUITests.swift +++ b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests/ViewMonitorUITests.swift @@ -12,6 +12,8 @@ final class ViewMonitorUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false + // 回転テスト後もシミュレータの向きは残るため、各テストを縦から始める。 + XCUIDevice.shared.orientation = .portrait } /// アプリを起動し、実行ボタンをタップして計測を開始する。 @@ -79,6 +81,32 @@ final class ViewMonitorUITests: XCTestCase { XCTAssertGreaterThanOrEqual(buttons.count, 5, "List の行に計測ボタンが出ていない") } + func testRotationKeepsMeasuringAndLauncherStaysOnScreen() { + let app = XCUIApplication() + launchAndStartMeasuring(app) + + // 横 → 縦の順で確認する。orientationDidChange はウィンドウのリサイズ + // 完了前に届くため、旧 bounds(横向きの幅)で実行ボタンを配置すると + // 縦に戻したとき画面外へ出る、という報告があった。 + for orientation in [UIDeviceOrientation.landscapeLeft, .portrait] { + XCUIDevice.shared.orientation = orientation + + let launcher = app.buttons["ViewMonitor.launcher"] + XCTAssertTrue(launcher.waitForExistence(timeout: 5)) + XCTAssertTrue(launcher.isSelected, "回転で計測が OFF に戻ってしまった (\(orientation.rawValue))") + + let window = app.windows.firstMatch.frame + XCTAssertTrue( + window.contains(launcher.frame), + "実行ボタンが画面外に出た (\(orientation.rawValue)): launcher=\(launcher.frame) window=\(window)" + ) + XCTAssertTrue( + app.buttons.matching(identifier: "ViewMonitor.monitorButton").firstMatch.exists, + "回転後に再スキャンされていない (\(orientation.rawValue))" + ) + } + } + func testShieldBlocksAppInteractionWhileMeasuring() { let app = XCUIApplication() app.launch() diff --git a/README.md b/README.md index 2733579..bf2577c 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,10 @@ accidental taps cannot navigate away, fire actions, or scroll the content (a screen transition would discard the measurement overlay). Only ViewMonitor's own UI — the measurement buttons, the info panel, and the toggle — receives touches. Measurement buttons are pinned at the positions -captured when the toggle was turned ON, and if the screen still changes -while measuring (e.g. a programmatic transition), the overlay closes and -the toggle returns to OFF. To interact with the app (e.g. scroll a list to +captured when the toggle was turned ON. If the screen still changes while +measuring (device rotation, or a programmatic transition such as a timer- +driven push), measuring stays ON and the new screen is re-scanned; the +current selection resets. To interact with the app (e.g. scroll a list to measure items further down), toggle OFF, move the screen, then toggle ON again to re-scan. diff --git a/Sources/ViewMonitor/ViewMonitor.swift b/Sources/ViewMonitor/ViewMonitor.swift index b580d48..eb59d69 100644 --- a/Sources/ViewMonitor/ViewMonitor.swift +++ b/Sources/ViewMonitor/ViewMonitor.swift @@ -19,6 +19,9 @@ public final class ViewMonitor: NSObject { private var launcherButton: MonitorLauncherButton? private weak var rootView: UIView? private var started = false + /// 計測中(トグル ON)かどうか。実行ボタンの isSelected は reload() で + /// ボタンごと作り直されて消えるため、状態はここが持つ。 + private var measuring = false /// 計測を開始する。実行ボタンが画面に表示される。 public static func start() { @@ -62,6 +65,7 @@ public final class ViewMonitor: NSObject { shared.removeLauncherButton() shared.stopObservingOrientation() shared.started = false + shared.measuring = false } /// `viewDidAppear` の swizzling から呼ばれる。 @@ -74,10 +78,27 @@ public final class ViewMonitor: NSObject { /// 画面が入れ替わったので、オーバーレイと実行ボタンを貼り直す。 private func reload() { + reload(on: WindowProvider.keyWindow) + } + + /// テスト用に rootView を注入できる本体。ユニットテストのバンドルには + /// 接続済みの window scene が無く `WindowProvider.keyWindow` が常に nil に + /// なるため、貼り直し先を差し替えられるようにしている。 + private func reload(on newRootView: UIView?) { overlay.hide() removeLauncherButton() - rootView = WindowProvider.keyWindow + rootView = newRootView addLauncherButton() + // 計測中に reload が起きた(回転・プログラム起因の画面遷移。タップ起因の + // 遷移はシールドが塞いでいる)場合は、OFF に戻さず新しい画面を + // 再スキャンして計測を続ける。選択状態(赤枠・距離の参照)は旧画面の + // ビューと結びついているため引き継がない。 + guard measuring, let launcherButton, let rootView else { + return + } + launcherButton.isSelected = true + overlay.show(on: rootView) + rootView.bringSubviewToFront(launcherButton) } private func addLauncherButton() { @@ -97,6 +118,7 @@ public final class ViewMonitor: NSObject { guard let self, let rootView = self.rootView else { return } + self.measuring = isSelected if isSelected { self.overlay.show(on: rootView) // 計測ボタンは rootView に直接addSubviewされるため、 @@ -129,6 +151,12 @@ public final class ViewMonitor: NSObject { shared.addLauncherButton() } + /// テスト用: 画面遷移・回転相当の `reload()` を任意の rootView で実行する。 + /// 公開 API には含まれない。 + static func simulateReloadForTesting(on view: UIView) { + shared.reload(on: view) + } + private func removeLauncherButton() { launcherButton?.removeFromSuperview() launcherButton = nil @@ -156,6 +184,15 @@ public final class ViewMonitor: NSObject { guard started else { return } - reload() + // orientationDidChange はウィンドウのリサイズ完了前に届くため、 + // ここで即 reload すると旧 bounds のまま配置してしまう + // (横→縦で実行ボタンが x=705 など画面外に出る)。次の runloop に + // 遅らせ、リサイズ後のジオメトリで貼り直す。 + DispatchQueue.main.async { [weak self] in + guard let self, self.started else { + return + } + self.reload() + } } } diff --git a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift index 466c030..30cbe99 100644 --- a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift +++ b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift @@ -119,6 +119,75 @@ struct ViewMonitorLifecycleTests { ViewMonitor.stop() } + @Test("計測中に reload が起きても ON を維持して新画面を再スキャンする") + func reloadKeepsMeasuringAndRescans() throws { + // 回転やプログラム起因の画面遷移で reload() が走ると、従来は実行ボタンが + // OFF の新品に差し替わり計測が無言で終わっていた。シールドがタップ起因の + // 遷移を塞いだ今、残るこの2経路でも計測を維持し、新しい画面を + // 再スキャンして貼り直す。 + ViewMonitor.stop() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + window.addSubview(UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20))) + ViewMonitor.simulateLauncherButtonAttachedForTesting(to: window) + let launcher = try #require(window.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + launcher.isSelected = true + launcher.onToggle?(true) + #expect(window.subviews.contains { $0 is MonitorButton }) + + // 回転・画面遷移相当。次の画面には別の対象がある。 + let nextWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 480, height: 320)) + nextWindow.addSubview(UIImageView(frame: CGRect(x: 10, y: 10, width: 60, height: 40))) + ViewMonitor.simulateReloadForTesting(on: nextWindow) + + let newLauncher = try #require(nextWindow.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + #expect(newLauncher.isSelected, "reload で計測が OFF に戻ってしまった") + #expect(nextWindow.subviews.contains { $0 is MonitorButton }, "新画面が再スキャンされていない") + // 実行ボタン(停止操作)は再スキャン後も最前面。 + let launcherIndex = try #require(nextWindow.subviews.firstIndex { $0 === newLauncher }) + #expect(launcherIndex == nextWindow.subviews.count - 1) + + newLauncher.onToggle?(false) + ViewMonitor.stop() + } + + @Test("計測 OFF なら reload しても OFF のまま") + func reloadStaysOffWhenNotMeasuring() throws { + ViewMonitor.stop() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + ViewMonitor.simulateLauncherButtonAttachedForTesting(to: window) + + let nextWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + nextWindow.addSubview(UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20))) + ViewMonitor.simulateReloadForTesting(on: nextWindow) + + let launcher = try #require(nextWindow.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + #expect(!launcher.isSelected) + #expect(!nextWindow.subviews.contains { $0 is MonitorButton }) + ViewMonitor.stop() + } + + @Test("stop すると計測中フラグもリセットされる") + func stopClearsMeasuringState() throws { + // stop() 後に start() し直したとき、前回の計測中フラグが残っていると + // 最初の reload でいきなりオーバーレイが開いてしまう。 + // stop() は started ガードを持つため、実運用どおり start() を通す。 + ViewMonitor.stop() + ViewMonitor.start() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + ViewMonitor.simulateLauncherButtonAttachedForTesting(to: window) + let launcher = try #require(window.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + launcher.isSelected = true + launcher.onToggle?(true) + + ViewMonitor.stop() + + let nextWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + ViewMonitor.simulateReloadForTesting(on: nextWindow) + let newLauncher = try #require(nextWindow.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + #expect(!newLauncher.isSelected) + ViewMonitor.stop() + } + @Test("画面遷移で実行ボタンが差し替わると直前のボタンが解放される") func replacedLauncherButtonIsDeallocated() throws { // onToggle クロージャが自身の button を強参照キャプチャすると