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;