diff --git a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj index cb642aa..35face1 100644 --- a/native/ios/MatchLiveTv.xcodeproj/project.pbxproj +++ b/native/ios/MatchLiveTv.xcodeproj/project.pbxproj @@ -677,7 +677,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; @@ -698,7 +698,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = S8Q9TWBRG5; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; @@ -727,7 +727,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = S8Q9TWBRG5; ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = NO; @@ -756,7 +756,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; @@ -775,7 +775,7 @@ buildSettings = { CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; @@ -794,7 +794,7 @@ buildSettings = { CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; diff --git a/native/ios/MatchLiveTv/Resources/Info.plist b/native/ios/MatchLiveTv/Resources/Info.plist index 82b2230..04a65da 100644 --- a/native/ios/MatchLiveTv/Resources/Info.plist +++ b/native/ios/MatchLiveTv/Resources/Info.plist @@ -32,7 +32,7 @@ CFBundleVersion - 33 + 35 LSRequiresIPhoneOS NSCameraUsageDescription diff --git a/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift b/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift index 2b940bb..2c82bab 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/MatchCoverSection.swift @@ -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 { @@ -71,6 +73,17 @@ struct MatchCoverSection: View { .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"), @@ -95,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)) + } + } } diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift index 9688f0e..6064cb3 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift @@ -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) diff --git a/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift b/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift index 2727545..542585a 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/StepTransmissionScreen.swift @@ -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) diff --git a/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift b/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift index 88b51ae..1623b87 100644 --- a/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift +++ b/native/ios/MatchLiveTv/UI/Wizard/WizardComponents.swift @@ -68,11 +68,18 @@ 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) - Text(value).font(MatchTypography.titleMedium) + if let valueIdentifier { + Text(value) + .font(MatchTypography.titleMedium) + .accessibilityIdentifier(valueIdentifier) + } else { + Text(value).font(MatchTypography.titleMedium) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) diff --git a/native/ios/MatchLiveTvUITests/HubFlowsUITests.swift b/native/ios/MatchLiveTvUITests/HubFlowsUITests.swift index 720bd4f..f85dd86 100644 --- a/native/ios/MatchLiveTvUITests/HubFlowsUITests.swift +++ b/native/ios/MatchLiveTvUITests/HubFlowsUITests.swift @@ -169,25 +169,7 @@ final class HubFlowsUITests: XCTestCase { 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() - } + applyMatchCover() scrollToId("wizard.next") tapHittable(any("wizard.next")) XCTAssertTrue( @@ -196,13 +178,18 @@ final class HubFlowsUITests: XCTestCase { "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) { - // Non selezionare YouTube in produzione: solo verifica che la card esista. - capture("07b-youtube-visible") + 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( @@ -220,6 +207,18 @@ final class HubFlowsUITests: XCTestCase { "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)) @@ -278,6 +277,26 @@ final class HubFlowsUITests: XCTestCase { } } + 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) @@ -295,6 +314,174 @@ final class HubFlowsUITests: XCTestCase { 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() diff --git a/native/ios/generate_xcodeproj.py b/native/ios/generate_xcodeproj.py index 15048d5..b8ef250 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 = 33; + CURRENT_PROJECT_VERSION = 35; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -122,7 +122,7 @@ app_debug_settings = app_settings + """ test_settings = """ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 35; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 2.0.12;