diff --git a/.gitignore b/.gitignore index 959e3c3..08780f5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ fastlane/test_output # Env / secrets .env .env.* +suixinkanUITests/UITestCredentials.local.plist *.pem diff --git a/AGENTS.md b/AGENTS.md index fb215d2..af72aec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ iOS - iOS App - SwiftUI - - 最低支持 iOS 17 + - 最低支持 iOS 16 - 只支持竖屏 diff --git a/Podfile b/Podfile index 763cef1..914de66 100644 --- a/Podfile +++ b/Podfile @@ -1,4 +1,4 @@ -platform :ios, '17.0' +platform :ios, '16.0' target 'suixinkan' do use_frameworks! diff --git a/Podfile.lock b/Podfile.lock index 78dbedd..db846d1 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -25,6 +25,6 @@ SPEC CHECKSUMS: AMapLocation-NO-IDFA: 0fa6e7d5e42e10253f4437424d98966f53760862 AMapSearch-NO-IDFA: 8e9d104032990a3b0cab66afb535299887c61047 -PODFILE CHECKSUM: 50778cfc2820db549075417f39146c17f4bd07f1 +PODFILE CHECKSUM: baa972253fcebc312545f7592c48cbba632f1907 COCOAPODS: 1.16.2 diff --git a/Pods/Manifest.lock b/Pods/Manifest.lock index 78dbedd..db846d1 100644 --- a/Pods/Manifest.lock +++ b/Pods/Manifest.lock @@ -25,6 +25,6 @@ SPEC CHECKSUMS: AMapLocation-NO-IDFA: 0fa6e7d5e42e10253f4437424d98966f53760862 AMapSearch-NO-IDFA: 8e9d104032990a3b0cab66afb535299887c61047 -PODFILE CHECKSUM: 50778cfc2820db549075417f39146c17f4bd07f1 +PODFILE CHECKSUM: baa972253fcebc312545f7592c48cbba632f1907 COCOAPODS: 1.16.2 diff --git a/Pods/Pods.xcodeproj/project.pbxproj b/Pods/Pods.xcodeproj/project.pbxproj index 7a6ffef..f98de1b 100644 --- a/Pods/Pods.xcodeproj/project.pbxproj +++ b/Pods/Pods.xcodeproj/project.pbxproj @@ -768,86 +768,7 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 0B9288E0B1AFAE5C85E5C0C3D1C1D8E6 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 2D9188A08F7F86FBF3EBB3CD93699D53 /* Pods-suixinkan.release.xcconfig */; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - INFOPLIST_FILE = "Target Support Files/Pods-suixinkan/Pods-suixinkan-Info.plist"; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MACH_O_TYPE = staticlib; - MODULEMAP_FILE = "Target Support Files/Pods-suixinkan/Pods-suixinkan.modulemap"; - OTHER_LDFLAGS = ""; - OTHER_LIBTOOLFLAGS = ""; - PODS_ROOT = "$(SRCROOT)"; - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; - 0B998EC7D708CA14F6D00FD60D2A1C98 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 61B7C8006610229F8A3485704A3944CF /* Pods-suixinkan.debug.xcconfig */; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - INFOPLIST_FILE = "Target Support Files/Pods-suixinkan/Pods-suixinkan-Info.plist"; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MACH_O_TYPE = staticlib; - MODULEMAP_FILE = "Target Support Files/Pods-suixinkan/Pods-suixinkan.modulemap"; - OTHER_LDFLAGS = ""; - OTHER_LIBTOOLFLAGS = ""; - PODS_ROOT = "$(SRCROOT)"; - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - 0FAFBB4DB9A284A5FAA328FEA1AF4FD9 /* Debug */ = { + 0606EC124017CF52B54FEB3096BC7E75 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 3CE7B2EE0FE95BBD834825FA4D0571C5 /* Pods-suixinkanTests.debug.xcconfig */; buildSettings = { @@ -865,7 +786,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests-Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -886,6 +807,68 @@ }; name = Debug; }; + 30E0B9EFD9A5C45D0D351231E81B30B3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + 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; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "POD_CONFIGURATION_RELEASE=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRIP_INSTALLED_PRODUCT = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + SYMROOT = "${SRCROOT}/../build"; + }; + name = Release; + }; 33FF65CC939DB2ACD3BF5B4E6E6F56D6 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 1C389829A9B7304C4C2F81E54D9281D4 /* AMapFoundation-NO-IDFA.release.xcconfig */; @@ -905,46 +888,6 @@ }; name = Release; }; - 443447B150CD11B46EEB5D882C8F21B7 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = A1C2AE0D141F894C0C841BDB91EA7784 /* Pods-suixinkanTests.release.xcconfig */; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = NO; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_MODULE_VERIFIER = NO; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - INFOPLIST_FILE = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests-Info.plist"; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MACH_O_TYPE = staticlib; - MODULEMAP_FILE = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.modulemap"; - OTHER_LDFLAGS = ""; - OTHER_LIBTOOLFLAGS = ""; - PODS_ROOT = "$(SRCROOT)"; - PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; 49EBBE60492138920A951F8BD7CD4013 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 0249ED7AD58489423B69253BB3F68C77 /* AMap3DMap-NO-IDFA.release.xcconfig */; @@ -982,71 +925,45 @@ }; name = Debug; }; - 6A5C026ED62BBFE3380CD257EEFAEB20 /* Debug */ = { + 59C25D381C381A4E53F13543611D1E41 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = A1C2AE0D141F894C0C841BDB91EA7784 /* Pods-suixinkanTests.release.xcconfig */; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = 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 = ( - "POD_CONFIGURATION_DEBUG=1", - "DEBUG=1", + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRIP_INSTALLED_PRODUCT = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - SYMROOT = "${SRCROOT}/../build"; + MACH_O_TYPE = staticlib; + MODULEMAP_FILE = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.modulemap"; + OTHER_LDFLAGS = ""; + OTHER_LIBTOOLFLAGS = ""; + PODS_ROOT = "$(SRCROOT)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; }; - name = Debug; + name = Release; }; 7BB3D81AAC9EB818C249626A41588C41 /* Debug */ = { isa = XCBuildConfiguration; @@ -1066,6 +983,45 @@ }; name = Debug; }; + 8076472B15CAFA346B2FBF304BD0B32C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 61B7C8006610229F8A3485704A3944CF /* Pods-suixinkan.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = "Target Support Files/Pods-suixinkan/Pods-suixinkan-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MACH_O_TYPE = staticlib; + MODULEMAP_FILE = "Target Support Files/Pods-suixinkan/Pods-suixinkan.modulemap"; + OTHER_LDFLAGS = ""; + OTHER_LIBTOOLFLAGS = ""; + PODS_ROOT = "$(SRCROOT)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; A5889394557E95B280BBA784CBC27862 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 50416CD761E5B67B024889A9055280A1 /* AMapLocation-NO-IDFA.debug.xcconfig */; @@ -1140,7 +1096,47 @@ }; name = Debug; }; - E873CA97BD124B21839AF13C9BE1CD18 /* Release */ = { + EB4999FBC2DB974780AA976726FD20A2 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2D9188A08F7F86FBF3EBB3CD93699D53 /* Pods-suixinkan.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = "Target Support Files/Pods-suixinkan/Pods-suixinkan-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MACH_O_TYPE = staticlib; + MODULEMAP_FILE = "Target Support Files/Pods-suixinkan/Pods-suixinkan.modulemap"; + OTHER_LDFLAGS = ""; + OTHER_LIBTOOLFLAGS = ""; + PODS_ROOT = "$(SRCROOT)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + F4FF6A0D1970CA9705974E3CB2134802 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -1175,13 +1171,16 @@ 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 = ( - "POD_CONFIGURATION_RELEASE=1", + "POD_CONFIGURATION_DEBUG=1", + "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -1190,17 +1189,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MTL_ENABLE_DEBUG_INFO = NO; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; STRIP_INSTALLED_PRODUCT = NO; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; SYMROOT = "${SRCROOT}/../build"; }; - name = Release; + name = Debug; }; /* End XCBuildConfiguration section */ @@ -1208,8 +1208,8 @@ 4821239608C13582E20E6DA73FD5F1F9 /* Build configuration list for PBXProject "Pods" */ = { isa = XCConfigurationList; buildConfigurations = ( - 6A5C026ED62BBFE3380CD257EEFAEB20 /* Debug */, - E873CA97BD124B21839AF13C9BE1CD18 /* Release */, + F4FF6A0D1970CA9705974E3CB2134802 /* Debug */, + 30E0B9EFD9A5C45D0D351231E81B30B3 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -1244,8 +1244,8 @@ 9D12533D25351890E8822DC48F49289E /* Build configuration list for PBXNativeTarget "Pods-suixinkanTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 0FAFBB4DB9A284A5FAA328FEA1AF4FD9 /* Debug */, - 443447B150CD11B46EEB5D882C8F21B7 /* Release */, + 0606EC124017CF52B54FEB3096BC7E75 /* Debug */, + 59C25D381C381A4E53F13543611D1E41 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -1262,8 +1262,8 @@ FCDBF877017B428DCC77941BF6BFCE55 /* Build configuration list for PBXNativeTarget "Pods-suixinkan" */ = { isa = XCConfigurationList; buildConfigurations = ( - 0B998EC7D708CA14F6D00FD60D2A1C98 /* Debug */, - 0B9288E0B1AFAE5C85E5C0C3D1C1D8E6 /* Release */, + 8076472B15CAFA346B2FBF304BD0B32C /* Debug */, + EB4999FBC2DB974780AA976726FD20A2 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index ab0d3b5..ff23698 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -38,6 +38,12 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 10. 明确 token 失效时清空 token 和账号快照,回到登录页。 11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 +## UI Test 启动约定 + +- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。 +- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。 +- 详细运行方式见 `suixinkanUITests/README.md`。 + ## 登录和退出 登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理: diff --git a/suixinkan/App/AppUITestLaunchState.swift b/suixinkan/App/AppUITestLaunchState.swift index 8c2e821..b62d170 100644 --- a/suixinkan/App/AppUITestLaunchState.swift +++ b/suixinkan/App/AppUITestLaunchState.swift @@ -7,10 +7,19 @@ import Foundation -/// UI 测试启动状态清理,仅在测试进程显式传入启动参数时执行。 +/// UI 测试启动状态,仅在测试进程显式传入启动参数时生效。 enum AppUITestLaunchState { + /// UI Test 主开关,测试进程统一传入。 + static let uiTestsArgument = "-suixinkan-ui-tests" + /// 冷启动前清理本地登录缓存。 static let resetArgument = "-suixinkan-ui-tests-reset-state" + /// 当前进程是否由 XCUITest 启动。 + static var isRunningUITests: Bool { + ProcessInfo.processInfo.arguments.contains(uiTestsArgument) + } + + /// 按需在冷启动时清理 token、账号快照和偏好设置。 static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) { guard arguments.contains(resetArgument) else { return } diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 061483e..8bbb4b4 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -146,7 +146,7 @@ struct RootView: View { toastCenter: toastCenter ) } - if appSession.isLoggedIn { + if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests { PushNotificationManager.shared.requestAuthorizationAndRegister() } } @@ -161,6 +161,7 @@ struct RootView: View { ) } .onChange(of: scenePhase) { newPhase in + guard !AppUITestLaunchState.isRunningUITests else { return } if appSession.isLoggedIn { scenicQueueRuntime.update( api: scenicQueueAPI, @@ -173,6 +174,7 @@ struct RootView: View { } } .onChange(of: accountContext.currentScenic?.id) { _ in + guard !AppUITestLaunchState.isRunningUITests else { return } if appSession.isLoggedIn { scenicQueueRuntime.update( api: scenicQueueAPI, @@ -185,7 +187,9 @@ struct RootView: View { .onChange(of: appSession.phase) { phase in switch phase { case .loggedIn: - PushNotificationManager.shared.requestAuthorizationAndRegister() + if !AppUITestLaunchState.isRunningUITests { + PushNotificationManager.shared.requestAuthorizationAndRegister() + } case .loggedOut: accountContext.reset() permissionContext.reset() diff --git a/suixinkanUITests/Auth/AuthFlowUITests.swift b/suixinkanUITests/Auth/AuthFlowUITests.swift new file mode 100644 index 0000000..8f38aec --- /dev/null +++ b/suixinkanUITests/Auth/AuthFlowUITests.swift @@ -0,0 +1,25 @@ +// +// AuthFlowUITests.swift +// suixinkanUITests +// + +import XCTest + +final class AuthFlowUITests: AuthenticatedUITestCase { + /// 测试完整登录后可进入主 Tab。 + func testLoginReachesMainTabs() { + XCTAssertTrue(app.isOnMainTabs) + } + + /// 测试登录后默认能访问首页和我的 Tab。 + func testLoggedInUserCanSwitchPrimaryTabs() { + TabBarNavigator.select(.home, app: app) + XCTAssertTrue( + app.application.staticTexts["常用应用"].waitForExistence(timeout: 8) + || app.application.staticTexts["请选择景区"].exists + ) + + TabBarNavigator.select(.profile, app: app) + NavigationAssertions.waitForNavigationTitle("个人信息", in: app) + } +} diff --git a/suixinkanUITests/Auth/LoginSmokeUITests.swift b/suixinkanUITests/Auth/LoginSmokeUITests.swift new file mode 100644 index 0000000..3041c09 --- /dev/null +++ b/suixinkanUITests/Auth/LoginSmokeUITests.swift @@ -0,0 +1,60 @@ +// +// LoginSmokeUITests.swift +// suixinkanUITests +// + +import XCTest + +final class LoginSmokeUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + /// 测试冷启动会展示登录页关键控件。 + func testFreshLaunchShowsLoginScreen() { + let app = SuixinkanApp(resetState: true).launch(waitForLoading: false) + + XCTAssertTrue(app.application.staticTexts["login.title"].waitForExistence(timeout: 8)) + XCTAssertTrue(app.application.textFields["login.username"].exists) + XCTAssertTrue( + app.application.secureTextFields["login.password"].exists + || app.application.textFields["login.password"].exists + ) + XCTAssertTrue(app.application.buttons["login.submit"].exists) + XCTAssertTrue(app.application.buttons["login.userAgreement"].exists) + XCTAssertTrue(app.application.buttons["login.privacyPolicy"].exists) + } + + /// 测试未勾选协议时无法直接登录。 + func testLoginRequiresPrivacyAgreement() throws { + let app = SuixinkanApp(resetState: true).launch(waitForLoading: false) + guard UITestLaunchConfiguration.credentials != nil else { + throw XCTSkip("缺少 UI Test 账号。") + } + + let usernameField = app.application.textFields["login.username"] + XCTAssertTrue(usernameField.waitForExistence(timeout: 8)) + XCTAssertFalse(app.application.buttons["login.submit"].isEnabled) + } + + /// 测试空密码不会离开登录页。 + func testEmptyPasswordDoesNotLeaveLoginScreen() throws { + let app = SuixinkanApp(resetState: true).launch(waitForLoading: false) + let credentials = try UITestLaunchConfiguration.requireCredentials() + + let usernameField = app.application.textFields["login.username"] + XCTAssertTrue(usernameField.waitForExistence(timeout: 8)) + usernameField.tap() + usernameField.typeText(credentials.phone) + + let privacyToggle = app.application.buttons["login.privacy"] + if privacyToggle.exists { + privacyToggle.tap() + } + + app.application.buttons["login.submit"].tap() + app.waitForGlobalLoadingToDisappear(timeout: 5) + XCTAssertTrue(app.isOnLoginScreen) + } +} diff --git a/suixinkanUITests/Home/HomeRoutesUITests.swift b/suixinkanUITests/Home/HomeRoutesUITests.swift new file mode 100644 index 0000000..c4997a9 --- /dev/null +++ b/suixinkanUITests/Home/HomeRoutesUITests.swift @@ -0,0 +1,33 @@ +// +// HomeRoutesUITests.swift +// suixinkanUITests +// + +import XCTest + +final class HomeRoutesUITests: AuthenticatedUITestCase { + /// 参数化遍历首页调试菜单,覆盖去重后的全部 Home 路由目标页。 + func testAllHomeRoutesFromDebugMenu() { + for entry in UITestHomeMenuCatalog.entries { + XCTContext.runActivity(named: entry.menuTitle) { _ in + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: entry.menuTitle, app: app) + UITestExpectationVerifier.verify(entry.expectation, app: app) + restoreAfterRoute(entry.expectation) + } + } + } + + /// 根据打开结果恢复到调试菜单,便于继续下一项。 + private func restoreAfterRoute(_ expectation: UITestHomeMenuCatalog.Expectation) { + switch expectation { + case .tab: + DebugMenuNavigator.open(app: app) + case .staticText: + BackNavigator.dismissCustomTopBarIfNeeded(app: app) + DebugMenuNavigator.returnToMenu(app: app) + default: + DebugMenuNavigator.returnToMenu(app: app) + } + } +} diff --git a/suixinkanUITests/LoginSmokeUITests.swift b/suixinkanUITests/LoginSmokeUITests.swift deleted file mode 100644 index 08f8012..0000000 --- a/suixinkanUITests/LoginSmokeUITests.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// LoginSmokeUITests.swift -// suixinkanUITests -// -// Created by Codex on 2026/6/26. -// - -import XCTest - -final class LoginSmokeUITests: XCTestCase { - override func setUp() { - super.setUp() - continueAfterFailure = false - } - - func testFreshLaunchShowsLoginScreen() { - let app = XCUIApplication() - app.launchArguments.append("-suixinkan-ui-tests-reset-state") - app.launch() - - XCTAssertTrue(app.staticTexts["login.title"].waitForExistence(timeout: 5)) - XCTAssertTrue(app.textFields["login.username"].exists) - XCTAssertTrue(app.secureTextFields["login.password"].exists || app.textFields["login.password"].exists) - XCTAssertTrue(app.buttons["login.submit"].exists) - XCTAssertTrue(app.buttons["login.userAgreement"].exists) - XCTAssertTrue(app.buttons["login.privacyPolicy"].exists) - } -} diff --git a/suixinkanUITests/Modules/InviteUITests.swift b/suixinkanUITests/Modules/InviteUITests.swift new file mode 100644 index 0000000..8e74475 --- /dev/null +++ b/suixinkanUITests/Modules/InviteUITests.swift @@ -0,0 +1,26 @@ +// +// InviteUITests.swift +// suixinkanUITests +// + +import XCTest + +final class InviteUITests: AuthenticatedUITestCase { + /// 测试邀请页与邀请记录页。 + func testInviteAndRecordPages() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "注册邀请", app: app) + NavigationAssertions.waitForNavigationTitle("摄影师邀请", in: app) + + let recordEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '邀请记录'")).firstMatch + if recordEntry.waitForExistence(timeout: 6) { + recordEntry.tap() + NavigationAssertions.waitForNavigationTitle("邀请记录", in: app) + } else { + BackNavigator.goBack(app: app) + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "邀请记录", app: app) + NavigationAssertions.waitForNavigationTitle("邀请记录", in: app) + } + } +} diff --git a/suixinkanUITests/Modules/LiveUITests.swift b/suixinkanUITests/Modules/LiveUITests.swift new file mode 100644 index 0000000..e8f3ae1 --- /dev/null +++ b/suixinkanUITests/Modules/LiveUITests.swift @@ -0,0 +1,29 @@ +// +// LiveUITests.swift +// suixinkanUITests +// + +import XCTest + +final class LiveUITests: AuthenticatedUITestCase { + /// 测试直播管理列表与创建页。 + func testLiveManagementAndCreatePage() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "直播管理", app: app) + NavigationAssertions.waitForNavigationTitle("直播管理", in: app) + + let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch + if addButton.waitForExistence(timeout: 6) { + addButton.tap() + NavigationAssertions.waitForNavigationTitle("添加直播", in: app) + BackNavigator.goBack(app: app) + } + } + + /// 测试直播相册页可打开。 + func testLiveAlbumPage() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "直播相册", app: app) + NavigationAssertions.waitForNavigationTitle("直播相册", in: app) + } +} diff --git a/suixinkanUITests/Modules/LocationReportUITests.swift b/suixinkanUITests/Modules/LocationReportUITests.swift new file mode 100644 index 0000000..7e8ee6a --- /dev/null +++ b/suixinkanUITests/Modules/LocationReportUITests.swift @@ -0,0 +1,26 @@ +// +// LocationReportUITests.swift +// suixinkanUITests +// + +import XCTest + +final class LocationReportUITests: AuthenticatedUITestCase { + /// 测试位置上报与历史记录页。 + func testLocationReportAndHistory() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "位置上报", app: app) + NavigationAssertions.waitForNavigationTitle("位置上报", in: app) + + let historyEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '历史'")).firstMatch + if historyEntry.waitForExistence(timeout: 6) { + historyEntry.tap() + NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app) + } else { + BackNavigator.goBack(app: app) + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "定位上报历史", app: app) + NavigationAssertions.waitForNavigationTitle("位置上报历史", in: app) + } + } +} diff --git a/suixinkanUITests/Modules/MessageCenterUITests.swift b/suixinkanUITests/Modules/MessageCenterUITests.swift new file mode 100644 index 0000000..75dd588 --- /dev/null +++ b/suixinkanUITests/Modules/MessageCenterUITests.swift @@ -0,0 +1,21 @@ +// +// MessageCenterUITests.swift +// suixinkanUITests +// + +import XCTest + +final class MessageCenterUITests: AuthenticatedUITestCase { + /// 测试消息中心列表可展示并尝试进入详情。 + func testMessageCenterListAndDetail() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "消息中心", app: app) + NavigationAssertions.waitForNavigationTitle("消息中心", in: app) + + let firstCell = app.application.cells.element(boundBy: 0) + if firstCell.waitForExistence(timeout: 8) { + firstCell.tap() + NavigationAssertions.waitForNavigationTitle("消息详情", in: app) + } + } +} diff --git a/suixinkanUITests/Modules/PilotCertificationUITests.swift b/suixinkanUITests/Modules/PilotCertificationUITests.swift new file mode 100644 index 0000000..2c24a72 --- /dev/null +++ b/suixinkanUITests/Modules/PilotCertificationUITests.swift @@ -0,0 +1,28 @@ +// +// PilotCertificationUITests.swift +// suixinkanUITests +// + +import XCTest + +final class PilotCertificationUITests: AuthenticatedUITestCase { + /// 测试飞手认证表单校验与验证码按钮。 + func testPilotCertificationValidation() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "飞手认证", app: app) + NavigationAssertions.waitForNavigationTitle("飞手认证", in: app) + + let submitButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '提交'")).firstMatch + if submitButton.waitForExistence(timeout: 6) { + submitButton.tap() + } + + XCTAssertTrue( + app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '请'")).firstMatch + .waitForExistence(timeout: 4) + ) + + let smsButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '验证码'")).firstMatch + XCTAssertTrue(smsButton.exists) + } +} diff --git a/suixinkanUITests/Modules/ProjectsUITests.swift b/suixinkanUITests/Modules/ProjectsUITests.swift new file mode 100644 index 0000000..27df3e3 --- /dev/null +++ b/suixinkanUITests/Modules/ProjectsUITests.swift @@ -0,0 +1,29 @@ +// +// ProjectsUITests.swift +// suixinkanUITests +// + +import XCTest + +final class ProjectsUITests: AuthenticatedUITestCase { + /// 测试摄影师项目列表与新建页。 + func testProjectManagementCreatePage() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "项目管理", app: app) + NavigationAssertions.waitForNavigationTitle("项目管理", in: app) + + let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新建'")).firstMatch + if createButton.waitForExistence(timeout: 6) { + createButton.tap() + NavigationAssertions.waitForNavigationTitle("新建项目", in: app) + BackNavigator.goBack(app: app) + } + } + + /// 测试店铺项目管理页可打开。 + func testStoreProjectManagementPage() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "店铺项目管理", app: app) + NavigationAssertions.waitForNavigationTitle("店铺项目管理", in: app) + } +} diff --git a/suixinkanUITests/Modules/PunchPointUITests.swift b/suixinkanUITests/Modules/PunchPointUITests.swift new file mode 100644 index 0000000..60bdd47 --- /dev/null +++ b/suixinkanUITests/Modules/PunchPointUITests.swift @@ -0,0 +1,21 @@ +// +// PunchPointUITests.swift +// suixinkanUITests +// + +import XCTest + +final class PunchPointUITests: AuthenticatedUITestCase { + /// 测试打卡点列表与新建页。 + func testPunchPointListAndCreatePage() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "打卡点管理", app: app) + NavigationAssertions.waitForNavigationTitle("打卡点管理", in: app) + + let createButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '新增'")).firstMatch + if createButton.waitForExistence(timeout: 6) { + createButton.tap() + NavigationAssertions.waitForNavigationTitle("新增打卡点", in: app) + } + } +} diff --git a/suixinkanUITests/Modules/QueueManagementUITests.swift b/suixinkanUITests/Modules/QueueManagementUITests.swift new file mode 100644 index 0000000..ad0746d --- /dev/null +++ b/suixinkanUITests/Modules/QueueManagementUITests.swift @@ -0,0 +1,21 @@ +// +// QueueManagementUITests.swift +// suixinkanUITests +// + +import XCTest + +final class QueueManagementUITests: AuthenticatedUITestCase { + /// 测试排队管理首页与设置页。 + func testQueueManagementAndSettings() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "排队管理", app: app) + NavigationAssertions.waitForNavigationTitle("排队管理", in: app) + + let settingsEntry = app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '设置'")).firstMatch + if settingsEntry.waitForExistence(timeout: 6) { + settingsEntry.tap() + NavigationAssertions.waitForNavigationTitle("排队设置", in: app) + } + } +} diff --git a/suixinkanUITests/Modules/ScheduleUITests.swift b/suixinkanUITests/Modules/ScheduleUITests.swift new file mode 100644 index 0000000..0c67f23 --- /dev/null +++ b/suixinkanUITests/Modules/ScheduleUITests.swift @@ -0,0 +1,21 @@ +// +// ScheduleUITests.swift +// suixinkanUITests +// + +import XCTest + +final class ScheduleUITests: AuthenticatedUITestCase { + /// 测试日程管理页与新增排班页。 + func testScheduleManagementAndAddPage() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "日程管理", app: app) + NavigationAssertions.waitForNavigationTitle("日程管理", in: app) + + let addButton = app.application.buttons.matching(NSPredicate(format: "label CONTAINS '添加'")).firstMatch + if addButton.waitForExistence(timeout: 6) { + addButton.tap() + NavigationAssertions.waitForNavigationTitle("添加日程", in: app) + } + } +} diff --git a/suixinkanUITests/Modules/TasksUITests.swift b/suixinkanUITests/Modules/TasksUITests.swift new file mode 100644 index 0000000..5917967 --- /dev/null +++ b/suixinkanUITests/Modules/TasksUITests.swift @@ -0,0 +1,32 @@ +// +// TasksUITests.swift +// suixinkanUITests +// + +import XCTest + +final class TasksUITests: AuthenticatedUITestCase { + /// 测试任务列表可进入详情,发布任务页可打开。 + func testTaskListDetailAndCreateValidation() throws { + openTaskManagement() + + let firstCell = app.application.cells.element(boundBy: 0) + if firstCell.waitForExistence(timeout: 10) { + firstCell.tap() + NavigationAssertions.waitForNavigationTitle("任务详情", in: app) + BackNavigator.goBack(app: app) + } else { + throw XCTSkip("测试账号暂无任务数据,跳过详情流测试。") + } + + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "发布任务", app: app) + NavigationAssertions.waitForNavigationTitle("发布任务", in: app) + } + + private func openTaskManagement() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "任务管理", app: app) + NavigationAssertions.waitForNavigationTitle("任务管理", in: app) + } +} diff --git a/suixinkanUITests/Modules/WalletUITests.swift b/suixinkanUITests/Modules/WalletUITests.swift new file mode 100644 index 0000000..b5d5cc2 --- /dev/null +++ b/suixinkanUITests/Modules/WalletUITests.swift @@ -0,0 +1,42 @@ +// +// WalletUITests.swift +// suixinkanUITests +// + +import XCTest + +final class WalletUITests: AuthenticatedUITestCase { + /// 测试钱包首页与提现表单校验。 + func testWalletWithdrawValidation() { + openWallet() + + app.application.staticTexts["提现"].tap() + NavigationAssertions.waitForNavigationTitle("提现申请", in: app) + + let submitButton = app.application.buttons["提交提现申请"] + XCTAssertTrue(submitButton.waitForExistence(timeout: 6)) + submitButton.tap() + XCTAssertTrue( + app.application.staticTexts["请输入提现金额"].waitForExistence(timeout: 4) + || app.application.staticTexts.matching(NSPredicate(format: "label CONTAINS '金额'")).firstMatch.exists + ) + } + + /// 测试银行卡与积分兑换二级页可打开。 + func testWalletSecondaryPages() { + openWallet() + + app.application.staticTexts["银行卡"].tap() + NavigationAssertions.waitForNavigationTitle("银行卡设置", in: app) + BackNavigator.goBack(app: app) + + app.application.staticTexts["积分兑换"].tap() + NavigationAssertions.waitForNavigationTitle("积分兑换", in: app) + } + + private func openWallet() { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "我的钱包", app: app) + NavigationAssertions.waitForNavigationTitle("我的钱包", in: app) + } +} diff --git a/suixinkanUITests/Orders/OrdersFlowUITests.swift b/suixinkanUITests/Orders/OrdersFlowUITests.swift new file mode 100644 index 0000000..c9c0359 --- /dev/null +++ b/suixinkanUITests/Orders/OrdersFlowUITests.swift @@ -0,0 +1,76 @@ +// +// OrdersFlowUITests.swift +// suixinkanUITests +// + +import XCTest + +final class OrdersFlowUITests: AuthenticatedUITestCase { + /// 测试门店订单列表可进入详情页。 + func testStoreOrderDetailFlow() throws { + TabBarNavigator.select(.orders, app: app) + + app.application.staticTexts["订单管理"].tap() + app.waitForGlobalLoadingToDisappear() + + let firstOrderCard = app.application.otherElements + .matching(NSPredicate(format: "identifier BEGINSWITH 'orders.store.card.'")) + .firstMatch + if !firstOrderCard.waitForExistence(timeout: 10) { + throw XCTSkip("测试账号当前景区下暂无门店订单,跳过详情流测试。") + } + + firstOrderCard.tap() + NavigationAssertions.waitForNavigationTitle("订单详情", in: app) + BackNavigator.goBack(app: app) + } + + /// 测试核销订单列表可进入详情页。 + func testWriteOffOrderDetailFlow() throws { + TabBarNavigator.select(.orders, app: app) + + app.application.staticTexts["核销订单"].tap() + app.waitForGlobalLoadingToDisappear() + + let firstDetailButton = app.application.buttons + .matching(NSPredicate(format: "identifier BEGINSWITH 'orders.writeoff.detail.'")) + .firstMatch + if !firstDetailButton.waitForExistence(timeout: 10) { + throw XCTSkip("测试账号当前景区下暂无核销订单,跳过详情流测试。") + } + + firstDetailButton.tap() + NavigationAssertions.waitForNavigationTitle("核销订单详情", in: app) + BackNavigator.goBack(app: app) + } + + /// 测试押金订单链路可进入列表与详情。 + func testDepositOrderFlow() throws { + DebugMenuNavigator.open(app: app) + DebugMenuNavigator.tapMenu(title: "押金订单详情", app: app) + NavigationAssertions.waitForNavigationTitle("押金订单", in: app) + + let firstCell = app.application.cells.element(boundBy: 0) + if firstCell.waitForExistence(timeout: 8) { + firstCell.tap() + NavigationAssertions.waitForNavigationTitle("押金订单详情", in: app) + BackNavigator.goBack(app: app) + } else { + throw XCTSkip("测试账号暂无押金订单,跳过详情流测试。") + } + } + + /// 测试扫码页可打开并关闭。 + func testScannerCanOpenAndClose() { + TabBarNavigator.select(.orders, app: app) + + let scanButton = app.application.buttons["扫码核销"] + XCTAssertTrue(scanButton.waitForExistence(timeout: 6)) + scanButton.tap() + + let closeButton = app.application.buttons["scanner.close"] + XCTAssertTrue(closeButton.waitForExistence(timeout: 8)) + closeButton.tap() + NavigationAssertions.waitForNavigationTitle("订单", in: app) + } +} diff --git a/suixinkanUITests/Profile/ProfileRoutesUITests.swift b/suixinkanUITests/Profile/ProfileRoutesUITests.swift new file mode 100644 index 0000000..82a3523 --- /dev/null +++ b/suixinkanUITests/Profile/ProfileRoutesUITests.swift @@ -0,0 +1,49 @@ +// +// ProfileRoutesUITests.swift +// suixinkanUITests +// + +import XCTest + +final class ProfileRoutesUITests: AuthenticatedUITestCase { + /// 测试系统设置页及协议入口可打开。 + func testSettingsAndAgreementPages() { + TabBarNavigator.select(.profile, app: app) + + app.application.staticTexts["系统设置"].tap() + NavigationAssertions.waitForNavigationTitle("设置", in: app) + + app.application.staticTexts["关于我们"].tap() + NavigationAssertions.waitForNavigationTitle("关于我们", in: app) + BackNavigator.goBack(app: app) + + app.application.staticTexts["用户协议"].tap() + NavigationAssertions.waitForNavigationTitle("用户协议", in: app) + BackNavigator.goBack(app: app) + + app.application.staticTexts["隐私政策"].tap() + NavigationAssertions.waitForNavigationTitle("隐私政策", in: app) + BackNavigator.goBack(app: app) + BackNavigator.goBack(app: app) + } + + /// 测试实名认证页可打开并展示表单。 + func testRealNameAuthPage() { + TabBarNavigator.select(.profile, app: app) + + app.application.staticTexts["认证状态"].tap() + NavigationAssertions.waitForNavigationTitle("实名认证", in: app) + XCTAssertTrue(app.application.textFields.count > 0 || app.application.secureTextFields.count > 0) + BackNavigator.goBack(app: app) + } + + /// 测试账号切换页可打开并取消返回。 + func testAccountSwitchPage() { + TabBarNavigator.select(.profile, app: app) + + app.application.staticTexts["当前账号"].tap() + NavigationAssertions.waitForNavigationTitle("账号切换", in: app) + BackNavigator.goBack(app: app) + NavigationAssertions.waitForNavigationTitle("个人信息", in: app) + } +} diff --git a/suixinkanUITests/README.md b/suixinkanUITests/README.md new file mode 100644 index 0000000..9929a28 --- /dev/null +++ b/suixinkanUITests/README.md @@ -0,0 +1,88 @@ +# suixinkan UI Tests + +本目录包含 App 的 XCUITest 回归套件,通过 **api-test 真实接口** 验证页面可达性与关键业务流程。 + +## 约束 + +- 测试代码仅位于 `suixinkanUITests/`,App 侧仅扩展 `suixinkan/App/` 基础设施(如 `AppUITestLaunchState`)。 +- **不修改** `Features/`、`Core/` 业务代码,不在 App 内注入 Mock 网络。 +- DEBUG 构建下通过「我的 → 首页调试」批量覆盖首页路由。 + +## 环境变量 + +在 Scheme **Environment Variables** 或 `xcodebuild` 中配置: + +| 变量 | 必填 | 说明 | +| --- | --- | --- | +| `SUI_TEST_PHONE` | 是 | 登录手机号 | +| `SUI_TEST_PASSWORD` | 是 | 登录密码 | +| `SUI_TEST_ACCOUNT_NAME` | 否 | 多账号选择页要点的账号展示名 | + +**本机快捷配置(推荐):** 在 `suixinkanUITests/UITestCredentials.local.plist` 中配置 `SUI_TEST_PHONE`、`SUI_TEST_PASSWORD` 和可选的 `SUI_TEST_ACCOUNT_NAME`。该文件已加入 `.gitignore`,不会提交到 Git。环境变量优先级高于本地 plist。 + +缺少账号时,需要登录的用例会自动 `XCTSkip`。 + +已登录用例默认复用 Keychain 会话,每个测试类只完整登录一次;仅 `LoginSmokeUITests` 会显式清空本地状态。 + +## 测试数据约定(api-test) + +以下数据建议为 UI Test 专用账号预置,否则部分「列表 → 详情」用例会跳过: + +- 至少 1 条门店订单、1 条核销订单 +- 至少 1 条任务、项目、打卡点、押金订单(可选) +- 账号需有景区/门店上下文 + +## 运行命令 + +```bash +# 仅跑 UI Tests(需先配置环境变量) +xcodebuild test \ + -workspace suixinkan.xcworkspace \ + -scheme suixinkan \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:suixinkanUITests + +# 带环境变量示例 +SUI_TEST_PHONE=13800138000 \ +SUI_TEST_PASSWORD='your-password' \ +xcodebuild test \ + -workspace suixinkan.xcworkspace \ + -scheme suixinkan \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -only-testing:suixinkanUITests +``` + +## 启动参数 + +UI Test 进程会自动传入: + +- `-suixinkan-ui-tests`:主开关,App 侧跳过推送注册与排队 WebSocket +- `-suixinkan-ui-tests-reset-state`:冷启动清理 Keychain / UserDefaults + +## 目录结构 + +``` +suixinkanUITests/ + Support/ # 启动配置、登录、导航、断言 + Auth/ # 登录冒烟与完整登录流 + Shell/ # 主 Tab + Home/ # 首页调试菜单全路由 + Profile/ # 我的二级页 + Orders/ # 订单深层流 + Modules/ # 各业务模块交互 +``` + +## 降级 / 跳过项 + +以下场景在模拟器 UI Test 中仅验证「可打开 / 可关闭」,不做完整硬件或上传闭环: + +- 真机摄像头扫码内容识别 +- 直播推流预览与音视频专项 +- 高德地图完整选点 +- OSS 真实文件上传(可选只验证选图 Sheet) + +## 新增页面时 + +1. 在 `Support/UITestHomeMenuCatalog.swift` 补充调试菜单覆盖项(如有首页入口)。 +2. 在对应 `Modules/*UITests.swift` 增加关键交互用例。 +3. 优先使用已有 `accessibilityIdentifier`、导航栏标题和稳定文案定位,不修改 Features 视图。 diff --git a/suixinkanUITests/Shell/MainTabUITests.swift b/suixinkanUITests/Shell/MainTabUITests.swift new file mode 100644 index 0000000..8e82c03 --- /dev/null +++ b/suixinkanUITests/Shell/MainTabUITests.swift @@ -0,0 +1,28 @@ +// +// MainTabUITests.swift +// suixinkanUITests +// + +import XCTest + +final class MainTabUITests: AuthenticatedUITestCase { + /// 测试四个主 Tab 均可切换且展示核心标题。 + func testAllMainTabsAreReachable() { + TabBarNavigator.select(.home, app: app) + XCTAssertTrue( + app.application.staticTexts["常用应用"].waitForExistence(timeout: 8) + || app.application.staticTexts["请选择景区"].exists + ) + + TabBarNavigator.select(.orders, app: app) + NavigationAssertions.waitForNavigationTitle("订单", in: app) + NavigationAssertions.waitForStaticText("订单管理", in: app) + + TabBarNavigator.select(.statistics, app: app) + NavigationAssertions.waitForNavigationTitle("数据", in: app) + + TabBarNavigator.select(.profile, app: app) + NavigationAssertions.waitForNavigationTitle("个人信息", in: app) + NavigationAssertions.waitForStaticText("系统设置", in: app) + } +} diff --git a/suixinkanUITests/Support/AuthenticatedUITestCase.swift b/suixinkanUITests/Support/AuthenticatedUITestCase.swift new file mode 100644 index 0000000..d5e21f2 --- /dev/null +++ b/suixinkanUITests/Support/AuthenticatedUITestCase.swift @@ -0,0 +1,42 @@ +// +// AuthenticatedUITestCase.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// 需要已登录态的 UI Test 基类,每个测试类只启动并登录一次。 +class AuthenticatedUITestCase: XCTestCase { + private static var sharedApp: SuixinkanApp? + + var app: SuixinkanApp! + + override class func setUp() { + super.setUp() + guard sharedApp == nil else { return } + guard UITestLaunchConfiguration.credentials != nil else { return } + + sharedApp = try? UITestAuthenticatedSession.launchLoggedIn() + } + + override class func tearDown() { + sharedApp?.application.terminate() + sharedApp = nil + super.tearDown() + } + + override func setUpWithError() throws { + try super.setUpWithError() + continueAfterFailure = false + + guard let sharedApp = Self.sharedApp else { + throw XCTSkip("缺少 UI Test 账号或登录失败。") + } + + app = sharedApp + app.application.activate() + app.waitForGlobalLoadingToDisappear() + } +} diff --git a/suixinkanUITests/Support/BackNavigator.swift b/suixinkanUITests/Support/BackNavigator.swift new file mode 100644 index 0000000..f596fb3 --- /dev/null +++ b/suixinkanUITests/Support/BackNavigator.swift @@ -0,0 +1,34 @@ +// +// BackNavigator.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// 导航返回封装。 +enum BackNavigator { + /// 通过导航栏返回按钮回到上一页。 + static func goBack(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) { + let navigationBar = app.application.navigationBars.element(boundBy: 0) + guard navigationBar.exists else { + XCTFail("当前页面没有可返回的导航栏。", file: file, line: line) + return + } + + let backButton = navigationBar.buttons.element(boundBy: 0) + XCTAssertTrue(backButton.waitForExistence(timeout: 4), "未找到返回按钮。", file: file, line: line) + backButton.tap() + app.waitForGlobalLoadingToDisappear() + } + + /// 关闭自定义顶栏页面(如全部功能页)。 + static func dismissCustomTopBarIfNeeded(app: SuixinkanApp) { + let backCandidates = app.application.buttons.matching(NSPredicate(format: "label CONTAINS 'chevron.left'")) + if backCandidates.count > 0 { + backCandidates.element(boundBy: 0).tap() + app.waitForGlobalLoadingToDisappear() + } + } +} diff --git a/suixinkanUITests/Support/DebugMenuNavigator.swift b/suixinkanUITests/Support/DebugMenuNavigator.swift new file mode 100644 index 0000000..2a3fe7a --- /dev/null +++ b/suixinkanUITests/Support/DebugMenuNavigator.swift @@ -0,0 +1,42 @@ +// +// DebugMenuNavigator.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// DEBUG 首页菜单调试页导航封装。 +enum DebugMenuNavigator { + /// 进入「我的 → 首页调试」列表。 + static func open(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) { + TabBarNavigator.select(.profile, app: app) + let debugEntry = app.application.staticTexts["首页调试"] + XCTAssertTrue(debugEntry.waitForExistence(timeout: 8), "未找到首页调试入口。", file: file, line: line) + debugEntry.tap() + NavigationAssertions.waitForNavigationTitle("首页菜单调试", in: app, file: file, line: line) + } + + /// 在调试菜单中点击指定标题。 + static func tapMenu( + title: String, + app: SuixinkanApp, + file: StaticString = #filePath, + line: UInt = #line + ) { + let cell = app.application.staticTexts[title] + XCTAssertTrue(cell.waitForExistence(timeout: 8), "调试菜单未找到:\(title)", file: file, line: line) + cell.tap() + app.waitForGlobalLoadingToDisappear() + } + + /// 返回调试菜单列表。 + static func returnToMenu(app: SuixinkanApp) { + if app.application.navigationBars["首页菜单调试"].exists { + return + } + BackNavigator.goBack(app: app) + NavigationAssertions.waitForNavigationTitle("首页菜单调试", in: app) + } +} diff --git a/suixinkanUITests/Support/LoginFlow.swift b/suixinkanUITests/Support/LoginFlow.swift new file mode 100644 index 0000000..31ef094 --- /dev/null +++ b/suixinkanUITests/Support/LoginFlow.swift @@ -0,0 +1,104 @@ +// +// LoginFlow.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// 登录与多账号选择流程封装。 +enum LoginFlow { + /// 如当前在登录页则执行完整登录,否则直接返回。 + static func loginIfNeeded( + app: SuixinkanApp, + credentials: UITestCredentials, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard app.isOnLoginScreen else { return } + + fillLoginForm(app: app, credentials: credentials) + submitLogin(app: app) + handleAccountSelectionIfNeeded(app: app, credentials: credentials) + app.waitForGlobalLoadingToDisappear() + + XCTAssertTrue( + app.isOnMainTabs, + "登录后应进入主 Tab。", + file: file, + line: line + ) + } + + /// 填写登录表单并勾选协议。 + static func fillLoginForm(app: SuixinkanApp, credentials: UITestCredentials) { + let usernameField = app.application.textFields["login.username"] + XCTAssertTrue(usernameField.waitForExistence(timeout: 8)) + usernameField.tap() + usernameField.clearAndType(credentials.phone) + + let passwordField = preferredPasswordField(in: app.application) + XCTAssertTrue(passwordField.waitForExistence(timeout: 4)) + passwordField.tap() + passwordField.clearAndType(credentials.password) + + let privacyToggle = app.application.buttons["login.privacy"] + if privacyToggle.exists { + privacyToggle.tap() + } + } + + /// 点击登录按钮。 + static func submitLogin(app: SuixinkanApp) { + let submitButton = app.application.buttons["login.submit"] + XCTAssertTrue(submitButton.waitForExistence(timeout: 4)) + submitButton.tap() + app.waitForGlobalLoadingToDisappear() + } + + /// 处理 v9 多账号选择页。 + static func handleAccountSelectionIfNeeded( + app: SuixinkanApp, + credentials: UITestCredentials + ) { + let accountSelectionTitle = app.application.navigationBars["选择账号"] + guard accountSelectionTitle.waitForExistence(timeout: 6) else { return } + + if let accountName = credentials.accountName { + let accountCell = app.application.staticTexts[accountName] + if accountCell.waitForExistence(timeout: 4) { + accountCell.tap() + } + } + + let confirmButton = app.application.buttons["进入系统"] + XCTAssertTrue(confirmButton.waitForExistence(timeout: 4)) + confirmButton.tap() + app.waitForGlobalLoadingToDisappear() + } + + /// 优先读取安全输入框,兼容调试构建下的普通文本框。 + private static func preferredPasswordField(in app: XCUIApplication) -> XCUIElement { + let secureField = app.secureTextFields["login.password"] + if secureField.exists { + return secureField + } + return app.textFields["login.password"] + } +} + +private extension XCUIElement { + /// 清空并重新输入文本。 + func clearAndType(_ text: String) { + guard let currentValue = value as? String else { + typeText(text) + return + } + + tap() + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count) + typeText(deleteString) + typeText(text) + } +} diff --git a/suixinkanUITests/Support/NavigationAssertions.swift b/suixinkanUITests/Support/NavigationAssertions.swift new file mode 100644 index 0000000..6d206fb --- /dev/null +++ b/suixinkanUITests/Support/NavigationAssertions.swift @@ -0,0 +1,74 @@ +// +// NavigationAssertions.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// 页面导航与可见性断言工具。 +enum NavigationAssertions { + /// 等待导航栏标题出现。 + @discardableResult + static func waitForNavigationTitle( + _ title: String, + in app: SuixinkanApp, + timeout: TimeInterval = 12, + file: StaticString = #filePath, + line: UInt = #line + ) -> Bool { + let navigationBar = app.application.navigationBars[title] + let exists = navigationBar.waitForExistence(timeout: timeout) + XCTAssertTrue(exists, "未找到导航标题:\(title)", file: file, line: line) + return exists + } + + /// 等待任意一个导航标题出现。 + @discardableResult + static func waitForAnyNavigationTitle( + _ titles: [String], + in app: SuixinkanApp, + timeout: TimeInterval = 12, + file: StaticString = #filePath, + line: UInt = #line + ) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + for title in titles { + if app.application.navigationBars[title].exists { + return title + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + + XCTFail("未找到任一导航标题:\(titles.joined(separator: " / "))", file: file, line: line) + return nil + } + + /// 等待静态文案出现。 + @discardableResult + static func waitForStaticText( + _ text: String, + in app: SuixinkanApp, + timeout: TimeInterval = 8, + file: StaticString = #filePath, + line: UInt = #line + ) -> Bool { + let label = app.application.staticTexts[text] + let exists = label.waitForExistence(timeout: timeout) + XCTAssertTrue(exists, "未找到文案:\(text)", file: file, line: line) + return exists + } + + /// 断言页面未停留在全局 Loading。 + static func assertNotStuckOnLoading( + app: SuixinkanApp, + file: StaticString = #filePath, + line: UInt = #line + ) { + let loading = app.application.staticTexts["加载中"] + XCTAssertFalse(loading.exists, "页面不应长期停留在全局 Loading。", file: file, line: line) + } +} diff --git a/suixinkanUITests/Support/SuixinkanUIApplication.swift b/suixinkanUITests/Support/SuixinkanUIApplication.swift new file mode 100644 index 0000000..d89d171 --- /dev/null +++ b/suixinkanUITests/Support/SuixinkanUIApplication.swift @@ -0,0 +1,78 @@ +// +// SuixinkanUIApplication.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// 封装 XCUIApplication 的常用等待、权限弹窗和全局 Loading 处理。 +final class SuixinkanApp { + let application: XCUIApplication + private let resetState: Bool + + /// 创建并配置 UI Test 启动参数的应用实例。 + init(resetState: Bool = false) { + self.resetState = resetState + application = XCUIApplication() + UITestLaunchConfiguration.apply(to: application, resetState: resetState) + } + + /// 启动 App 并等待首屏稳定。 + @discardableResult + func launch(waitForLoading: Bool = true) -> Self { + if application.state == .runningForeground { + application.activate() + } else { + application.launch() + } + dismissSystemAlertsIfNeeded() + if waitForLoading { + waitForGlobalLoadingToDisappear() + } + return self + } + + /// 等待全局 Loading 层消失。 + func waitForGlobalLoadingToDisappear(timeout: TimeInterval = 45) { + let loading = application.staticTexts["加载中"] + guard loading.waitForExistence(timeout: 2) else { return } + + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: loading) + _ = XCTWaiter.wait(for: [expectation], timeout: timeout) + } + + /// 处理系统权限弹窗,尽量点允许以便页面继续加载。 + func dismissSystemAlertsIfNeeded() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let allowButtons = ["允许", "好", "使用App时允许", "Allow", "Allow While Using App", "OK"] + for title in allowButtons { + let button = springboard.buttons[title] + if button.waitForExistence(timeout: 1) { + button.tap() + } + } + } + + /// 判断当前是否仍在登录页。 + var isOnLoginScreen: Bool { + application.staticTexts["login.title"].exists + } + + /// 判断主 Tab 是否已出现。 + var isOnMainTabs: Bool { + application.buttons["首页"].waitForExistence(timeout: 1) + || application.buttons["我的"].waitForExistence(timeout: 1) + } + + /// 等待冷启动后进入登录页或主 Tab。 + func waitForInitialScreen(timeout: TimeInterval = 45) { + if application.staticTexts["login.title"].waitForExistence(timeout: 2) { + return + } + _ = application.buttons["首页"].waitForExistence(timeout: timeout) + || application.buttons["我的"].waitForExistence(timeout: 2) + } +} diff --git a/suixinkanUITests/Support/TabBarNavigator.swift b/suixinkanUITests/Support/TabBarNavigator.swift new file mode 100644 index 0000000..0c31456 --- /dev/null +++ b/suixinkanUITests/Support/TabBarNavigator.swift @@ -0,0 +1,26 @@ +// +// TabBarNavigator.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// 主 Tab 导航封装。 +enum TabBarNavigator { + enum Tab: String, CaseIterable { + case home = "首页" + case orders = "订单" + case statistics = "数据" + case profile = "我的" + } + + /// 切换到指定 Tab 并等待页面稳定。 + static func select(_ tab: Tab, app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) { + let button = app.application.buttons[tab.rawValue] + XCTAssertTrue(button.waitForExistence(timeout: 8), "未找到 Tab:\(tab.rawValue)", file: file, line: line) + button.tap() + app.waitForGlobalLoadingToDisappear() + } +} diff --git a/suixinkanUITests/Support/UITestAuthenticatedSession.swift b/suixinkanUITests/Support/UITestAuthenticatedSession.swift new file mode 100644 index 0000000..ff5c5fb --- /dev/null +++ b/suixinkanUITests/Support/UITestAuthenticatedSession.swift @@ -0,0 +1,24 @@ +// +// UITestAuthenticatedSession.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// 已登录会话启动器,供需要主界面的 UI Test 复用。 +enum UITestAuthenticatedSession { + /// 启动 App 并完成登录,返回配置好的应用包装器。 + static func launchLoggedIn( + resetState: Bool = false, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> SuixinkanApp { + let credentials = try UITestLaunchConfiguration.requireCredentials(file: file, line: line) + let app = SuixinkanApp(resetState: resetState).launch() + app.waitForInitialScreen() + LoginFlow.loginIfNeeded(app: app, credentials: credentials, file: file, line: line) + return app + } +} diff --git a/suixinkanUITests/Support/UITestExpectationVerifier.swift b/suixinkanUITests/Support/UITestExpectationVerifier.swift new file mode 100644 index 0000000..7aabf49 --- /dev/null +++ b/suixinkanUITests/Support/UITestExpectationVerifier.swift @@ -0,0 +1,43 @@ +// +// UITestExpectationVerifier.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// 根据首页菜单目录断言页面打开结果。 +enum UITestExpectationVerifier { + /// 校验调试菜单打开后的目标页。 + static func verify( + _ expectation: UITestHomeMenuCatalog.Expectation, + app: SuixinkanApp, + file: StaticString = #filePath, + line: UInt = #line + ) { + switch expectation { + case .navigationTitle(let title): + NavigationAssertions.waitForNavigationTitle(title, in: app, file: file, line: line) + case .anyNavigationTitle(let titles): + _ = NavigationAssertions.waitForAnyNavigationTitle(titles, in: app, file: file, line: line) + case .tab(let tab): + switch tab { + case .orders: + NavigationAssertions.waitForNavigationTitle("订单", in: app, file: file, line: line) + case .statistics: + NavigationAssertions.waitForNavigationTitle("数据", in: app, file: file, line: line) + case .home: + XCTAssertTrue(app.application.staticTexts["常用应用"].waitForExistence(timeout: 8) || app.application.staticTexts["请选择景区"].exists, file: file, line: line) + case .profile: + NavigationAssertions.waitForNavigationTitle("个人信息", in: app, file: file, line: line) + } + case .staticText(let text): + NavigationAssertions.waitForStaticText(text, in: app, file: file, line: line) + case .unsupportedPlaceholder(let title): + _ = NavigationAssertions.waitForAnyNavigationTitle([title], in: app, file: file, line: line) + } + + NavigationAssertions.assertNotStuckOnLoading(app: app, file: file, line: line) + } +} diff --git a/suixinkanUITests/Support/UITestHomeMenuCatalog.swift b/suixinkanUITests/Support/UITestHomeMenuCatalog.swift new file mode 100644 index 0000000..c26972f --- /dev/null +++ b/suixinkanUITests/Support/UITestHomeMenuCatalog.swift @@ -0,0 +1,73 @@ +// +// UITestHomeMenuCatalog.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import Foundation + +/// 首页调试菜单覆盖目录,按去重后的目标页组织。 +enum UITestHomeMenuCatalog { + /// 打开菜单后的期望结果。 + enum Expectation: Equatable { + case navigationTitle(String) + case anyNavigationTitle([String]) + case tab(TabBarNavigator.Tab) + case staticText(String) + case unsupportedPlaceholder(title: String) + } + + /// 单个调试菜单用例。 + struct Entry: Equatable { + let menuTitle: String + let expectation: Expectation + } + + /// 去重后的首页路由覆盖表,同义 URI 只保留一个菜单标题。 + static let entries: [Entry] = [ + Entry(menuTitle: "空间设置", expectation: .navigationTitle("个人信息")), + Entry(menuTitle: "相册管理", expectation: .navigationTitle("相册管理")), + Entry(menuTitle: "相册预览上传", expectation: .navigationTitle("相册预览上传")), + Entry(menuTitle: "我的钱包", expectation: .navigationTitle("我的钱包")), + Entry(menuTitle: "立即收款", expectation: .navigationTitle("立即收款")), + Entry(menuTitle: "相册云盘", expectation: .navigationTitle("相册云盘")), + Entry(menuTitle: "传输管理", expectation: .navigationTitle("传输记录")), + Entry(menuTitle: "素材管理", expectation: .navigationTitle("素材管理")), + Entry(menuTitle: "上传素材", expectation: .navigationTitle("上传素材")), + Entry(menuTitle: "任务管理", expectation: .navigationTitle("任务管理")), + Entry(menuTitle: "发布任务", expectation: .navigationTitle("发布任务")), + Entry(menuTitle: "日程管理", expectation: .navigationTitle("日程管理")), + Entry(menuTitle: "消息中心", expectation: .navigationTitle("消息中心")), + Entry(menuTitle: "打卡点管理", expectation: .navigationTitle("打卡点管理")), + Entry(menuTitle: "样片管理", expectation: .navigationTitle("样片管理")), + Entry(menuTitle: "上传样片", expectation: .navigationTitle("上传样片")), + Entry(menuTitle: "直播管理", expectation: .navigationTitle("直播管理")), + Entry(menuTitle: "直播相册", expectation: .navigationTitle("直播相册")), + Entry(menuTitle: "景区选择", expectation: .navigationTitle("景区选择")), + Entry(menuTitle: "景区申请", expectation: .navigationTitle("景区申请")), + Entry(menuTitle: "权限申请", expectation: .navigationTitle("申请权限")), + Entry(menuTitle: "权限申请状态", expectation: .anyNavigationTitle(["权限申请", "权限申请状态"])), + Entry(menuTitle: "景区结算", expectation: .navigationTitle("景区结算")), + Entry(menuTitle: "结算审核", expectation: .navigationTitle("结算审核")), + Entry(menuTitle: "核销订单", expectation: .tab(.orders)), + Entry(menuTitle: "景区订单", expectation: .tab(.orders)), + Entry(menuTitle: "数据统计", expectation: .tab(.statistics)), + Entry(menuTitle: "项目管理", expectation: .navigationTitle("项目管理")), + Entry(menuTitle: "店铺项目管理", expectation: .navigationTitle("店铺项目管理")), + Entry(menuTitle: "位置上报", expectation: .navigationTitle("位置上报")), + Entry(menuTitle: "注册邀请", expectation: .navigationTitle("摄影师邀请")), + Entry(menuTitle: "邀请记录", expectation: .navigationTitle("邀请记录")), + Entry(menuTitle: "店铺管理", expectation: .staticText("全部功能")), + Entry(menuTitle: "飞行管理", expectation: .unsupportedPlaceholder(title: "飞行管理")), + Entry(menuTitle: "飞手认证", expectation: .navigationTitle("飞手认证")), + Entry(menuTitle: "飞控", expectation: .unsupportedPlaceholder(title: "飞控")), + Entry(menuTitle: "排队管理", expectation: .navigationTitle("排队管理")), + Entry(menuTitle: "运营区域", expectation: .navigationTitle("运营区域")), + Entry(menuTitle: "押金订单详情", expectation: .navigationTitle("押金订单")), + Entry(menuTitle: "提现审核", expectation: .navigationTitle("提现审核")), + Entry(menuTitle: "定位上报历史", expectation: .navigationTitle("位置上报历史")), + Entry(menuTitle: "更多功能", expectation: .staticText("全部功能")), + Entry(menuTitle: "系统设置", expectation: .navigationTitle("设置")) + ] +} diff --git a/suixinkanUITests/Support/UITestLaunchConfiguration.swift b/suixinkanUITests/Support/UITestLaunchConfiguration.swift new file mode 100644 index 0000000..f4ec611 --- /dev/null +++ b/suixinkanUITests/Support/UITestLaunchConfiguration.swift @@ -0,0 +1,115 @@ +// +// UITestLaunchConfiguration.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +/// UI Test 启动参数与环境变量,需与 App 侧 `AppUITestLaunchState` 保持同名。 +enum UITestLaunchConfiguration { + static let uiTestsArgument = "-suixinkan-ui-tests" + static let resetArgument = "-suixinkan-ui-tests-reset-state" + + static let phoneEnvironmentKey = "SUI_TEST_PHONE" + static let passwordEnvironmentKey = "SUI_TEST_PASSWORD" + static let accountNameEnvironmentKey = "SUI_TEST_ACCOUNT_NAME" + + /// 为 XCUIApplication 注入 UI Test 启动参数;仅在需要冷启动空白态时传入 resetState。 + static func apply(to app: XCUIApplication, resetState: Bool = false) { + if !app.launchArguments.contains(uiTestsArgument) { + app.launchArguments.append(uiTestsArgument) + } + + if resetState { + if !app.launchArguments.contains(resetArgument) { + app.launchArguments.append(resetArgument) + } + } else { + app.launchArguments.removeAll { $0 == resetArgument } + } + } + + /// 从 Scheme / xcodebuild 环境变量或本地 plist 读取测试账号。 + static var credentials: UITestCredentials? { + if let environmentCredentials = credentialsFromEnvironment() { + return environmentCredentials + } + return credentialsFromLocalPlist() + } + + /// 缺少测试账号时跳过需要真实登录的用例。 + static func requireCredentials(file: StaticString = #filePath, line: UInt = #line) throws -> UITestCredentials { + guard let credentials else { + throw XCTSkip( + "缺少 UI Test 账号。请配置环境变量 \(phoneEnvironmentKey) / \(passwordEnvironmentKey),或在本机创建 UITestCredentials.local.plist。" + ) + } + return credentials + } + + /// 从 Scheme / xcodebuild 环境变量读取测试账号。 + private static func credentialsFromEnvironment() -> UITestCredentials? { + let environment = ProcessInfo.processInfo.environment + guard + let phone = environment[phoneEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !phone.isEmpty, + let password = environment[passwordEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !password.isEmpty + else { + return nil + } + + let accountName = environment[accountNameEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + return UITestCredentials( + phone: phone, + password: password, + accountName: accountName?.isEmpty == false ? accountName : nil + ) + } + + /// 从 gitignore 的本地 plist 读取测试账号,便于本机开发配置。 + private static func credentialsFromLocalPlist() -> UITestCredentials? { + let plistURL = localCredentialsURL + guard + FileManager.default.fileExists(atPath: plistURL.path), + let dictionary = NSDictionary(contentsOf: plistURL) as? [String: String] + else { + return nil + } + + guard + let phone = dictionary[phoneEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !phone.isEmpty, + let password = dictionary[passwordEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !password.isEmpty + else { + return nil + } + + let accountName = dictionary[accountNameEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + return UITestCredentials( + phone: phone, + password: password, + accountName: accountName?.isEmpty == false ? accountName : nil + ) + } + + /// 本地测试账号 plist 路径。 + private static var localCredentialsURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("UITestCredentials.local.plist") + } +} + +/// UI Test 登录账号实体。 +struct UITestCredentials: Equatable { + let phone: String + let password: String + let accountName: String? +}