From 50b8a8c8e21501e6f835cdb8867cb972f31488dd Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Mon, 31 Aug 2026 08:40:58 +0200 Subject: [PATCH] =?UTF-8?q?Allinea=20iOS=20ad=20Android=20sul=20dialog=20C?= =?UTF-8?q?onfigura/Pi=C3=B9=20tardi=20e=20porta=20l'app=20a=202.0.12.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggiunge UI test sul simulatore (hub, lingue, wizard, diretta) e identificatori di accessibilità per poterli ripetere su collaudo e produzione. Co-authored-by: Cursor --- .../ios/MatchLiveTv.xcodeproj/project.pbxproj | 115 +++- .../xcschemes/MatchLiveTv.xcscheme | 10 + native/ios/MatchLiveTv/Core/AppConfig.swift | 4 + native/ios/MatchLiveTv/Core/AppLanguage.swift | 1 + native/ios/MatchLiveTv/Core/TokenStore.swift | 17 +- native/ios/MatchLiveTv/Resources/Info.plist | 8 +- .../UI/Account/AccountScreen.swift | 5 + .../Broadcast/BroadcastControlsOverlay.swift | 8 + .../UI/Login/ForgotPasswordScreen.swift | 2 + .../MatchLiveTv/UI/Login/LoginScreen.swift | 3 + .../UI/Matches/MatchesScreen.swift | 124 +++- .../UI/Wizard/MatchCoverSection.swift | 1 + .../UI/Wizard/StepMatchScreen.swift | 1 + .../UI/Wizard/StepNetworkTestScreen.swift | 3 + .../UI/Wizard/TeamBrandingEditor.swift | 1 + .../UI/Wizard/WizardComponents.swift | 2 + .../UI/Wizard/WizardShellScreen.swift | 1 + .../MatchLiveTvUITests/HubFlowsUITests.swift | 560 ++++++++++++++++++ native/ios/generate_xcodeproj.py | 8 +- 19 files changed, 842 insertions(+), 32 deletions(-) create mode 100644 native/ios/MatchLiveTvUITests/HubFlowsUITests.swift diff --git a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj index 1f40e5a..cb642aa 100644 --- a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj +++ b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 8F7E6D5C4B3A29180796A5B4 /* HubFlowsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8B7A6D5E4F3A2B1C0D9E8F /* HubFlowsUITests.swift */; }; 06FE9996B295456D8B8C7E96 /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */; }; 095654DAAF6C4EA4B73BAB61 /* LiveScoreDialogHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F8BF2514BB4F5185827B95 /* LiveScoreDialogHostTests.swift */; }; 0CB8933AC2B34D4099BBA881 /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */; }; @@ -107,9 +108,18 @@ remoteGlobalIDString = F54F6C97361C4E8A96AEDD11; remoteInfo = MatchLiveTv; }; + 4B3A29180796A5B4C3D2E1F0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AE0076753A1840A19E9F565F /* Project object */; + proxyType = 1; + remoteGlobalIDString = F54F6C97361C4E8A96AEDD11; + remoteInfo = MatchLiveTv; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 9C8B7A6D5E4F3A2B1C0D9E8F /* HubFlowsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchLiveTvUITests/HubFlowsUITests.swift; sourceTree = ""; }; + 7E6D5C4B3A29180796A5B4C3 /* MatchLiveTvUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 004F86D8E8BE47E589B923FA /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = ""; }; 1079231B9A4E446AA9AD6C0A /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = ""; }; 10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreActionDecodeTests.swift; path = MatchLiveTvTests/ScoreActionDecodeTests.swift; sourceTree = ""; }; @@ -228,6 +238,7 @@ children = ( CE0F9D7D5EE749E8AAE6AB43 /* MatchLiveTv.app */, 3FC0CC8155BE469ABB420DC5 /* MatchLiveTvTests.xctest */, + 7E6D5C4B3A29180796A5B4C3 /* MatchLiveTvUITests.xctest */, ); name = Products; sourceTree = ""; @@ -248,11 +259,20 @@ name = MatchLiveTvTests; sourceTree = ""; }; + 2A180796A5B4C3D2E1F0A98B /* MatchLiveTvUITests */ = { + isa = PBXGroup; + children = ( + 9C8B7A6D5E4F3A2B1C0D9E8F /* HubFlowsUITests.swift */, + ); + name = MatchLiveTvUITests; + sourceTree = ""; + }; 7E992B84100649D8BE8507F7 = { isa = PBXGroup; children = ( DDEF729FE3334F8998C4A31C /* MatchLiveTv */, 6987D3982FAD44A6869B9FD6 /* MatchLiveTvTests */, + 2A180796A5B4C3D2E1F0A98B /* MatchLiveTvUITests */, 0ADC6126267C410F9B60B426 /* Products */, ); sourceTree = ""; @@ -364,6 +384,22 @@ productReference = 3FC0CC8155BE469ABB420DC5 /* MatchLiveTvTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 6D5C4B3A29180796A5B4C3D2 /* MatchLiveTvUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F01796A5B4C3D2E1F0A98D7E /* Build configuration list for PBXNativeTarget "MatchLiveTvUITests" */; + buildPhases = ( + 5C4B3A29180796A5B4C3D2E1 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 3A29180796A5B4C3D2E1F0A9 /* PBXTargetDependency */, + ); + name = MatchLiveTvUITests; + productName = MatchLiveTvUITests; + productReference = 7E6D5C4B3A29180796A5B4C3 /* MatchLiveTvUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */ = { isa = PBXNativeTarget; buildConfigurationList = 117DCD84E04B4B03A2BFFFEA /* Build configuration list for PBXNativeTarget "MatchLiveTv" */; @@ -414,6 +450,7 @@ targets = ( F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */, 20C695DBFB4042879308B0F5 /* MatchLiveTvTests */, + 6D5C4B3A29180796A5B4C3D2 /* MatchLiveTvUITests */, ); }; /* End PBXProject section */ @@ -531,6 +568,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5C4B3A29180796A5B4C3D2E1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8F7E6D5C4B3A29180796A5B4 /* HubFlowsUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -539,6 +584,11 @@ target = F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */; targetProxy = 58E3C5EC752E497FBFD32D78 /* PBXContainerItemProxy */; }; + 3A29180796A5B4C3D2E1F0A9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */; + targetProxy = 4B3A29180796A5B4C3D2E1F0 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -627,10 +677,11 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; - MARKETING_VERSION = 2.0.10; + MARKETING_VERSION = 2.0.12; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -647,7 +698,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = S8Q9TWBRG5; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; @@ -658,7 +709,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.10; + MARKETING_VERSION = 2.0.12; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -676,7 +727,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = S8Q9TWBRG5; ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = NO; @@ -688,7 +739,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.10; + MARKETING_VERSION = 2.0.12; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -705,10 +756,11 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; - MARKETING_VERSION = 2.0.10; + MARKETING_VERSION = 2.0.12; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -718,6 +770,44 @@ }; name = Debug; }; + 19180796A5B4C3D2E1F0A98C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 33; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + MARKETING_VERSION = 2.0.12; + PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = MatchLiveTv; + }; + name = Debug; + }; + 081796A5B4C3D2E1F0A98D7E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 33; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + MARKETING_VERSION = 2.0.12; + PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = MatchLiveTv; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -748,6 +838,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F01796A5B4C3D2E1F0A98D7E /* Build configuration list for PBXNativeTarget "MatchLiveTvUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 19180796A5B4C3D2E1F0A98C /* Debug */, + 081796A5B4C3D2E1F0A98D7E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme b/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme index d611ee6..5ccb558 100644 --- a/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme +++ b/native/ios/MatchLiveTv.xcodeproj/xcshareddata/xcschemes/MatchLiveTv.xcscheme @@ -52,6 +52,16 @@ ReferencedContainer = "container:MatchLiveTv.xcodeproj"> + + + + CFBundlePackageType APPL CFBundleShortVersionString - 2.0.10 + 2.0.12 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 32 + 33 LSRequiresIPhoneOS NSCameraUsageDescription @@ -41,10 +41,6 @@ Match Live TV usa il microfono per l'audio della diretta. NSPhotoLibraryUsageDescription Match Live TV usa le foto per i loghi delle squadre. - UIBackgroundModes - - audio - UILaunchScreen UIColorName diff --git a/native/ios/MatchLiveTv/UI/Account/AccountScreen.swift b/native/ios/MatchLiveTv/UI/Account/AccountScreen.swift index ac5e8cc..af1d4fa 100644 --- a/native/ios/MatchLiveTv/UI/Account/AccountScreen.swift +++ b/native/ios/MatchLiveTv/UI/Account/AccountScreen.swift @@ -30,9 +30,11 @@ struct AccountScreen: View { Image(systemName: "chevron.left") .foregroundStyle(MatchColors.textSecondary) } + .accessibilityIdentifier("account.back") Text(L10n.t("account.title")) .font(MatchTypography.headlineMedium) .foregroundStyle(.white) + .accessibilityIdentifier("account.title") Spacer() } .padding(.horizontal, 16) @@ -66,6 +68,7 @@ struct AccountScreen: View { enabled: !loadingProfile && !name.trimmingCharacters(in: .whitespaces).isEmpty && !savingProfile, loading: savingProfile ) + .accessibilityIdentifier("account.save.profile") Text(L10n.t("account.password.heading")) .font(MatchTypography.titleMedium) @@ -101,12 +104,14 @@ struct AccountScreen: View { enabled: !currentPassword.isEmpty && !newPassword.isEmpty && !confirmPassword.isEmpty && !savingPassword, loading: savingPassword ) + .accessibilityIdentifier("account.save.password") MatchSecondaryButton( label: L10n.t("action.logout"), action: logout, enabled: !loggingOut ) + .accessibilityIdentifier("account.logout") .padding(.top, 24) } .padding(24) diff --git a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift index 43e7bdc..04a0aa2 100644 --- a/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift +++ b/native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift @@ -194,16 +194,19 @@ struct BroadcastControlsOverlay: View { action: { pendingConfirm = .shareLive }, enabled: shareLiveEnabled ) + .accessibilityIdentifier("broadcast.share") SideIconButton( systemName: "video.fill", accessibilityLabel: L10n.t("broadcast.share.regia.cd"), action: { pendingConfirm = .shareRegia } ) + .accessibilityIdentifier("broadcast.regia") SideIconButton( systemName: "slider.horizontal.3", accessibilityLabel: L10n.t("broadcast.min.quality.cd"), action: { showMinQualityPicker = true } ) + .accessibilityIdentifier("broadcast.abr") if let onCloseSet { SideIconButton( systemName: "checkmark", @@ -231,18 +234,21 @@ struct BroadcastControlsOverlay: View { action: { pendingConfirm = .pauseOrResume }, highlighted: isPaused ) + .accessibilityIdentifier("broadcast.pause") SideIconButton( systemName: audioMuted ? "speaker.slash.fill" : "speaker.wave.2.fill", accessibilityLabel: audioMuted ? L10n.t("broadcast.unmute.cd") : L10n.t("broadcast.mute.cd"), action: { pendingConfirm = .toggleMute }, highlighted: audioMuted ) + .accessibilityIdentifier("broadcast.mute") SideIconButton( systemName: "stop.fill", accessibilityLabel: L10n.t("broadcast.terminate.cd"), action: { pendingConfirm = .terminate }, danger: true ) + .accessibilityIdentifier("broadcast.terminate") } } @@ -510,10 +516,12 @@ private struct TeamScoreColumn: View { } } ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons) + .accessibilityIdentifier("broadcast.score.away.plus") ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus) } else { ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus) ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons) + .accessibilityIdentifier(alignEnd ? "broadcast.score.away.plus" : "broadcast.score.home.plus") if showBasketButtons { if let onPlus2 { ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true) diff --git a/native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift b/native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift index 56f558d..dc25876 100644 --- a/native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift +++ b/native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift @@ -18,6 +18,7 @@ struct ForgotPasswordScreen: View { Image(systemName: "chevron.left") .foregroundStyle(MatchColors.textSecondary) } + .accessibilityIdentifier("forgot.back") Text(L10n.t("forgot.password.title")) .font(MatchTypography.headlineMedium) .foregroundStyle(.white) @@ -64,6 +65,7 @@ struct ForgotPasswordScreen: View { enabled: !email.trimmingCharacters(in: .whitespaces).isEmpty && !loading && !success, loading: loading ) + .accessibilityIdentifier("forgot.submit") } .padding(24) } diff --git a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift index 87c34b0..5ca4e9b 100644 --- a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift +++ b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift @@ -27,6 +27,7 @@ struct LoginScreen: View { .foregroundStyle(MatchColors.textSecondary) } .accessibilityLabel(L10n.t("language.label")) + .accessibilityIdentifier("login.language") } .padding(.top, 8) MatchLiveWordmark(showSlogan: true) @@ -57,6 +58,7 @@ struct LoginScreen: View { .foregroundStyle(MatchColors.textSecondary) .underline() } + .accessibilityIdentifier("login.forgot") } .padding(.top, 12) if let error { @@ -71,6 +73,7 @@ struct LoginScreen: View { enabled: !email.isEmpty && !password.isEmpty, loading: loading ) + .accessibilityIdentifier("login.submit") .padding(.top, 32) } .padding(.horizontal, 24) diff --git a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift index 7f811bb..ce78822 100644 --- a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift +++ b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift @@ -18,6 +18,8 @@ struct MatchesScreen: View { @State private var showSchedule = false @State private var showTeamPicker = false @State private var resumeMatch: Match? + @State private var configureMatch: Match? + @State private var pendingConfigureMatch: Match? @State private var deleteMatch: Match? @State private var snackbar: String? @State private var showLanguagePicker = false @@ -35,15 +37,19 @@ struct MatchesScreen: View { } label: { Image(systemName: "globe") .foregroundStyle(MatchColors.textSecondary) + .frame(minWidth: 44, minHeight: 44) } .accessibilityLabel(L10n.t("language.label")) + .accessibilityIdentifier("matches.language") Button { onOpenAccount() } label: { Image(systemName: "person.crop.circle") .foregroundStyle(MatchColors.textSecondary) + .frame(minWidth: 44, minHeight: 44) } .accessibilityLabel(L10n.t("account.title")) + .accessibilityIdentifier("matches.account") } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -91,6 +97,7 @@ struct MatchesScreen: View { .font(MatchTypography.bodyMedium) .foregroundStyle(MatchColors.textSecondary) MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true }) + .accessibilityIdentifier("matches.new") if let activeTeam { TeamPickerBar( team: activeTeam, @@ -151,14 +158,16 @@ struct MatchesScreen: View { } ) .task(id: refreshToken) { reload(showSpinner: refreshToken == 0) } - .alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) { - Button(L10n.t("sheet.resume.camera")) { - if let match = resumeMatch { resumeBroadcast(match) } - resumeMatch = nil + .alert( + L10n.t("sheet.configure.now.title"), + isPresented: Binding(get: { configureMatch != nil }, set: { if !$0 { configureMatch = nil } }) + ) { + Button(L10n.t("sheet.configure")) { + if let match = configureMatch { onOpenSetup(match.id) } + configureMatch = nil } - Button(L10n.t("sheet.configure"), role: .cancel) { - if let match = resumeMatch { onOpenSetup(match.id) } - resumeMatch = nil + Button(L10n.t("sheet.later"), role: .cancel) { + configureMatch = nil } } message: { Text(L10n.t("sheet.configure.now.body")) @@ -201,10 +210,19 @@ struct MatchesScreen: View { .presentationDetents([.medium]) .presentationDragIndicator(.visible) } - .sheet(isPresented: $showSchedule) { + .sheet(isPresented: $showSchedule, onDismiss: { + if let match = pendingConfigureMatch { + pendingConfigureMatch = nil + configureMatch = match + } + }) { ScheduleMatchSheet(container: container, teamId: activeTeam?.id) { match in + pendingConfigureMatch = match + snackbar = L10n.t("matches.msg.scheduled.ok") + reload(showSpinner: false) showSchedule = false - onOpenSetup(match.id) + } onFailed: { error in + snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.schedule.error") } } .sheet(isPresented: $showTeamPicker) { @@ -229,6 +247,29 @@ struct MatchesScreen: View { ProgressView().tint(MatchColors.primaryRed) } } + .overlay { + if let match = resumeMatch { + ZStack(alignment: .bottom) { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { resumeMatch = nil } + ResumeSessionSheet( + match: match, + onResumeCamera: { + resumeMatch = nil + resumeBroadcast(match) + }, + onContinueSetup: { + resumeMatch = nil + onOpenSetup(match.id) + }, + onDismiss: { resumeMatch = nil } + ) + } + .ignoresSafeArea(edges: .bottom) + .accessibilityIdentifier("resume.sheet") + } + } .overlay(alignment: .bottom) { if let snackbar { Text(snackbar) @@ -379,6 +420,7 @@ private struct ActiveSessionBanner: View { .background(MatchColors.primaryRed.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) + .accessibilityIdentifier("matches.resume.banner") } } @@ -523,12 +565,14 @@ private struct NewMatchSheet: View { subtitle: L10n.t("sheet.schedule.option.sub"), action: onSchedule ) + .accessibilityIdentifier("sheet.schedule.option") SheetOptionTile( systemImage: "play.circle", title: L10n.t("sheet.quick.option"), subtitle: L10n.t("sheet.quick.option.sub"), action: onQuickStart ) + .accessibilityIdentifier("sheet.quick.option") } .padding(.top, 20) Spacer(minLength: 24) @@ -570,14 +614,68 @@ private struct SheetOptionTile: View { } } +private struct ResumeSessionSheet: View { + let match: Match + let onResumeCamera: () -> Void + let onContinueSetup: () -> Void + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(L10n.t("sheet.live.in.progress")) + .font(MatchTypography.headlineMedium) + .accessibilityIdentifier("resume.sheet.title") + Text("\(match.teamName) vs \(match.opponentName)") + .font(MatchTypography.bodyMedium) + .padding(.top, 4) + if let status = match.activeSessionStatus, !status.isEmpty { + Text(L10n.t("sheet.status", status)) + .font(MatchTypography.labelLarge) + .foregroundStyle(MatchColors.primaryRed) + .padding(.top, 8) + } + VStack(spacing: 8) { + if match.canResumeCamera { + MatchPrimaryButton(label: L10n.t("sheet.resume.camera"), action: onResumeCamera) + .accessibilityIdentifier("resume.camera") + } + if match.activeSessionStatus == "idle" { + MatchSecondaryButton(label: L10n.t("sheet.continue.setup"), action: onContinueSetup) + .accessibilityIdentifier("resume.continue.setup") + } + Button(action: onDismiss) { + Text(L10n.t("action.cancel")) + .font(MatchTypography.labelLarge) + .foregroundStyle(MatchColors.textSecondary) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + .accessibilityIdentifier("resume.cancel") + } + .padding(.top, 20) + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 24) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(MatchColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("resume.sheet.panel") + } +} + private struct ScheduleMatchSheet: View { @ObservedObject var container: AppContainer let teamId: String? let onCreated: (Match) -> Void + var onFailed: (Error) -> Void = { _ in } @Environment(\.dismiss) private var dismiss @State private var opponent = "" @State private var location = "" @State private var date = Date().addingTimeInterval(86400) + @State private var saving = false var body: some View { NavigationStack { @@ -593,14 +691,15 @@ private struct ScheduleMatchSheet: View { } ToolbarItem(placement: .confirmationAction) { Button(L10n.t("sheet.save.schedule")) { create() } - .disabled(opponent.isEmpty || teamId == nil) + .disabled(opponent.isEmpty || teamId == nil || saving) } } } } private func create() { - guard let teamId else { return } + guard let teamId, !saving else { return } + saving = true Task { do { let match = try await container.matchRepository.createScheduledMatch( @@ -611,7 +710,8 @@ private struct ScheduleMatchSheet: View { ) onCreated(match) } catch { - // Sheet chiude solo su successo; errori gestiti dal chiamante via snackbar se necessario. + onFailed(error) + saving = false } } } diff --git a/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift b/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift index 7e4078f..2b940bb 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift @@ -68,6 +68,7 @@ struct MatchCoverSection: View { .overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1)) } .buttonStyle(.plain) + .accessibilityIdentifier("wizard.cover.change") .padding(.top, 12) if hasMatchOverride || localCoverImage != nil { diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift index 714622b..f4e1b14 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/StepMatchScreen.swift @@ -224,6 +224,7 @@ struct StepMatchScreen: View { } MatchPrimaryButton(label: L10n.t("wizard.action.next"), action: saveAndContinue, enabled: !saving, loading: saving) + .accessibilityIdentifier("wizard.next") .padding(.top, 32) } .padding(.horizontal, 20) diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift index ff6126a..9688f0e 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift @@ -125,6 +125,7 @@ struct StepNetworkTestScreen: View { action: runTest, enabled: !testing ) + .accessibilityIdentifier("wizard.network.test") .padding(.top, 24) } @@ -135,12 +136,14 @@ struct StepNetworkTestScreen: View { HStack(spacing: spacing) { MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack, enabled: !starting) .frame(width: backWidth) + .accessibilityIdentifier("wizard.back") MatchPrimaryButton( label: L10n.t("wizard.action.start"), action: startLive, enabled: ready, loading: starting ) + .accessibilityIdentifier("wizard.start") .frame(width: forwardWidth) } } diff --git a/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift b/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift index 897dfc1..dcc22a6 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/TeamBrandingEditor.swift @@ -44,6 +44,7 @@ struct TeamBrandingRow: View { .font(MatchTypography.titleMedium) .padding(8) .overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1)) + .accessibilityIdentifier("wizard.opponent") } else { Text(teamName.isEmpty ? L10n.t("wizard.branding.team.fallback.name") : teamName) .font(MatchTypography.titleMedium) diff --git a/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift b/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift index 4d1aa4e..88b51ae 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift @@ -37,9 +37,11 @@ struct WizardFooterButtons: View { if showBack { MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack) .frame(maxWidth: .infinity) + .accessibilityIdentifier("wizard.back") } MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading) .frame(maxWidth: .infinity) + .accessibilityIdentifier("wizard.next") } } } diff --git a/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift index 6cd43dc..5da7205 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/WizardShellScreen.swift @@ -30,6 +30,7 @@ struct WizardShellScreen: View { Image(systemName: "xmark").foregroundStyle(.white) } .accessibilityLabel(L10n.t("common.close")) + .accessibilityIdentifier("wizard.close") Text(wizardStepTitle(currentStep)) .font(MatchTypography.titleMedium) Spacer(minLength: 0) diff --git a/native/ios/MatchLiveTvUITests/HubFlowsUITests.swift b/native/ios/MatchLiveTvUITests/HubFlowsUITests.swift new file mode 100644 index 0000000..52e5372 --- /dev/null +++ b/native/ios/MatchLiveTvUITests/HubFlowsUITests.swift @@ -0,0 +1,560 @@ +import XCTest + +/// Pilota il simulatore come un utente (backend da API_BASE_URL). +final class HubFlowsUITests: XCTestCase { + private var app: XCUIApplication! + private let shotDir = "/tmp/mltv-uitest" + private var apiBaseUrl = "http://localhost:3000" + + override func setUpWithError() throws { + continueAfterFailure = false + addUIInterruptionMonitor(withDescription: "Dialog di sistema") { alert in + for label in [ + "Non ora", "Not Now", "Not now", + "Consenti", "OK", "Allow", "Allow Once", "Consenti una volta", + "Allow While Using App", "Consenti durante l'utilizzo", + "Allow While Using the App" + ] { + if alert.buttons[label].exists { + alert.buttons[label].tap() + return true + } + } + return false + } + try FileManager.default.createDirectory(atPath: shotDir, withIntermediateDirectories: true) + app = XCUIApplication() + // L'URL API arriva dall'Info.plist dell'app ($(API_BASE_URL) di xcodebuild). + // Non iniettare un default localhost: maschererebbe il collaudo. + if let fromEnv = ProcessInfo.processInfo.environment["API_BASE_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !fromEnv.isEmpty { + apiBaseUrl = fromEnv + app.launchEnvironment["API_BASE_URL"] = fromEnv + } + app.launchArguments = ["--uitesting-reset", "-AppleLanguages", "(it)", "-AppleLocale", "it_IT"] + app.launch() + } + + func testLanguagesOnLogin() throws { + waitLogin() + dismissKeyboard() + capture("lang-00-it-login") + XCTAssertTrue( + app.staticTexts["ACCEDI"].exists || app.buttons["ACCEDI"].exists, + "Login italiano atteso (ACCEDI)" + ) + XCTAssertTrue(any("login.forgot").exists || app.buttons["Password dimenticata?"].exists) + + assertLanguageSwitch( + code: "en", + loginTitle: "LOG IN", + forgot: "Forgot password?", + shot: "lang-01-en-login" + ) + assertLanguageSwitch( + code: "fr", + loginTitle: "CONNEXION", + forgot: "Mot de passe oublié ?", + shot: "lang-02-fr-login" + ) + assertLanguageSwitch( + code: "de", + loginTitle: "ANMELDEN", + forgot: "Passwort vergessen?", + shot: "lang-03-de-login" + ) + assertLanguageSwitch( + code: "es", + loginTitle: "ACCEDER", + forgot: "¿Olvidaste la contraseña?", + shot: "lang-04-es-login" + ) + assertLanguageSwitch( + code: "it", + loginTitle: "ACCEDI", + forgot: "Password dimenticata?", + shot: "lang-05-it-login" + ) + } + + func testCoachFlows() throws { + waitLogin() + dismissKeyboard() + capture("01-login") + + let forgot = firstExisting(["login.forgot"], labels: ["Password dimenticata?", "Forgot password?"]) + XCTAssertTrue(forgot.waitForExistence(timeout: 8), "Link password dimenticata assente") + tapHittable(forgot) + XCTAssertTrue(any("forgot.back").waitForExistence(timeout: 8), "Schermata password dimenticata assente") + capture("02-forgot-password") + + let mail = app.textFields.firstMatch + XCTAssertTrue(mail.waitForExistence(timeout: 4)) + tapHittable(mail) + mail.typeText("coach@matchlivetv.test") + dismissKeyboard() + tapHittable(firstExisting(["forgot.submit"], labels: ["Invia link di reset", "Send reset link"])) + XCTAssertTrue( + app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'link'")).firstMatch.waitForExistence(timeout: 12) + || app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'registrat'")).firstMatch.exists, + "Manca il messaggio di esito del reset password.\n\(app.debugDescription)" + ) + capture("02b-forgot-submitted") + tapHittable(app.buttons["forgot.back"]) + + loginIfNeeded() + waitHub() + dismissSystemDialogs() + capture("03-hub-dopo-login") + + openLanguagePicker(from: "matches.language") + tapHittable(any("language.en")) + XCTAssertTrue( + app.buttons["NEW MATCH"].waitForExistence(timeout: 8) || any("matches.new").waitForExistence(timeout: 4), + "Hub non passato in inglese" + ) + capture("03b-hub-en") + openLanguagePicker(from: "matches.language") + tapHittable(any("language.it")) + XCTAssertTrue( + app.buttons["NUOVA PARTITA"].waitForExistence(timeout: 8) || any("matches.new").waitForExistence(timeout: 4), + "Hub non tornato in italiano" + ) + + openAccountAndBack() + capture("04-hub-dopo-account") + + scheduleMatchShowsConfigureDialog() + capture("05-dopo-programmazione") + } + + func testPasswordRejectAndLogout() throws { + loginIfNeeded() + waitHub() + dismissSystemDialogs() + tapHittable(firstExisting(["matches.account"], labels: ["Account"])) + XCTAssertTrue(any("account.back").waitForExistence(timeout: 8), "Account non aperto") + app.swipeUp() + app.swipeUp() + let secures = app.secureTextFields + XCTAssertTrue(secures.element(boundBy: 0).waitForExistence(timeout: 6), "Campi password assenti") + tapHittable(secures.element(boundBy: 0)) + secures.element(boundBy: 0).typeText("sbagliata") + tapHittable(secures.element(boundBy: 1)) + secures.element(boundBy: 1).typeText("NuovaPass123!") + tapHittable(secures.element(boundBy: 2)) + secures.element(boundBy: 2).typeText("NuovaPass123!") + dismissKeyboard() + tapHittable(firstExisting(["account.save.password"], labels: ["Aggiorna password"])) + sleep(2) + capture("13-password-reject") + XCTAssertTrue( + app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] 'password' OR label CONTAINS[c] 'non valid' OR label CONTAINS[c] 'errat' OR label CONTAINS[c] 'incorrect'")).firstMatch.waitForExistence(timeout: 8) + || any("account.save.password").exists, + "Atteso un errore sul cambio password con password attuale sbagliata" + ) + tapHittable(any("account.logout")) + XCTAssertTrue( + any("login.submit").waitForExistence(timeout: 12) || app.staticTexts["ACCEDI"].waitForExistence(timeout: 4), + "Logout non ha riportato al login" + ) + capture("14-dopo-logout") + } + + func testWizardNetworkAndLive() throws { + loginIfNeeded() + waitHub() + dismissSystemDialogs() + tapNewMatch() + let quick = firstExisting(["sheet.quick.option"], labels: ["Avvia subito"]) + XCTAssertTrue(quick.waitForExistence(timeout: 8)) + tapHittable(quick) + XCTAssertTrue( + app.staticTexts["01 · Partita"].waitForExistence(timeout: 25), + "Wizard step 1 assente" + ) + capture("06-wizard-step1") + + let opponent = any("wizard.opponent").exists ? any("wizard.opponent") : app.textFields.firstMatch + if opponent.waitForExistence(timeout: 8) { + tapHittable(opponent) + if let current = opponent.value as? String, !current.isEmpty { + let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: current.count) + opponent.typeText(deletes) + } + opponent.typeText("UI Wizard") + dismissKeyboard() + } + if any("wizard.cover.change").waitForExistence(timeout: 3) + || app.buttons["Cambia copertina per questa partita"].waitForExistence(timeout: 2) { + let cover = firstExisting(["wizard.cover.change"], labels: ["Cambia copertina per questa partita"]) + tapHittable(cover) + sleep(1) + capture("06b-cover-picker") + let spring = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for label in ["Annulla", "Cancel", "Chiudi", "Close"] { + if spring.buttons[label].exists { + spring.buttons[label].tap() + break + } + if app.buttons[label].exists { + tapHittable(app.buttons[label]) + break + } + } + dismissSystemDialogs() + } + scrollToId("wizard.next") + tapHittable(any("wizard.next")) + XCTAssertTrue( + app.staticTexts["02 · Trasmissione"].waitForExistence(timeout: 20) + || app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Trasmissione' OR label CONTAINS 'Transmission'")).firstMatch.waitForExistence(timeout: 8), + "Wizard step 2 (trasmissione) non aperto.\n\(app.debugDescription)" + ) + capture("07-wizard-step2") + if app.buttons["NON IN ELENCO"].waitForExistence(timeout: 3) { + tapHittable(app.buttons["NON IN ELENCO"]) + } + if app.staticTexts["YouTube Live"].waitForExistence(timeout: 3) { + tapHittable(app.staticTexts["YouTube Live"]) + sleep(1) + capture("07b-youtube-tap") + if app.alerts.firstMatch.exists { + app.alerts.buttons.firstMatch.tap() + } + } + scrollToId("wizard.next") + tapHittable(any("wizard.next")) + XCTAssertTrue( + app.staticTexts["03 · Test rete"].waitForExistence(timeout: 20) + || any("wizard.network.test").waitForExistence(timeout: 10), + "Wizard step 3 (rete) non aperto.\n\(app.debugDescription)" + ) + capture("08-wizard-step3") + + scrollToId("wizard.network.test") + tapHittable(any("wizard.network.test")) + XCTAssertTrue( + app.staticTexts["PRONTO PER ANDARE IN DIRETTA"].waitForExistence(timeout: 20) + || app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'PRONTO' OR label CONTAINS 'READY'")).firstMatch.waitForExistence(timeout: 10), + "Test rete non completato.\n\(app.debugDescription)" + ) + capture("08b-network-ready") + + let start = any("wizard.start") + XCTAssertTrue(start.waitForExistence(timeout: 6)) + tapHittable(start) + dismissSystemDialogs() + let liveVisible = + any("broadcast.terminate").waitForExistence(timeout: 25) + || any("broadcast.pause").waitForExistence(timeout: 4) + || app.buttons["CONCEDI PERMESSI"].waitForExistence(timeout: 4) + || app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'camera' OR label CONTAINS 'microfono' OR label CONTAINS 'Permessi'")).firstMatch.waitForExistence(timeout: 4) + capture("09-broadcast") + XCTAssertTrue(liveVisible, "Dopo INIZIA non si apre la diretta né la richiesta permessi.\n\(app.debugDescription)") + + if any("broadcast.mute").waitForExistence(timeout: 3) { + tapHittable(any("broadcast.mute")) + sleep(1) + capture("09b-mute-confirm") + if app.buttons["Conferma"].waitForExistence(timeout: 2) || app.alerts.buttons.element(boundBy: 1).exists { + let confirm = app.buttons["Conferma"].exists ? app.buttons["Conferma"] : app.alerts.buttons.element(boundBy: 1) + tapHittable(confirm) + } + capture("09c-muted") + } + + if any("broadcast.score.home.plus").waitForExistence(timeout: 3) { + tapHittable(any("broadcast.score.home.plus")) + sleep(1) + capture("09c2-score-plus") + } + if any("broadcast.abr").waitForExistence(timeout: 2) { + tapHittable(any("broadcast.abr")) + sleep(1) + capture("09c3-abr") + if app.buttons["Annulla"].waitForExistence(timeout: 2) { + tapHittable(app.buttons["Annulla"]) + } + } + if any("broadcast.share").waitForExistence(timeout: 2) { + tapHittable(any("broadcast.share")) + sleep(1) + if app.buttons["Conferma"].waitForExistence(timeout: 3) { + tapHittable(app.buttons["Conferma"]) + } + sleep(1) + capture("09c4-share") + let spring = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for label in ["Chiudi", "Close", "Annulla", "Cancel"] { + if spring.buttons[label].exists { + spring.buttons[label].tap() + break + } + if app.buttons[label].exists { + tapHittable(app.buttons[label]) + break + } + } + } + + if any("broadcast.terminate").waitForExistence(timeout: 3) { + tapHittable(any("broadcast.terminate")) + sleep(1) + capture("09d-terminate-confirm") + for label in ["TERMINA", "END", "BEENDEN", "TERMINAR", "TERMINER"] { + if app.buttons[label].exists { + tapHittable(app.buttons[label]) + break + } + } + } else if any("wizard.close").waitForExistence(timeout: 2) { + tapHittable(any("wizard.close")) + } + _ = any("matches.new").waitForExistence(timeout: 12) + capture("09e-back-hub") + } + + func testResumeLiveSheet() throws { + loginIfNeeded() + waitHub() + dismissSystemDialogs() + capture("10-hub-con-banner") + + let banner = app.buttons["matches.resume.banner"] + if !banner.waitForExistence(timeout: 10) { + throw XCTSkip("Nessuna sessione attiva da riprendere su \(apiBaseUrl)") + } + tapHittable(banner) + sleep(1) + capture("11-dopo-tap-banner") + + let sheetVisible = + any("resume.sheet").waitForExistence(timeout: 4) + || any("resume.sheet.panel").waitForExistence(timeout: 2) + || any("resume.camera").waitForExistence(timeout: 2) + || app.staticTexts["Diretta in corso"].waitForExistence(timeout: 2) + XCTAssertTrue(sheetVisible, "Lo sheet di ripresa non è comparso.\n\(app.debugDescription)") + capture("12-resume-sheet") + + let cancel = firstExisting(["resume.cancel"], labels: ["Annulla", "Cancel"]) + if cancel.waitForExistence(timeout: 3) { + tapHittable(cancel) + } + } + + private func assertLanguageSwitch(code: String, loginTitle: String, forgot: String, shot: String) { + openLanguagePicker(from: "login.language") + let row = any("language.\(code)") + XCTAssertTrue(row.waitForExistence(timeout: 6), "Voce lingua \(code) assente") + tapHittable(row) + XCTAssertTrue( + app.staticTexts[loginTitle].waitForExistence(timeout: 8) || app.buttons[loginTitle].waitForExistence(timeout: 2), + "Dopo lingua \(code) manca il titolo \(loginTitle).\n\(app.debugDescription)" + ) + XCTAssertTrue( + any("login.forgot").waitForExistence(timeout: 4) + || app.buttons[forgot].waitForExistence(timeout: 2) + || app.staticTexts[forgot].waitForExistence(timeout: 1), + "Dopo lingua \(code) manca '\(forgot)'" + ) + capture(shot) + } + + private func openLanguagePicker(from id: String) { + let globe = any(id) + XCTAssertTrue(globe.waitForExistence(timeout: 8), "Bottone lingua \(id) assente") + tapHittable(globe) + XCTAssertTrue( + any("language.it").waitForExistence(timeout: 8) || app.staticTexts["Italiano"].waitForExistence(timeout: 4), + "Picker lingua non aperto" + ) + } + + private func loginIfNeeded() { + if hubVisible() { return } + waitLogin() + dismissKeyboard() + let email = app.textFields.firstMatch + XCTAssertTrue(email.waitForExistence(timeout: 8)) + tapHittable(email) + email.typeText("coach@matchlivetv.test") + let password = app.secureTextFields.firstMatch + XCTAssertTrue(password.waitForExistence(timeout: 4)) + tapHittable(password) + password.typeText("Password123") + dismissKeyboard() + let submit = firstExisting(["login.submit"], labels: ["ACCEDI", "LOG IN", "CONNEXION", "ANMELDEN", "ACCEDER"]) + XCTAssertTrue(submit.waitForExistence(timeout: 4)) + tapHittable(submit) + dismissSystemDialogs() + } + + private func waitLogin() { + let deadline = Date().addingTimeInterval(20) + while Date() < deadline { + if any("login.submit").exists || app.textFields.firstMatch.exists { + return + } + if hubVisible() { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + capture("fail-login") + XCTFail("Login non comparso. Gerarchia:\n\(app.debugDescription)") + } + + private func waitHub() { + XCTAssertTrue( + any("matches.new").waitForExistence(timeout: 25) + || app.buttons["NUOVA PARTITA"].waitForExistence(timeout: 5) + || app.buttons["NEW MATCH"].waitForExistence(timeout: 2), + "Hub partite non raggiunto dopo login su \(apiBaseUrl).\n\(app.debugDescription)" + ) + dismissKeyboard() + } + + private func hubVisible() -> Bool { + app.buttons["matches.new"].exists || app.buttons["NUOVA PARTITA"].exists || app.buttons["NEW MATCH"].exists + } + + private func openAccountAndBack() { + let account = firstExisting(["matches.account"], labels: ["Account"]) + XCTAssertTrue(account.waitForExistence(timeout: 6), "Bottone Account assente") + tapHittable(account) + XCTAssertTrue( + any("account.title").waitForExistence(timeout: 8) || any("account.back").waitForExistence(timeout: 2), + "Schermata Account non aperta.\n\(app.debugDescription)" + ) + capture("04a-account") + XCTAssertTrue(any("account.save.profile").waitForExistence(timeout: 4), "Manca Salva profilo") + tapHittable(any("account.save.profile")) + sleep(2) + capture("04b-account-saved") + tapHittable(app.buttons["account.back"]) + XCTAssertTrue(any("matches.new").waitForExistence(timeout: 8)) + } + + private func scheduleMatchShowsConfigureDialog() { + tapNewMatch() + capture("05a-nuova-partita") + let schedule = firstExisting(["sheet.schedule.option"], labels: ["Programma partita"]) + XCTAssertTrue(schedule.waitForExistence(timeout: 8), "Opzione Programma partita assente") + tapHittable(schedule) + let opponent = app.textFields["Avversario"].exists ? app.textFields["Avversario"] : app.textFields.firstMatch + XCTAssertTrue(opponent.waitForExistence(timeout: 8), "Campo avversario assente") + tapHittable(opponent) + opponent.typeText("UI Test Programmata") + dismissKeyboard() + let save = app.buttons["SALVA IN PROGRAMMA"] + XCTAssertTrue(save.waitForExistence(timeout: 4)) + tapHittable(save) + XCTAssertTrue( + app.staticTexts["Configurare ora?"].waitForExistence(timeout: 12) || app.alerts.firstMatch.waitForExistence(timeout: 4), + "Manca il dialog Configura/Più tardi" + ) + capture("05b-dialog-configura") + let later = app.buttons["Più tardi"].exists ? app.buttons["Più tardi"] : app.alerts.buttons.element(boundBy: 0) + tapHittable(later) + XCTAssertTrue(any("matches.new").waitForExistence(timeout: 8)) + XCTAssertTrue( + app.staticTexts["UI Test Programmata"].waitForExistence(timeout: 8) + || app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Programmata'")).firstMatch.exists, + "La partita programmata non compare in hub" + ) + } + + private func tapNewMatch() { + let neu = firstExisting(["matches.new"], labels: ["NUOVA PARTITA", "NEW MATCH"]) + XCTAssertTrue(neu.waitForExistence(timeout: 8)) + tapHittable(neu) + } + + private func scrollToId(_ id: String) { + let el = any(id) + for _ in 0..<8 { + if el.exists && el.isHittable { return } + app.swipeUp() + } + } + + private func any(_ id: String) -> XCUIElement { + app.descendants(matching: .any)[id] + } + + private func firstExisting(_ ids: [String], labels: [String] = []) -> XCUIElement { + for id in ids { + let el = any(id) + if el.exists { return el } + } + for label in labels { + let button = app.buttons[label] + if button.exists { return button } + let text = app.staticTexts[label] + if text.exists { return text } + } + if let firstId = ids.first { + return any(firstId) + } + return app.buttons[labels.first ?? ""] + } + + private func tapHittable(_ element: XCUIElement) { + if element.isHittable { + element.tap() + return + } + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + + private func dismissSystemDialogs() { + let spring = XCUIApplication(bundleIdentifier: "com.apple.springboard") + _ = spring.buttons["Non ora"].waitForExistence(timeout: 3) + || spring.buttons["Not Now"].waitForExistence(timeout: 1) + || app.alerts.buttons["Non ora"].waitForExistence(timeout: 1) + let labels = ["Non ora", "Not Now", "Not now", "OK", "Consenti", "Allow"] + for _ in 0..<8 { + var tapped = false + for label in labels { + if spring.buttons[label].exists { + spring.buttons[label].tap() + tapped = true + break + } + if app.alerts.buttons[label].exists { + app.alerts.buttons[label].tap() + tapped = true + break + } + } + if !tapped { break } + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + } + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap() + } + + private func dismissKeyboard() { + if !app.keyboards.firstMatch.exists { return } + for name in ["Fine", "Done", "Return", "Invio"] { + let key = app.keyboards.buttons[name] + if key.exists { + key.tap() + return + } + } + app.swipeDown() + sleep(1) + if app.keyboards.firstMatch.exists { + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12)).tap() + } + } + + private func capture(_ name: String) { + let shot = app.screenshot() + try? shot.pngRepresentation.write(to: URL(fileURLWithPath: "\(shotDir)/\(name).png")) + let attachment = XCTAttachment(screenshot: shot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + try? app.debugDescription.write(toFile: "\(shotDir)/\(name).txt", atomically: true, encoding: .utf8) + } +} diff --git a/native/ios/generate_xcodeproj.py b/native/ios/generate_xcodeproj.py index 5b7762b..15048d5 100755 --- a/native/ios/generate_xcodeproj.py +++ b/native/ios/generate_xcodeproj.py @@ -96,7 +96,7 @@ lines += [ app_settings = """ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -107,7 +107,7 @@ app_settings = """ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.10; + MARKETING_VERSION = 2.0.12; PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv; PRODUCT_NAME = "$(TARGET_NAME)"; API_BASE_URL = "https://www.matchlivetv.it"; @@ -122,10 +122,10 @@ app_debug_settings = app_settings + """ test_settings = """ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MARKETING_VERSION = 2.0.10; + MARKETING_VERSION = 2.0.12; PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos;