Compare commits

...
3 Commits
Author SHA1 Message Date
Emiliano FrascaroandCursor 1185d0ce61 Prepara iOS 2.0.12 build 35 per TestFlight e rafforza i test in produzione.
Forza dirette unlisted sul sito (non YouTube), copertina di test e verifica che in pausa gli spettatori vedano la slate. Alza il numero di build oltre le 33–34 già su App Store Connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
Emiliano FrascaroandCursor 90d481a2d1 Rende più robusti i UI test iOS (logout, copertina, YouTube non selezionato in prod).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
Emiliano FrascaroandCursor 50b8a8c8e2 Allinea iOS ad Android sul dialog Configura/Più tardi e porta l'app a 2.0.12.
Aggiunge UI test sul simulatore (hub, lingue, wizard, diretta) e identificatori di accessibilità per poterli ripetere su collaudo e produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
20 changed files with 1054 additions and 38 deletions
@@ -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 = "<group>"; };
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 = "<group>"; };
1079231B9A4E446AA9AD6C0A /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = "<group>"; };
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreActionDecodeTests.swift; path = MatchLiveTvTests/ScoreActionDecodeTests.swift; sourceTree = "<group>"; };
@@ -228,6 +238,7 @@
children = (
CE0F9D7D5EE749E8AAE6AB43 /* MatchLiveTv.app */,
3FC0CC8155BE469ABB420DC5 /* MatchLiveTvTests.xctest */,
7E6D5C4B3A29180796A5B4C3 /* MatchLiveTvUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -248,11 +259,20 @@
name = MatchLiveTvTests;
sourceTree = "<group>";
};
2A180796A5B4C3D2E1F0A98B /* MatchLiveTvUITests */ = {
isa = PBXGroup;
children = (
9C8B7A6D5E4F3A2B1C0D9E8F /* HubFlowsUITests.swift */,
);
name = MatchLiveTvUITests;
sourceTree = "<group>";
};
7E992B84100649D8BE8507F7 = {
isa = PBXGroup;
children = (
DDEF729FE3334F8998C4A31C /* MatchLiveTv */,
6987D3982FAD44A6869B9FD6 /* MatchLiveTvTests */,
2A180796A5B4C3D2E1F0A98B /* MatchLiveTvUITests */,
0ADC6126267C410F9B60B426 /* Products */,
);
sourceTree = "<group>";
@@ -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 = 35;
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 = 35;
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 = 35;
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 = 35;
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 = 35;
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 = 35;
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 */
@@ -52,6 +52,16 @@
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6D5C4B3A29180796A5B4C3D2"
BuildableName = "MatchLiveTvUITests.xctest"
BlueprintName = "MatchLiveTvUITests"
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
@@ -2,6 +2,10 @@ import Foundation
enum AppConfig {
static var apiBaseUrl: String {
if let env = ProcessInfo.processInfo.environment["API_BASE_URL"]?.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")),
!env.isEmpty {
return env
}
let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String
?? "https://www.matchlivetv.it"
return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
@@ -1763,6 +1763,7 @@ struct LanguagePickerView: View {
}
}
}
.accessibilityIdentifier("language.\(lang.rawValue)")
}
}
.navigationTitle(L10n.t("language.label"))
@@ -16,9 +16,22 @@ final class TokenStore: ObservableObject {
private let service = "com.matchlivetv.match-live-tv.auth"
init() {
if ProcessInfo.processInfo.arguments.contains("--uitesting-reset") {
deleteKeychain(key: Keys.accessToken)
deleteKeychain(key: Keys.refreshToken)
defaults.removeObject(forKey: Keys.userId)
defaults.removeObject(forKey: Keys.userEmail)
defaults.removeObject(forKey: Keys.userName)
defaults.removeObject(forKey: Keys.userRole)
defaults.removeObject(forKey: Keys.activeTeamId)
defaults.set("it", forKey: "mltv.appLanguage")
session = nil
activeTeamId = nil
} else {
session = loadSession()
activeTeamId = defaults.string(forKey: Keys.activeTeamId)
}
}
func saveSession(_ session: StoredSession) {
self.session = session
+2 -6
View File
@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.0.10</string>
<string>2.0.12</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -32,7 +32,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>32</string>
<string>35</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
@@ -41,10 +41,6 @@
<string>Match Live TV usa il microfono per l'audio della diretta.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Match Live TV usa le foto per i loghi delle squadre.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
@@ -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)
@@ -75,17 +78,20 @@ struct AccountScreen: View {
MatchAccountSecureField(
title: L10n.t("account.current.password"),
text: $currentPassword,
visible: $passwordVisible
visible: $passwordVisible,
accessibilityId: "account.password.current"
)
MatchAccountSecureField(
title: L10n.t("account.new.password"),
text: $newPassword,
visible: $passwordVisible
visible: $passwordVisible,
accessibilityId: "account.password.new"
)
MatchAccountSecureField(
title: L10n.t("account.confirm.password"),
text: $confirmPassword,
visible: $passwordVisible
visible: $passwordVisible,
accessibilityId: "account.password.confirm"
)
if let passwordError {
@@ -101,12 +107,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)
@@ -214,6 +222,8 @@ private struct MatchAccountSecureField: View {
@Binding var text: String
@Binding var visible: Bool
var accessibilityId: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
@@ -229,6 +239,7 @@ private struct MatchAccountSecureField: View {
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.accessibilityIdentifier(accessibilityId)
Button(action: { visible.toggle() }) {
Image(systemName: visible ? "eye.slash" : "eye")
.foregroundStyle(MatchColors.textSecondary)
@@ -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)
@@ -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)
}
@@ -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)
@@ -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
}
}
}
@@ -1,5 +1,6 @@
import PhotosUI
import SwiftUI
import UIKit
struct MatchCoverSection: View {
let effectiveCoverUrl: String?
@@ -29,6 +30,7 @@ struct MatchCoverSection: View {
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4)
.accessibilityIdentifier("wizard.cover.source")
Group {
if let localCoverImage {
@@ -68,8 +70,20 @@ struct MatchCoverSection: View {
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
}
.buttonStyle(.plain)
.accessibilityIdentifier("wizard.cover.change")
.padding(.top, 12)
if ProcessInfo.processInfo.arguments.contains("--uitesting-reset") {
Button("Usa copertina di test") {
localCoverImage = Self.makeUiTestCoverImage()
}
.font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity)
.frame(height: 44)
.accessibilityIdentifier("wizard.cover.uitest")
.padding(.top, 8)
}
if hasMatchOverride || localCoverImage != nil {
MatchSecondaryButton(
label: L10n.t("wizard.match.cover.reset"),
@@ -94,4 +108,14 @@ struct MatchCoverSection: View {
}
}
}
/// PNG 16:9 magenta, usata solo dai UI test (`--uitesting-reset`).
static func makeUiTestCoverImage() -> UIImage {
let size = CGSize(width: 1280, height: 720)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
UIColor(red: 1, green: 0.125, blue: 0.75, alpha: 1).setFill()
ctx.fill(CGRect(origin: .zero, size: size))
}
}
}
@@ -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)
@@ -92,7 +92,8 @@ struct StepNetworkTestScreen: View {
if testCompleted, let shareUrl {
WizardReadOnlyField(
label: currentSession.platform == "youtube" ? L10n.t("wizard.network.link.youtube.label") : L10n.t("wizard.network.link.live.label"),
value: shareUrl
value: shareUrl,
valueIdentifier: "wizard.network.shareurl"
)
.padding(.top, 16)
@@ -125,6 +126,7 @@ struct StepNetworkTestScreen: View {
action: runTest,
enabled: !testing
)
.accessibilityIdentifier("wizard.network.test")
.padding(.top, 24)
}
@@ -135,12 +137,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)
}
}
@@ -9,7 +9,7 @@ struct StepTransmissionScreen: View {
@State private var team: Team?
@State private var platform = "matchlivetv"
@State private var privacy = "public"
@State private var privacy = ProcessInfo.processInfo.arguments.contains("--uitesting-reset") ? "unlisted" : "public"
@State private var creating = false
private var youtubeReady: Bool {
@@ -42,6 +42,7 @@ struct StepTransmissionScreen: View {
selected: platform == "matchlivetv",
onClick: { platform = "matchlivetv" }
)
.accessibilityIdentifier("wizard.platform.site")
.padding(.top, 12)
WizardPlatformCard(
@@ -70,6 +71,7 @@ struct StepTransmissionScreen: View {
selected: privacy == "unlisted",
action: { privacy = "unlisted" }
)
.accessibilityIdentifier("wizard.privacy.unlisted")
.frame(maxWidth: .infinity)
}
.padding(.top, 12)
@@ -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)
@@ -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")
}
}
}
@@ -66,12 +68,19 @@ struct WizardOutlinedField: View {
struct WizardReadOnlyField: View {
let label: String
let value: String
var valueIdentifier: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(MatchTypography.bodyMedium)
if let valueIdentifier {
Text(value)
.font(MatchTypography.titleMedium)
.accessibilityIdentifier(valueIdentifier)
} else {
Text(value).font(MatchTypography.titleMedium)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 10))
@@ -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)
@@ -0,0 +1,727 @@
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 testLogoutReturnsToLogin() throws {
loginIfNeeded()
waitHub()
dismissSystemDialogs()
tapHittable(firstExisting(["matches.account"], labels: ["Account"]))
XCTAssertTrue(any("account.back").waitForExistence(timeout: 8), "Account non aperto")
app.swipeUp()
app.swipeUp()
XCTAssertTrue(any("account.logout").waitForExistence(timeout: 6), "Bottone Esci assente")
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()
}
applyMatchCover()
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 any("wizard.platform.site").waitForExistence(timeout: 4) {
tapHittable(any("wizard.platform.site"))
}
let unlisted = firstExisting(["wizard.privacy.unlisted"], labels: ["NON IN ELENCO", "UNLISTED"])
XCTAssertTrue(unlisted.waitForExistence(timeout: 6), "Manca l'opzione NON IN ELENCO")
tapHittable(unlisted)
XCTAssertTrue(
app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Solo chi ha il link' OR label CONTAINS 'Only those with the link'")).firstMatch.waitForExistence(timeout: 4)
|| any("wizard.privacy.unlisted").exists,
"Visibilità non impostata su privato/unlisted"
)
capture("07b-privacy-unlisted")
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 tree = app.debugDescription
XCTAssertFalse(tree.contains("youtube.com"), "Il test ha creato un video YouTube: deve restare sul sito, unlisted")
XCTAssertTrue(
tree.contains("matchlivetv.it/live") || tree.contains("/live/"),
"Manca il link diretta sul sito"
)
let sessionId = extractLiveSessionId()
XCTAssertNotNil(sessionId, "Impossibile leggere l'id sessione dal link diretta.\n\(tree)")
if let sessionId {
try? sessionId.write(toFile: "\(shotDir)/live-session-id.txt", atomically: true, encoding: .utf8)
assertLivePageIsUnlistedWithMatchCover(sessionId: sessionId)
}
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.pause").waitForExistence(timeout: 4) {
tapHittable(any("broadcast.pause"))
sleep(1)
capture("09c5-pause-confirm")
let confirmPause = firstExisting([], labels: ["Conferma", "Confirm"])
if confirmPause.waitForExistence(timeout: 3) {
tapHittable(confirmPause)
}
sleep(3)
capture("09c6-paused")
XCTAssertTrue(
app.staticTexts["PAUSA"].waitForExistence(timeout: 8)
|| app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'PAUSA' OR label CONTAINS 'PAUSED'")).firstMatch.exists,
"Dopo la conferma non compare lo stato PAUSA"
)
if let sessionId {
assertViewerShowsCoverWhilePaused(sessionId: sessionId)
}
}
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")
}
private func applyMatchCover() {
scrollToId("wizard.cover.change")
let change = firstExisting(["wizard.cover.change"], labels: ["Cambia copertina per questa partita"])
XCTAssertTrue(change.waitForExistence(timeout: 8), "Manca Cambia copertina per questa partita")
scrollToId("wizard.cover.uitest")
let testCover = any("wizard.cover.uitest")
XCTAssertTrue(testCover.waitForExistence(timeout: 6), "Manca il fallback copertina di test")
tapHittable(testCover)
XCTAssertTrue(
app.staticTexts.matching(
NSPredicate(format: "label CONTAINS 'Questa partita' OR label CONTAINS 'This match'")
).firstMatch.waitForExistence(timeout: 8),
"La copertina di test non è stata applicata a questa partita.\n\(app.debugDescription)"
)
capture("06c-cover-match")
tapHittable(change)
sleep(2)
capture("06b-cover-picker")
_ = pickFirstPhotoFromSystemPicker()
dismissPhotoPicker()
XCTAssertTrue(
app.staticTexts.matching(
NSPredicate(format: "label CONTAINS 'Questa partita' OR label CONTAINS 'This match'")
).firstMatch.waitForExistence(timeout: 6),
"Dopo il selettore foto la copertina della partita è sparita"
)
}
@discardableResult
private func pickFirstPhotoFromSystemPicker() -> Bool {
let photos = XCUIApplication(bundleIdentifier: "com.apple.PhotosUIService")
guard photos.wait(for: .runningForeground, timeout: 5) else { return false }
let candidates: [XCUIElement] = [
photos.images.firstMatch,
photos.collectionViews.cells.firstMatch,
photos.cells.firstMatch
]
for el in candidates {
if el.waitForExistence(timeout: 2) {
el.tap()
sleep(1)
for label in ["Aggiungi", "Add", "Scegli", "Choose", "Done", "Fine"] {
if photos.buttons[label].exists {
photos.buttons[label].tap()
break
}
}
let source = app.staticTexts.matching(
NSPredicate(format: "label CONTAINS 'Questa partita' OR label CONTAINS 'This match'")
).firstMatch
if source.waitForExistence(timeout: 4) { return true }
}
}
return false
}
private func dismissPhotoPicker() {
let photos = XCUIApplication(bundleIdentifier: "com.apple.PhotosUIService")
let spring = XCUIApplication(bundleIdentifier: "com.apple.springboard")
for label in ["Annulla", "Cancel", "Chiudi", "Close"] {
if photos.buttons[label].exists {
photos.buttons[label].tap()
sleep(1)
return
}
if spring.buttons[label].exists {
spring.buttons[label].tap()
sleep(1)
return
}
if app.buttons[label].exists {
tapHittable(app.buttons[label])
sleep(1)
return
}
}
if photos.state == .runningForeground {
photos.swipeDown()
sleep(1)
}
}
private func extractLiveSessionId() -> String? {
let share = any("wizard.network.shareurl")
let raw = share.exists ? share.label : app.debugDescription
guard let range = raw.range(of: #"https?://[^\s]+/live/[0-9a-fA-F-]{36}"#, options: .regularExpression) else {
return nil
}
let url = String(raw[range])
return url.components(separatedBy: "/live/").last
}
private func assertLivePageIsUnlistedWithMatchCover(sessionId: String) {
let pageURL = "https://www.matchlivetv.it/live/\(sessionId)"
let deadline = Date().addingTimeInterval(40)
var html = ""
var poster = ""
while Date() < deadline {
html = httpGet(pageURL) ?? ""
if html.contains("youtube.com/watch") {
XCTFail("La pagina pubblica punta a YouTube invece che al sito")
return
}
if let range = html.range(of: #"poster="([^"]+)""#, options: .regularExpression) {
poster = String(html[range])
.replacingOccurrences(of: "poster=\"", with: "")
.replacingOccurrences(of: "\"", with: "")
}
let unlisted = html.contains("noindex")
let customPoster = !poster.isEmpty && !poster.contains("copertina-canale")
if unlisted && customPoster { break }
RunLoop.current.run(until: Date().addingTimeInterval(2))
}
XCTAssertTrue(html.contains("noindex") || html.contains("nofollow"), "La diretta deve restare non in elenco (noindex)")
XCTAssertFalse(poster.isEmpty, "Manca l'attributo poster sul player live")
XCTAssertFalse(poster.contains("copertina-canale"), "Il poster è ancora la copertina di default: \(poster)")
try? html.write(toFile: "\(shotDir)/live-page.html", atomically: true, encoding: .utf8)
try? poster.write(toFile: "\(shotDir)/live-poster.txt", atomically: true, encoding: .utf8)
}
private func assertViewerShowsCoverWhilePaused(sessionId: String) {
let statusURL = "https://www.matchlivetv.it/live/\(sessionId)/status.json"
let deadline = Date().addingTimeInterval(25)
var payload: [String: Any] = [:]
while Date() < deadline {
payload = httpGetJSON(statusURL) ?? [:]
let paused = payload["paused"] as? Bool ?? false
let status = payload["status"] as? String ?? ""
let showingCover = payload["showing_cover"] as? Bool ?? false
if (paused || status == "paused") && showingCover {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(1.5))
}
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]),
let text = String(data: data, encoding: .utf8) {
try? text.write(toFile: "\(shotDir)/live-status-paused.json", atomically: true, encoding: .utf8)
}
let paused = payload["paused"] as? Bool ?? false
let status = payload["status"] as? String ?? ""
let showingCover = payload["showing_cover"] as? Bool ?? false
XCTAssertTrue(paused || status == "paused", "status.json non è in pausa: \(payload)")
XCTAssertTrue(showingCover, "Gli spettatori non stanno vedendo la copertina (showing_cover=false): \(payload)")
}
private func httpGet(_ urlString: String) -> String? {
guard let url = URL(string: urlString) else { return nil }
let sem = DispatchSemaphore(value: 0)
var result: String?
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data { result = String(data: data, encoding: .utf8) }
sem.signal()
}.resume()
_ = sem.wait(timeout: .now() + 15)
return result
}
private func httpGetJSON(_ urlString: String) -> [String: Any]? {
guard let body = httpGet(urlString),
let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return json
}
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)
}
}
+4 -4
View File
@@ -96,7 +96,7 @@ lines += [
app_settings = """
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 35;
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 = 35;
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;