From 5dcd0f0104bb3101ee09929c0bee90bd1a92a2ef Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 30 Oct 2026 12:19:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommendation):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=EB=B0=B0=EB=84=88=EC=99=80=20=EC=97=B0?= =?UTF-8?q?=EC=86=8D=20=EC=88=9C=ED=99=98=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 18 ++ .../V2/Component/Banner/SubBanner.swift | 42 +++ .../Component/Banner/SubBannerCarousel.swift | 158 +++++++++++ .../Banner/SubBannerLinkOpener.swift | 28 ++ .../MainContentRecommendationView.swift | 6 + .../MainContentRecommendationViewModel.swift | 8 + .../Models/AudioRecommendationsResponse.swift | 6 + .../MainContentRecommendationApi.swift | 4 +- .../MainHomeRecommendationView.swift | 4 + .../MainHomeRecommendationViewModel.swift | 7 + .../MainHomeRecommendationResponse.swift | 5 + .../MainHomeRecommendationApi.swift | 7 +- work/tests/SubBannerCheck.swift | 260 ++++++++++++++++++ work/tests/SubBannerGestureHarness.swift | 203 ++++++++++++++ work/tests/SubBannerGestureUITests.swift | 169 ++++++++++++ work/tests/check-sub-banners.sh | 44 +++ .../generate-sub-banner-gesture-project.rb | 49 ++++ work/tests/run-sub-banner-gesture-tests.sh | 29 ++ 18 files changed, 1045 insertions(+), 2 deletions(-) create mode 100644 SodaLive/Sources/V2/Component/Banner/SubBanner.swift create mode 100644 SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift create mode 100644 SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift create mode 100644 work/tests/SubBannerCheck.swift create mode 100644 work/tests/SubBannerGestureHarness.swift create mode 100644 work/tests/SubBannerGestureUITests.swift create mode 100644 work/tests/check-sub-banners.sh create mode 100644 work/tests/generate-sub-banner-gesture-project.rb create mode 100644 work/tests/run-sub-banner-gesture-tests.sh diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index d38c5b16..212b39a8 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -25,6 +25,12 @@ 2A49520637FD59B21473C7C2 /* PostChannelDonationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */; }; 2B35B93B2F7253CADBC12A82 /* DateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C41DF9D62AF5B9C3C1FAC /* DateParser.swift */; }; 2B65EEB59EE24059AC5DEADC /* BannerCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24266E32FC49D7C31610C2AD /* BannerCarousel.swift */; }; + BA2910013010000000A5BBA3 /* SubBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA2900013010000000A5BBA3 /* SubBanner.swift */; }; + BA2910023010000000A5BBA3 /* SubBannerLinkOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA2900023010000000A5BBA3 /* SubBannerLinkOpener.swift */; }; + BA2910033010000000A5BBA3 /* SubBannerCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA2900033010000000A5BBA3 /* SubBannerCarousel.swift */; }; + BA2920013010000000A5BBA3 /* SubBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA2900013010000000A5BBA3 /* SubBanner.swift */; }; + BA2920023010000000A5BBA3 /* SubBannerLinkOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA2900023010000000A5BBA3 /* SubBannerLinkOpener.swift */; }; + BA2920033010000000A5BBA3 /* SubBannerCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA2900033010000000A5BBA3 /* SubBannerCarousel.swift */; }; 2BA6CE345486A0F75F7E75DA /* CreatorChannelFanTalkActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D241AC104728809A8E70C8 /* CreatorChannelFanTalkActionPopup.swift */; }; 2D761BB45AB6A332BE4690A1 /* CreatorChannelFanTalkEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A08977117E94D72914BB3FA /* CreatorChannelFanTalkEmptyView.swift */; }; 2DA2EA227CAA0E757E63A335 /* CreatorChannelFanTalkCountBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB71B1A61924B10599005FE /* CreatorChannelFanTalkCountBar.swift */; }; @@ -2224,6 +2230,9 @@ 1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationRepository.swift; sourceTree = ""; }; 214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserProfileChannelDonationView.swift; sourceTree = ""; }; 24266E32FC49D7C31610C2AD /* BannerCarousel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BannerCarousel.swift; sourceTree = ""; }; + BA2900013010000000A5BBA3 /* SubBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubBanner.swift; sourceTree = ""; }; + BA2900023010000000A5BBA3 /* SubBannerLinkOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubBannerLinkOpener.swift; sourceTree = ""; }; + BA2900033010000000A5BBA3 /* SubBannerCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubBannerCarousel.swift; sourceTree = ""; }; 25C5D06E4F54C0ED7B7BD462 /* CreatorChannelFanTalkViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkViewModel.swift; sourceTree = ""; }; 25C5D06F4F54C0ED7B7BD462 /* CreatorChannelFanTalkWriteViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkWriteViewModel.swift; sourceTree = ""; }; 25C5D0704F54C0ED7B7BD462 /* CreatorChannelFanTalkWriteView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkWriteView.swift; sourceTree = ""; }; @@ -3388,6 +3397,9 @@ isa = PBXGroup; children = ( 24266E32FC49D7C31610C2AD /* BannerCarousel.swift */, + BA2900013010000000A5BBA3 /* SubBanner.swift */, + BA2900023010000000A5BBA3 /* SubBannerLinkOpener.swift */, + BA2900033010000000A5BBA3 /* SubBannerCarousel.swift */, ); path = Banner; sourceTree = ""; @@ -7986,6 +7998,9 @@ 7AB453CE38CA0B3D612E9467 /* AudioContentCard.swift in Sources */, C0A704000000000000000001 /* AudioContentListRow.swift in Sources */, 2B65EEB59EE24059AC5DEADC /* BannerCarousel.swift in Sources */, + BA2910013010000000A5BBA3 /* SubBanner.swift in Sources */, + BA2910023010000000A5BBA3 /* SubBannerLinkOpener.swift in Sources */, + BA2910033010000000A5BBA3 /* SubBannerCarousel.swift in Sources */, 4572B5004B70CED10BDDB4C3 /* FollowAllButton.swift in Sources */, BCA6CD9E57528C5F4203F1A5 /* CapsuleTabBar.swift in Sources */, 176AE05194A71107EA461B7B /* AiCharacterCard.swift in Sources */, @@ -8928,6 +8943,9 @@ C9E67EC59CB2709A7A75A8B8 /* AudioContentCard.swift in Sources */, C0A704000000000000000002 /* AudioContentListRow.swift in Sources */, 7389BAA35C414A1A0C1FE616 /* BannerCarousel.swift in Sources */, + BA2920013010000000A5BBA3 /* SubBanner.swift in Sources */, + BA2920023010000000A5BBA3 /* SubBannerLinkOpener.swift in Sources */, + BA2920033010000000A5BBA3 /* SubBannerCarousel.swift in Sources */, 9346B50E2D4531CE2CE373C8 /* FollowAllButton.swift in Sources */, 1690309E86928580115E94E2 /* CapsuleTabBar.swift in Sources */, 9B9CA62A8B2AED3A327FB06B /* AiCharacterCard.swift in Sources */, diff --git a/SodaLive/Sources/V2/Component/Banner/SubBanner.swift b/SodaLive/Sources/V2/Component/Banner/SubBanner.swift new file mode 100644 index 00000000..e9f1cd2f --- /dev/null +++ b/SodaLive/Sources/V2/Component/Banner/SubBanner.swift @@ -0,0 +1,42 @@ +import Foundation + +struct SubBanner: Decodable, Hashable, Identifiable { + enum Placement: String, Decodable { + case homeRecommendation = "HOME_RECOMMENDATION" + case contentRecommendation = "CONTENT_RECOMMENDATION" + } + + let id: Int + let placement: Placement + let imageUrl: String + let webUrl: String? + let androidDeepLink: String? + let iosDeepLink: String? + + var imageURL: URL? { Self.webURL(imageUrl) } + var webURL: URL? { Self.webURL(webUrl) } + var iosURL: URL? { + guard let url = Self.absoluteURL(iosDeepLink), + let scheme = url.scheme?.lowercased(), + !["javascript", "data", "file", "content"].contains(scheme) else { return nil } + return url + } + + var isEligible: Bool { + imageURL != nil && (iosURL != nil || webURL != nil) + } + + private static func absoluteURL(_ raw: String?) -> URL? { + guard let raw, let url = URL(string: raw), + url.scheme != nil, url.absoluteString == raw else { return nil } + return url + } + + private static func webURL(_ raw: String?) -> URL? { + guard let url = absoluteURL(raw), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty else { return nil } + return url + } +} diff --git a/SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift b/SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift new file mode 100644 index 00000000..362bb558 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift @@ -0,0 +1,158 @@ +import SwiftUI + +import Kingfisher + +struct SubBannerCarousel: View { + let items: [SubBanner] + let onTap: (SubBanner) -> Void + + @Environment(\.scenePhase) private var scenePhase + @State private var displayIndex = 1 + @State private var itemsRevision = 0 + @State private var containerWidth: CGFloat = 0 + @State private var imageRatios: [String: CGFloat] = [:] + @State private var lastKnownRatio: CGFloat? + @GestureState private var isSwiping = false + @GestureState private var dragOffset: CGFloat = 0 + + private static let autoScrollNanoseconds: UInt64 = 5_000_000_000 + private static let transitionDuration: TimeInterval = 0.35 + + private var safeDisplayIndex: Int { + items.count > 1 ? min(max(displayIndex, 0), items.count + 1) : 0 + } + + private var safeIndex: Int { + items.count > 1 ? (safeDisplayIndex + items.count - 1) % items.count : 0 + } + + private var pages: [(id: String, item: SubBanner)] { + let originals = items.map { (id: "item-\($0.id)", item: $0) } + guard items.count > 1, let first = items.first, let last = items.last else { return originals } + return [(id: "leading-\(last.id)", item: last)] + originals + [(id: "trailing-\(first.id)", item: first)] + } + + private var currentRatio: CGFloat? { + guard !items.isEmpty else { return nil } + return imageRatios[items[safeIndex].imageUrl] + } + + private var height: CGFloat { + guard !items.isEmpty, let ratio = currentRatio ?? lastKnownRatio else { return 0 } + return containerWidth / ratio + } + + var body: some View { + GeometryReader { geometry in + HStack(spacing: 0) { + ForEach(pages, id: \.id) { page in + Button { + onTap(page.item) + } label: { + KFImage(page.item.imageURL) + .placeholder { Color.gray800 } + .onSuccess { result in + let size = result.image.size + guard size.width > 0, size.height > 0 else { return } + imageRatios[page.item.imageUrl] = size.width / size.height + } + .startLoadingBeforeViewAppear() + .resizable() + .scaledToFit() + .frame(width: geometry.size.width, height: height) + .background(Color.gray800) + } + .buttonStyle(SubBannerTapStyle(drag: dragGesture(width: geometry.size.width))) + .accessibilityLabel(Text("\(safeIndex + 1) / \(items.count)")) + .accessibilityHidden(pages[safeDisplayIndex].id != page.id) + } + } + .offset(x: -CGFloat(safeDisplayIndex) * geometry.size.width + dragOffset) + .animation(.easeOut(duration: Self.transitionDuration), value: displayIndex) + .frame(width: geometry.size.width, height: height, alignment: .leading) + .contentShape(Rectangle()) + .clipped() + .onAppear { containerWidth = geometry.size.width } + .onChange(of: geometry.size.width) { containerWidth = $0 } + } + .frame(height: height) + .onChange(of: currentRatio) { ratio in + if let ratio { lastKnownRatio = ratio } + } + .onChange(of: items) { _ in + itemsRevision += 1 + normalize(to: items.count > 1 ? 1 : 0) + if items.isEmpty { lastKnownRatio = nil } + } + .onDisappear { + itemsRevision += 1 + normalize(to: items.count > 1 ? safeIndex + 1 : 0) + } + .task(id: scenePhase == .active && items.count > 1 ? items : []) { + guard scenePhase == .active, items.count > 1 else { return } + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: Self.autoScrollNanoseconds) + } catch { + return + } + guard !Task.isCancelled else { return } + if !isSwiping { move(by: 1) } + } + } + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: move(by: 1) + case .decrement: move(by: -1) + @unknown default: break + } + } + } + + private func dragGesture(width: CGFloat) -> some Gesture { + DragGesture(minimumDistance: SodaSpacing.s12, coordinateSpace: .global) + .updating($dragOffset) { value, offset, _ in + if items.count > 1, abs(value.translation.width) > abs(value.translation.height) { + offset = value.translation.width + } + } + .updating($isSwiping) { _, swiping, _ in swiping = true } + .onEnded { value in + guard abs(value.translation.width) > abs(value.translation.height) else { return } + if value.translation.width < -width * 0.2 { + move(by: 1) + } else if value.translation.width > width * 0.2 { + move(by: -1) + } + } + } + + private func move(by step: Int) { + guard items.count > 1, (1...items.count).contains(displayIndex) else { return } + let next = displayIndex + step + displayIndex = next + guard next == 0 || next == items.count + 1 else { return } + let revision = itemsRevision + DispatchQueue.main.asyncAfter(deadline: .now() + Self.transitionDuration) { + guard itemsRevision == revision, displayIndex == next else { return } + normalize(to: next == 0 ? items.count : 1) + } + } + + private func normalize(to index: Int) { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { displayIndex = index } + } +} + +private struct SubBannerTapStyle: PrimitiveButtonStyle { + let drag: Drag + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .contentShape(Rectangle()) + .simultaneousGesture(drag.exclusively(before: TapGesture().onEnded { configuration.trigger() })) + .accessibilityAction { configuration.trigger() } + } +} diff --git a/SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift b/SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift new file mode 100644 index 00000000..666d52f4 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift @@ -0,0 +1,28 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +enum SubBannerLinkOpener { + static func open(_ banner: SubBanner, using opener: @escaping (URL, @escaping (Bool) -> Void) -> Void) { + let webURL = banner.webURL + if let iosURL = banner.iosURL { + opener(iosURL) { opened in + if !opened, let webURL { + opener(webURL) { _ in } + } + } + } else if let webURL { + opener(webURL) { _ in } + } + } + + #if canImport(UIKit) + static func open(_ banner: SubBanner) { + open(banner) { url, completion in + UIApplication.shared.open(url, options: [:], completionHandler: completion) + } + } + #endif +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift index 70cf55b3..bf3950a0 100644 --- a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift @@ -32,6 +32,12 @@ struct MainContentRecommendationView: View { onTapBanner: onTapBanner ) + if !recommendations.eligibleSubBanners.isEmpty { + SubBannerCarousel(items: recommendations.eligibleSubBanners) { + SubBannerLinkOpener.open($0) + } + } + MainContentVoiceOnOnlySection( title: I18n.MainContentRecommendation.sodaLiveOnlySectionTitle, items: recommendations.originalSeries, diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift index 6cd926fb..ae99dce6 100644 --- a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift @@ -43,6 +43,14 @@ final class MainContentRecommendationViewModel: ObservableObject { } receiveValue: { [weak self] response in guard let self else { return } + guard response.statusCode == 200 else { + self.errorMessage = I18n.MainContentRecommendation.loadFailedMessage + self.isShowPopup = true + self.isLoading = false + self.hasLoaded = true + return + } + let responseData = response.data do { diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift index 375855aa..97c6c45e 100644 --- a/SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift @@ -2,6 +2,7 @@ import Foundation struct AudioRecommendationsResponse: Decodable { let banners: [AudioBannerResponse] + let subBanners: [SubBanner] let originalSeries: [OriginalSeriesResponse] let latestAudios: [AudioCardResponse] let newAndHotAudios: [AudioCardResponse] @@ -10,8 +11,13 @@ struct AudioRecommendationsResponse: Decodable { let mostCommentedAudios: [CommentedAudioResponse] let recommendedAudios: [AudioCardResponse] + var eligibleSubBanners: [SubBanner] { + subBanners.filter { $0.placement == .contentRecommendation && $0.isEligible } + } + var isEmpty: Bool { banners.isEmpty && + eligibleSubBanners.isEmpty && originalSeries.isEmpty && latestAudios.isEmpty && newAndHotAudios.isEmpty && diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift index 886defc8..2a0f565c 100644 --- a/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift @@ -32,6 +32,8 @@ extension MainContentRecommendationApi: TargetType { } var headers: [String: String]? { - return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + let token = UserDefaults.string(forKey: .token) + guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return ["Authorization": "Bearer \(token)"] } } diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift index 7d6ffcbb..51b75146 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift @@ -44,6 +44,10 @@ struct MainHomeRecommendationView: View { onTapCommunityPost: onTapCommunityPost ) + if let items = viewModel.recommendations?.eligibleSubBanners, !items.isEmpty { + SubBannerCarousel(items: items) { SubBannerLinkOpener.open($0) } + } + MainHomeRecentDebutCreatorSection( items: viewModel.recommendations?.recentDebutCreators ?? [], onTapCreator: onTapCreator diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift index 5cfcefb3..657ae1bd 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift @@ -33,6 +33,13 @@ final class MainHomeRecommendationViewModel: ObservableObject { } receiveValue: { [weak self] response in guard let self else { return } + guard response.statusCode == 200 else { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + self.isLoading = false + return + } + let responseData = response.data do { diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift index 2725c66a..087929a5 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Models/MainHomeRecommendationResponse.swift @@ -3,12 +3,17 @@ import Foundation struct HomeRecommendationResponse: Decodable { let lives: [HomeLiveItem] let banners: [RecommendationBannerResponse] + let subBanners: [SubBanner] let recentlyActiveCreators: [HomeActiveCreatorItem] let recentDebutCreators: [HomeCreatorItem] let aiCharacters: [HomeAiCharacterItem] let genreCreators: [HomeGenreCreatorGroupItem] let cheerCreators: [HomeCreatorItem] let popularCommunityPosts: [HomePopularCommunityPostItem] + + var eligibleSubBanners: [SubBanner] { + subBanners.filter { $0.placement == .homeRecommendation && $0.isEligible } + } } struct HomeLiveItem: Decodable, Hashable { diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift index 43d24906..0de3b24a 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift @@ -42,6 +42,11 @@ extension MainHomeRecommendationApi: TargetType { } var headers: [String: String]? { - return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + let token = UserDefaults.string(forKey: .token) + if case .getRecommendations = self, + token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return nil + } + return ["Authorization": "Bearer \(token)"] } } diff --git a/work/tests/SubBannerCheck.swift b/work/tests/SubBannerCheck.swift new file mode 100644 index 00000000..a5c14903 --- /dev/null +++ b/work/tests/SubBannerCheck.swift @@ -0,0 +1,260 @@ +import Foundation +import Combine + +#if SB_MOYA_DOUBLE +public enum Method { case get, post } +public enum Task { case requestPlain, requestJSONEncodable(any Encodable) } +public protocol TargetType { + var baseURL: URL { get } + var path: String { get } + var method: Method { get } + var task: Task { get } + var headers: [String: String]? { get } +} +public struct Response { + public let statusCode: Int + public let data: Data + public init(statusCode: Int, data: Data) { + self.statusCode = statusCode + self.data = data + } +} +public enum MoyaError: Error { case simulatedTransportFailure } +public enum SubBannerTransport { + public static var request: ((any TargetType) -> AnyPublisher)! +} +public final class MoyaProvider { public init() {} } +#elseif SB_COMBINE_MOYA_DOUBLE +import Moya +extension MoyaProvider { + public func requestPublisher(_ target: T) -> AnyPublisher { + SubBannerTransport.request(target) + } +} +#elseif SB_UI_CHECK +enum LanguageOption: String { case system, ko, en, ja } +#else +import Moya + +let BASE_URL = "https://synthetic.invalid" +final class UserDefaults { + static let standard = UserDefaults() + private var values: [String: Any] = [:] + func set(_ value: Any, forKey key: String) { values[key] = value } + func object(forKey key: String) -> Any? { values[key] } + func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } + func string(forKey key: String) -> String? { values[key] as? String } + func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 } + func data(forKey key: String) -> Data? { values[key] as? Data } + func removeObject(forKey key: String) { values.removeValue(forKey: key) } +} +enum I18n { + enum Common { static let commonError = "common-error" } + enum MainContentRecommendation { + static let emptyStateMessage = "empty" + static let loadFailedMessage = "load-failed" + } + enum HomeRecommendation { + static let activityLive = "live" + static let activityAudio = "audio" + static let activityCommunity = "community" + } +} +enum DateParser { + static func relativeTimeText(fromUTC: String, fallback: String, now: Date) -> String { fallback } +} +func DEBUG_LOG(_ message: String) {} +func ERROR_LOG(_ message: String) {} +struct GetCommunityPostListResponse: Decodable {} +final class CreatorCommunityRepository { + func communityPostLike(postId: Int) -> AnyPublisher { + fatalError("Unrelated community request must not execute") + } + func purchaseCommunityPost(postId: Int) -> AnyPublisher { + fatalError("Unrelated purchase request must not execute") + } +} + +@main +struct SubBannerCheck { + static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + guard condition() else { fputs("FAIL: \(message)\n", stderr); exit(1) } + } + + static func payload(home: Bool, items: String = "[]", success: Bool = true) -> String { + let fields = home + ? #""lives":[],"banners":[{"imageUrl":"https://synthetic.invalid/main","link":"https://synthetic.invalid/legacy"}],"recentlyActiveCreators":[],"recentDebutCreators":[],"aiCharacters":[],"genreCreators":[],"cheerCreators":[],"popularCommunityPosts":[]"# + : #""banners":[],"originalSeries":[],"latestAudios":[],"newAndHotAudios":[],"freeAudios":[],"pointAudios":[],"mostCommentedAudios":[],"recommendedAudios":[]"# + return #"{"success":\#(success),"message":"server-message","errorProperty":null,"data":{\#(fields),"subBanners":\#(items)}}"# + } + + static func checkAPI() { + for token in ["", " \t\n", "synthetic-token", " original-token "] { + UserDefaults.set(token, forKey: .token) + var paths: [String] = [] + SubBannerTransport.request = { target in + paths.append(target.path) + expect(target.baseURL.absoluteString == BASE_URL, "Base URL changed") + let follow = target.path.hasSuffix("/creators/follow") + let expectedHeader = follow || !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "Bearer \(token)" : nil + expect(target.headers?["Authorization"] == expectedHeader, + "Anonymous GET must omit Authorization; authenticated/follow must preserve raw token: \(target.path)") + if follow { + expect(target.method == .post, "Follow method changed") + guard case .requestJSONEncodable(let body) = target.task else { fatalError("Follow body missing") } + let encoded = try! JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as! [String: [Int]] + expect(encoded == ["creatorIds": [7, 9]], "Follow payload changed") + } else { + expect(target.method == .get, "Recommendations must GET") + guard case .requestPlain = target.task else { fatalError("No extra query/body allowed") } + } + return Empty().eraseToAnyPublisher() + } + let home = MainHomeRecommendationRepository() + _ = home.getRecommendations() + _ = MainContentRecommendationRepository().getAudioRecommendations() + _ = home.followRecommendedCreators(request: FollowRecommendedCreatorsRequest(creatorIds: [7, 9])) + expect(paths == ["/api/v2/home/recommendations", "/api/v2/audio/recommendations", "/api/v2/home/recommendations/creators/follow"], "Extra request or changed path") + } + print("PASS: actual APIs/repositories, two exact GETs, requestPlain, anonymous/whitespace auth, original bearer, adjacent follow POST/body/header") + } + + static func checkVMs() { + for home in [true, false] { + let failures: [(String, Int, Bool, String)] = [ + (payload(home: home), 201, false, "http"), + (payload(home: home), 401, false, "http"), + (payload(home: home), 503, false, "http"), + ("bad", 503, false, "http"), + (payload(home: home, success: false), 200, false, "server"), + (#"{"success":false,"data":null,"message":"server-message"}"#, 200, false, "server"), + ("broken", 200, false, "decode"), + (payload(home: home), 200, true, "transport") + ] + for (body, status, transportFailure, kind) in failures { + let subject = PassthroughSubject() + var requests = 0 + SubBannerTransport.request = { target in + requests += 1 + expect(target.path == (home ? "/api/v2/home/recommendations" : "/api/v2/audio/recommendations"), "VM uses wrong repository") + return subject.eraseToAnyPublisher() + } + let homeModel = MainHomeRecommendationViewModel() + let contentModel = MainContentRecommendationViewModel() + if home { homeModel.fetchRecommendations() } else { contentModel.fetchRecommendations() } + if transportFailure { subject.send(completion: .failure(.simulatedTransportFailure)) } + else { + subject.send(Response(statusCode: status, data: Data(body.utf8))) + subject.send(completion: .finished) + } + expect(requests == 1, "VM must make exactly one request") + expect(home ? homeModel.recommendations == nil : contentModel.recommendations == nil, + "HTTP \(status)/\(kind) must not become successful recommendations (home=\(home))") + expect(home ? homeModel.isShowPopup : contentModel.isShowPopup, "Existing failure popup lost") + expect(home ? !homeModel.isLoading : !contentModel.isLoading && contentModel.hasLoaded, "Failure must finish loading") + let message = home ? homeModel.errorMessage : contentModel.errorMessage + expect(message == (kind == "server" ? "server-message" : (home ? "common-error" : "load-failed")), "Failure message semantics changed") + } + } + print("PASS: actual two VMs reject non200 valid/invalid JSON, success false, decode/transport errors; preserve error UI and loading") + } + + static func item(id: Int = 1, placement: String = "HOME_RECOMMENDATION", image: String = "https://synthetic.invalid/image", web: String? = "https://synthetic.invalid/web", ios: String? = nil, android: String? = nil) -> String { + func encoded(_ value: String?) -> String { String(data: try! JSONEncoder().encode(value), encoding: .utf8)! } + return #"{"id":\#(id),"placement":"\#(placement)","imageUrl":\#(encoded(image)),"webUrl":\#(encoded(web)),"iosDeepLink":\#(encoded(ios)),"androidDeepLink":\#(encoded(android))}"# + } + + static func decode(_ json: String) -> SubBanner { try! JSONDecoder().decode(SubBanner.self, from: Data(json.utf8)) } + + static func checkDTOs() { + for id in [9_007_199_254_740_993, Int.max] { + let banner = decode(item(id: id)) + expect(banner.id == id && banner.placement == .homeRecommendation, "Lossless integer/enum decoding") + } + for bad in [item().replacingOccurrences(of: "HOME_RECOMMENDATION", with: "OTHER"), item().replacingOccurrences(of: "\"id\":1", with: "\"id\":1.5")] { + expect((try? JSONDecoder().decode(SubBanner.self, from: Data(bad.utf8))) == nil, "Invalid enum/fractional ID accepted") + } + let list = (1...23).reversed().map { item(id: $0) } + let mixed = "[" + (list + [item(id: 24, placement: "CONTENT_RECOMMENDATION"), item(id: 25, web: nil, android: "android://only"), item(id: 26, image: "/relative")]).joined(separator: ",") + "]" + let home = try! JSONDecoder().decode(ApiResponse.self, from: Data(payload(home: true, items: mixed).utf8)) + let content = try! JSONDecoder().decode(ApiResponse.self, from: Data(payload(home: false, items: mixed).utf8)) + expect(home.data!.eligibleSubBanners.map(\.id) == Array((1...23).reversed()), "Full 23+ order, eligibility or home placement lost") + expect(home.data!.banners.first?.link == "https://synthetic.invalid/legacy" && home.data!.subBanners.count == 26, "Legacy/data arrays lost") + expect(content.data!.eligibleSubBanners.map(\.id) == [24] && !content.data!.isEmpty, "Content subbanner-only must not be empty") + for items in ["[]", "[" + item() + "]", "[" + item(placement: "CONTENT_RECOMMENDATION", web: nil, android: "android://only") + "]"] { + let value = try! JSONDecoder().decode(ApiResponse.self, from: Data(payload(home: false, items: items).utf8)) + expect(value.data!.isEmpty, "Filtered-empty content must remain empty") + } + for home in [true, false] { + let missing = payload(home: home).replacingOccurrences(of: ",\"subBanners\":[]", with: "") + let invalid = payload(home: home, items: "null") + for json in [missing, invalid] { + if home { + expect((try? JSONDecoder().decode(ApiResponse.self, from: Data(json.utf8))) == nil, "Home subBanners required") + } else { + expect((try? JSONDecoder().decode(ApiResponse.self, from: Data(json.utf8))) == nil, "Content subBanners required") + } + } + let subject = PassthroughSubject() + SubBannerTransport.request = { _ in subject.eraseToAnyPublisher() } + let homeModel = MainHomeRecommendationViewModel() + let contentModel = MainContentRecommendationViewModel() + if home { homeModel.fetchRecommendations() } else { contentModel.fetchRecommendations() } + subject.send(Response(statusCode: 200, data: Data(payload(home: home, items: mixed).utf8))) + expect(home ? homeModel.recommendations?.eligibleSubBanners.count == 23 : contentModel.recommendations?.eligibleSubBanners.count == 1 && !contentModel.shouldShowEmptyState, "Actual VM success/filter delivery") + subject.send(Response(statusCode: 503, data: Data(payload(home: home).utf8))) + expect(home ? homeModel.recommendations?.subBanners.count == 26 : contentModel.recommendations?.subBanners.count == 26, "Failure must not replace existing data with empty") + } + print("PASS: actual DTOs/VM success, >2^53 and Int.max, enum, required array, 23+ full order, placement/eligibility, legacy banner, subbanner-only/filtered-empty, failure retains data") + } + + static func checkURLsAndOpener() { + let escaped = "HTTPS://synthetic.invalid/a%2fb?q=a%20b&next=soda%3A%2F%2Fgo#Raw" + for raw in ["https://synthetic.invalid", "http://synthetic.invalid/x", escaped] { + let banner = decode(item(image: raw, web: raw)) + expect(banner.imageURL?.absoluteString == raw && banner.webURL?.absoluteString == raw, "Accepted HTTP URI must be exact") + } + let badWeb = ["", " ", "\t\n", "/relative", "//synthetic.invalid", "https://", "https:///", "https:/path", "soda://host", "https://synthetic.invalid/a b", "https://synthetic.invalid/%zz", " https://synthetic.invalid", "https://synthetic.invalid\n"] + for raw in badWeb { + expect(decode(item(web: raw)).webURL == nil && decode(item(image: raw)).imageURL == nil, "Invalid web/image accepted: \(raw)") + } + let badDeep = ["", " ", "/relative", "//host", "1soda:abc", "JavaScript:alert(1)", "DATA:text/plain,x", "FILE:///tmp/x", "Content://host", "soda://a b", "soda://host/%zz", " soda://host", "soda://host\n"] + for raw in badDeep { expect(decode(item(ios: raw)).iosURL == nil, "Invalid deep URI accepted: \(raw)") } + for raw in ["soda://host/a%2fb?q=x%20y", "SoDa:opaque", escaped] { + expect(decode(item(ios: raw)).iosURL?.absoluteString == raw, "Deep URI normalized") + } + for mask in 0..<8 { + let web = mask & 1 != 0 ? escaped : nil + let ios = mask & 2 != 0 ? "SoDa://open/a%2fb?q=x%20y" : nil + let android = mask & 4 != 0 ? "android://never" : nil + let banner = decode(item(web: web, ios: ios, android: android)) + expect(banner.isEligible == (mask & 3 != 0), "All 8 link combinations eligibility") + for succeeds in [false, true] { + var attempts: [String] = [] + var pending: ((Bool) -> Void)? + SubBannerLinkOpener.open(banner) { url, completion in + attempts.append(url.absoluteString) + pending = completion + } + expect(attempts == (ios.map { [$0] } ?? web.map { [$0] } ?? []), "iOS-first initial attempt/order/raw URI") + pending?(succeeds) + let expected = ios.map { [$0] + (!succeeds ? web.map { [$0] } ?? [] : []) } ?? web.map { [$0] } ?? [] + expect(attempts == expected, "iOS result must fallback web once; never Android (mask=\(mask))") + if ios != nil && !succeeds && web != nil { pending?(false) } + expect(attempts == expected, "Both failures must not retry") + } + } + print("PASS: HTTP(S)+host, absolute URI/blacklist, malformed/blank/auto-encoded rejection, exact escaped URI, all 8 link combinations, completion-based iOS/web attempts, Android never") + } + + static func main() { + if CommandLine.arguments.contains("--http") { checkVMs(); return } + checkAPI() + checkVMs() + checkDTOs() + checkURLsAndOpener() + print("PASS: sub-banner production-code checks; native Combine, Moya transport double; no live requests") + } +} +#endif diff --git a/work/tests/SubBannerGestureHarness.swift b/work/tests/SubBannerGestureHarness.swift new file mode 100644 index 00000000..b2f5d24b --- /dev/null +++ b/work/tests/SubBannerGestureHarness.swift @@ -0,0 +1,203 @@ +import SwiftUI +import Kingfisher + +enum LanguageOption: String { case system, ko, en, ja } + +func recordHarnessEvent(_ event: String) { + let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let url = directory.appendingPathComponent("events.jsonl") + let data = try! JSONSerialization.data(withJSONObject: [ + "event": event, "time": CACurrentMediaTime(), "pid": ProcessInfo.processInfo.processIdentifier, + "test": ProcessInfo.processInfo.environment["SB_TEST_NAME"] ?? "manual" + ]) + Data([10]) + if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) } + let handle = try! FileHandle(forWritingTo: url) + handle.seekToEndOfFile() + handle.write(data) + try! handle.close() +} + +final class FixtureProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + } + override func stopLoading() {} +} + +@MainActor +final class FrameRecorder { + static let shared = FrameRecorder() + private var link: CADisplayLink? + private var end: CFTimeInterval = 0 + private var directory: URL? + private var frames: [[String: Any]] = [] + private var onTransition: (() -> Void)? + + func start(_ name: String, duration: TimeInterval = 2.5, onTransition: (() -> Void)? = nil) { + stop() + self.onTransition = onTransition + recordHarnessEvent("record-\(name)") + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + directory = documents.appendingPathComponent(name) + try! FileManager.default.createDirectory(at: directory!, withIntermediateDirectories: true) + frames = [] + end = CACurrentMediaTime() + duration + let displayLink = CADisplayLink(target: self, selector: #selector(capture)) + displayLink.preferredFramesPerSecond = 30 + displayLink.add(to: .main, forMode: .common) + link = displayLink + } + + @objc private func capture() { + guard CACurrentMediaTime() < end else { stop(); return } + guard let window = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows).first(where: \.isKeyWindow), let directory else { return } + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) + } + let filename = String(format: "%04d.png", frames.count) + try! image.pngData()!.write(to: directory.appendingPathComponent(filename)) + frames.append(["file": filename, "time": CACurrentMediaTime()]) + try! JSONSerialization.data(withJSONObject: frames, options: .prettyPrinted) + .write(to: directory.appendingPathComponent("frames.json")) + if onTransition != nil, hasBothEndpointImages(image) { + recordHarnessEvent("observed-mixed-\(directory.lastPathComponent)") + let action = onTransition + onTransition = nil + DispatchQueue.main.async { action?() } + } + } + + private func hasBothEndpointImages(_ image: UIImage) -> Bool { + let row = image.cgImage!.cropping(to: CGRect(x: 0, y: 240, width: 375, height: 1))! + var pixels = [UInt8](repeating: 0, count: 375 * 4) + pixels.withUnsafeMutableBytes { buffer in + let context = CGContext(data: buffer.baseAddress, width: 375, height: 1, bitsPerComponent: 8, + bytesPerRow: 375 * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.draw(row, in: CGRect(x: 0, y: 0, width: 375, height: 1)) + } + var red = 0 + var green = 0 + for offset in stride(from: 0, to: pixels.count, by: 4) { + if pixels[offset] > 200, pixels[offset + 1] < 100, pixels[offset + 2] < 100 { red += 1 } + if pixels[offset] < 100, pixels[offset + 1] > 150, pixels[offset + 2] < 150 { green += 1 } + } + return red > 5 && green > 5 + } + + private func stop() { + link?.invalidate() + link = nil + if let directory { + try! JSONSerialization.data(withJSONObject: frames, options: .prettyPrinted) + .write(to: directory.appendingPathComponent("frames.json")) + } + } +} + +@main +struct SubBannerGestureApp: App { + init() { + recordHarnessEvent("launch") + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [FixtureProtocol.self] + ImageDownloader.default.sessionConfiguration = configuration + for index in 1...3 { + for mixed in [false, true] { + let size = CGSize(width: 600, height: mixed && index == 2 ? 200 : 300) + let image = UIGraphicsImageRenderer(size: size).image { context in + [UIColor.systemRed, .systemGreen, .systemBlue][index - 1].setFill() + context.fill(CGRect(origin: .zero, size: size)) + UIColor.white.setFill() + context.fill(CGRect(x: 18, y: 0, width: 12, height: size.height)) + context.fill(CGRect(x: size.width - 30, y: 0, width: 12, height: size.height)) + ("BANNER \(index)" as NSString).draw(at: CGPoint(x: 180, y: 80), withAttributes: [ + .font: UIFont.boldSystemFont(ofSize: 36), .foregroundColor: UIColor.white + ]) + } + ImageCache.default.store(image, forKey: Self.url(index, mixed: mixed), toDisk: false) + } + } + } + + static func url(_ index: Int, mixed: Bool) -> String { + "https://gesture-fixture.invalid/\(mixed ? "mixed" : "equal")/\(index).png" + } + + var body: some Scene { + WindowGroup { GestureHarness() } + } +} + +struct GestureHarness: View { + private let arguments = ProcessInfo.processInfo.arguments + @Environment(\.scenePhase) private var scenePhase + @State private var count = 0 + @State private var lastID = 0 + @State private var replaced = false + @State private var shown = true + + private var active: Bool { arguments.contains("active") } + private var items: [SubBanner] { + if arguments.contains("empty") { return [] } + let amount = replaced || arguments.contains("single") ? 1 : (active ? 2 : 3) + return (1...amount).map { index in + SubBanner(id: replaced ? 9 : index, placement: .homeRecommendation, + imageUrl: SubBannerGestureApp.url(index, mixed: arguments.contains("mixed")), + webUrl: "https://gesture-fixture.invalid/link/\(index)", + androidDeepLink: nil, iosDeepLink: nil) + } + } + + var body: some View { + VStack(spacing: SodaSpacing.s8) { + Text("taps=\(count);last=\(lastID)").accessibilityIdentifier("tap-status") + Text("Native touch regression") + HStack { + Button("Record") { FrameRecorder.shared.start("manual") } + Button("Forward") { FrameRecorder.shared.start("forward") } + Button("Replace") { recordHarnessEvent("replace"); replaced = true } + Button(shown ? "Hide" : "Show") { recordHarnessEvent(shown ? "hide" : "show"); shown.toggle() } + } + ScrollView { + VStack(spacing: SodaSpacing.s24) { + if shown { + SubBannerCarousel(items: items) { item in + count += 1 + lastID = item.id + recordHarnessEvent("tap-\(item.id)") + } + .environment(\.scenePhase, active ? scenePhase : .inactive) + } + Text("추천 콘텐츠").accessibilityIdentifier("after-banner") + ForEach(0..<30) { index in + Text("Content row \(index)").frame(maxWidth: .infinity).frame(height: 44) + } + } + } + } + .appFont(.body3) + .background(Color.bg) + .foregroundColor(.white) + .preferredColorScheme(.dark) + .task(id: scenePhase) { + recordHarnessEvent("phase-\(scenePhase)") + guard active, scenePhase == .active, !arguments.contains("background") else { return } + do { try await Task.sleep(nanoseconds: 8_000_000_000) } catch { return } + let name = arguments.contains("replace-during-wrap") ? "replace" : (arguments.contains("hide-during-wrap") ? "hide" : "automatic") + if name == "automatic" { + FrameRecorder.shared.start(name, duration: 4) + } else { + FrameRecorder.shared.start(name, duration: 4) { + recordHarnessEvent(name) + if name == "replace" { replaced = true } else { shown = false } + } + } + } + } +} diff --git a/work/tests/SubBannerGestureUITests.swift b/work/tests/SubBannerGestureUITests.swift new file mode 100644 index 00000000..5b2955bc --- /dev/null +++ b/work/tests/SubBannerGestureUITests.swift @@ -0,0 +1,169 @@ +import XCTest + +final class SubBannerGestureUITests: XCTestCase { + private var app: XCUIApplication! + + private func launch(_ arguments: [String] = []) { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = arguments + app.launchEnvironment["SB_TEST_NAME"] = name + app.launch() + XCTAssertTrue(app.staticTexts["tap-status"].waitForExistence(timeout: 5)) + if !arguments.contains("empty") { XCTAssertTrue(page(1, total: arguments.contains("single") ? 1 : (arguments.contains("active") ? 2 : 3)).waitForExistence(timeout: 5)) } + } + + private func page(_ index: Int, total: Int = 3) -> XCUIElement { + app.buttons["\(index) / \(total)"].firstMatch + } + + private func status(_ count: Int, _ last: Int = 0) { + XCTAssertEqual(app.staticTexts["tap-status"].label, "taps=\(count);last=\(last)") + } + + private func drag(_ index: Int, from: CGFloat = 0.8, to: CGFloat = 0.2, duration: TimeInterval = 0.05, total: Int = 3) { + let banner = page(index, total: total) + banner.coordinate(withNormalizedOffset: CGVector(dx: from, dy: 0.5)) + .press(forDuration: duration, thenDragTo: banner.coordinate(withNormalizedOffset: CGVector(dx: to, dy: 0.5))) + } + + func testSwipeDoesNotTap() { + launch() + drag(1) + XCTAssertTrue(page(2).exists) + status(0) + page(2).tap() + status(1, 2) + } + + func testDirectionsWrapShortDragAndTap() { + launch() + page(1).tap() + status(1, 1) + drag(1, from: 0.2, to: 0.8) + XCTAssertTrue(page(3).exists) + status(1, 1) + page(3).tap() + status(2, 3) + drag(3) + XCTAssertTrue(page(1).exists) + status(2, 3) + drag(1, from: 0.5, to: 0.41) + XCTAssertTrue(page(1).exists) + status(2, 3) + drag(1, from: 0.5, to: 0.455) + status(2, 3) + page(1).tap() + status(3, 1) + XCTAssertEqual(app.buttons.matching(NSPredicate(format: "label == '1 / 3'")).count, 1) + } + + func testVerticalParentScrollDoesNotTap() { + launch() + let before = app.staticTexts["Content row 3"].frame.minY + let banner = page(1) + let start = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)) + let end = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)) + start.press(forDuration: 0.05, thenDragTo: end) + status(0) + XCTAssertLessThan(app.staticTexts["Content row 3"].frame.minY, before - 30) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.lifetime = .keepAlways + add(attachment) + } + + func testSingleAndEmpty() { + launch(["single"]) + page(1, total: 1).tap() + status(1, 1) + drag(1, total: 1) + status(1, 1) + app.terminate() + launch(["empty"]) + XCTAssertEqual(app.buttons.matching(NSPredicate(format: "label CONTAINS ' / '")).count, 0) + XCTAssertLessThan(app.staticTexts["after-banner"].frame.minY, 200) + } + + func testMixedRatiosAndReplacement() { + launch(["mixed"]) + XCTAssertEqual(page(1).frame.height, page(1).frame.width / 2, accuracy: 2) + drag(1) + XCTAssertEqual(page(2).frame.height, page(2).frame.width / 3, accuracy: 2) + drag(2, from: 0.2, to: 0.8) + drag(1, from: 0.2, to: 0.8) + XCTAssertEqual(page(3).frame.height, page(3).frame.width / 2, accuracy: 2) + app.buttons["Replace"].tap() + XCTAssertTrue(page(1, total: 1).exists) + page(1, total: 1).tap() + status(1, 9) + } + + func testManualBoundaryFrames() { + launch() + app.buttons["Record"].tap() + drag(1, from: 0.2, to: 0.8) + XCTAssertTrue(page(3).exists) + status(0) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.lifetime = .keepAlways + add(attachment) + Thread.sleep(forTimeInterval: 1) + app.buttons["Forward"].tap() + drag(3) + XCTAssertTrue(page(1).exists) + status(0) + Thread.sleep(forTimeInterval: 1) + } + + func testSwipeReleasePositionsAndRepeatedInput() { + launch() + drag(1, from: 0.9, to: 0.6, duration: 0.2) + XCTAssertTrue(page(2).waitForExistence(timeout: 2)) + status(0) + drag(2, from: 0.7, to: 0.15, duration: 0) + drag(3, from: 0.6, to: 0.1, duration: 0.1) + XCTAssertTrue(page(1).exists) + status(0) + page(1).tap() + status(1, 1) + } + + func testAutomaticFiveSecondWrapFrames() { + launch(["active"]) + XCTAssertTrue(page(2, total: 2).waitForExistence(timeout: 7)) + XCTAssertTrue(page(1, total: 2).waitForExistence(timeout: 7)) + Thread.sleep(forTimeInterval: 2) + status(0) + page(1, total: 2).tap() + status(1, 1) + } + + func testReplacementDuringAutomaticWrap() { + launch(["active", "replace-during-wrap"]) + XCTAssertTrue(page(1, total: 1).waitForExistence(timeout: 13)) + Thread.sleep(forTimeInterval: 0.5) + page(1, total: 1).tap() + status(1, 9) + } + + func testDisappearanceDuringAutomaticWrap() { + launch(["active", "hide-during-wrap"]) + XCTAssertTrue(page(2, total: 2).waitForExistence(timeout: 7)) + let removed = expectation(for: NSPredicate(format: "count == 0"), evaluatedWith: app.buttons.matching(NSPredicate(format: "label CONTAINS ' / '"))) + wait(for: [removed], timeout: 8) + Thread.sleep(forTimeInterval: 1) + status(0) + app.buttons["Show"].tap() + XCTAssertTrue(page(1, total: 2).waitForExistence(timeout: 2)) + } + + func testInactiveSceneCancelsAutomaticAdvance() { + launch(["active", "background"]) + let label = app.buttons.matching(NSPredicate(format: "label CONTAINS ' / '")).firstMatch.label + XCUIDevice.shared.press(.home) + Thread.sleep(forTimeInterval: 6) + app.activate() + XCTAssertTrue(app.buttons[label].exists) + status(0) + } +} diff --git a/work/tests/check-sub-banners.sh b/work/tests/check-sub-banners.sh new file mode 100644 index 00000000..9e636c79 --- /dev/null +++ b/work/tests/check-sub-banners.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TASK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/sub-banner-check.XXXXXX")" +export TMPDIR="$TASK_ROOT" NODE_COMPILE_CACHE="$TASK_ROOT/node" BUN_RUNTIME_TRANSPILER_CACHE_PATH="$TASK_ROOT/bun" +trap 'rm -rf "$TASK_ROOT"' EXIT +if [[ "${1:-}" == --ui ]]; then + MODULES=("$HOME"/Library/Developer/Xcode/DerivedData/SodaLive-*/Build/Products/Debug-iphonesimulator/Kingfisher.swiftmodule) + MODULE_PATH="${KINGFISHER_MODULE_PATH:-$(dirname "${MODULES[0]}")}" + xcrun swiftc -swift-version 5 -typecheck -D SB_UI_CHECK \ + -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" -target x86_64-apple-ios16.6-simulator \ + -module-cache-path "$TASK_ROOT/modules" -I "$MODULE_PATH" \ + "$ROOT/SodaLive/Sources/V2/Component/Banner/SubBanner"*.swift \ + "$ROOT/SodaLive/Sources/UI/Theme/"{Color,Spacing}.swift \ + "$ROOT/SodaLive/Sources/Extensions/"{ColorExtension,FontModifier}.swift \ + "$ROOT/work/tests/SubBannerCheck.swift" + printf '%s\n' 'PASS: production carousel/model/UIKit opener iOS16.6 typecheck with installed Kingfisher and real theme/font files' + exit +fi +swiftc -swift-version 5 -module-cache-path "$TASK_ROOT/modules" \ + -D SB_MOYA_DOUBLE -emit-module -emit-library -module-name Moya \ + "$ROOT/work/tests/SubBannerCheck.swift" -o "$TASK_ROOT/libMoya.dylib" +swiftc -swift-version 5 -module-cache-path "$TASK_ROOT/modules" \ + -I "$TASK_ROOT" -L "$TASK_ROOT" -lMoya \ + -D SB_COMBINE_MOYA_DOUBLE -emit-module -emit-library -module-name CombineMoya \ + "$ROOT/work/tests/SubBannerCheck.swift" -o "$TASK_ROOT/libCombineMoya.dylib" +swiftc -swift-version 5 -module-cache-path "$TASK_ROOT/modules" \ + "$ROOT/SodaLive/Sources/V2/Component/Banner/SubBanner.swift" \ + "$ROOT/SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift" \ + -I "$TASK_ROOT" -L "$TASK_ROOT" -lMoya -lCombineMoya \ + -Xlinker -rpath -Xlinker "$TASK_ROOT" \ + "$ROOT/SodaLive/Sources/V2/Main/Home/Recommendation/Repository/"*.swift \ + "$ROOT/SodaLive/Sources/V2/Main/Home/Recommendation/Models/"*.swift \ + "$ROOT/SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift" \ + "$ROOT/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/"*.swift \ + "$ROOT/SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift" \ + "$ROOT/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift" \ + "$ROOT/SodaLive/Sources/Settings/Event/Event.swift" \ + "$ROOT/SodaLive/Sources/Common/ApiResponse.swift" \ + "$ROOT/SodaLive/Sources/Common/ApiResponseWithoutData.swift" \ + "$ROOT/SodaLive/Sources/Extensions/UserDefaultsExtension.swift" \ + "$ROOT/work/tests/SubBannerCheck.swift" -o "$TASK_ROOT/check-sub-banners" +"$TASK_ROOT/check-sub-banners" "$@" diff --git a/work/tests/generate-sub-banner-gesture-project.rb b/work/tests/generate-sub-banner-gesture-project.rb new file mode 100644 index 00000000..a03d4703 --- /dev/null +++ b/work/tests/generate-sub-banner-gesture-project.rb @@ -0,0 +1,49 @@ +require 'xcodeproj' + +root, output, kingfisher = ARGV +project = Xcodeproj::Project.new("#{output}/Gesture.xcodeproj") +app = project.new_target(:application, 'Gesture', :ios, '16.6') +tests = project.new_target(:ui_test_bundle, 'GestureUITests', :ios, '16.6') +sources = %w[ + SodaLive/Sources/V2/Component/Banner/SubBanner.swift + SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift + SodaLive/Sources/UI/Theme/Color.swift + SodaLive/Sources/UI/Theme/Spacing.swift + SodaLive/Sources/Extensions/ColorExtension.swift + SodaLive/Sources/Extensions/FontModifier.swift + work/tests/SubBannerGestureHarness.swift +] +sources.each do |path| + source = path.end_with?('/SubBannerCarousel.swift') && ARGV[3] ? ARGV[3] : "#{root}/#{path}" + app.source_build_phase.add_file_reference(project.main_group.new_file(source)) +end +tests.source_build_phase.add_file_reference(project.main_group.new_file("#{root}/work/tests/SubBannerGestureUITests.swift")) +tests.add_dependency(app) +[app, tests].each do |target| + target.build_configurations.each do |configuration| + configuration.build_settings.merge!({ + 'PRODUCT_BUNDLE_IDENTIFIER' => "invalid.sodalive.gesturefix.#{target.name}", + 'GENERATE_INFOPLIST_FILE' => 'YES', + 'CODE_SIGNING_ALLOWED' => 'NO', + 'SWIFT_VERSION' => '5.0', + 'TARGETED_DEVICE_FAMILY' => '1', + 'ARCHS' => 'x86_64', + 'SWIFT_OPTIMIZATION_LEVEL' => '-Onone' + }) + end +end +app.build_configurations.each do |configuration| + configuration.build_settings.merge!({ + 'SWIFT_INCLUDE_PATHS' => "\"#{kingfisher}\"", + 'OTHER_LDFLAGS' => ['$(inherited)', "\"#{kingfisher}/Kingfisher.o\""], + 'INFOPLIST_KEY_UILaunchScreen_Generation' => 'YES' + }) +end +tests.build_configurations.each { |configuration| configuration.build_settings['TEST_TARGET_NAME'] = 'Gesture' } +project.save +scheme = Xcodeproj::XCScheme.new +scheme.add_build_target(app) +scheme.add_build_target(tests) +scheme.set_launch_target(app) +scheme.add_test_target(tests) +scheme.save_as(project.path, 'Gesture', true) diff --git a/work/tests/run-sub-banner-gesture-tests.sh b/work/tests/run-sub-banner-gesture-tests.sh new file mode 100644 index 00000000..800b0601 --- /dev/null +++ b/work/tests/run-sub-banner-gesture-tests.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +: "${SB_SIMULATOR_ID:?Set SB_SIMULATOR_ID to a booted iOS simulator UUID}" +BASE="${TMPDIR:-/tmp}" +RUN_ROOT="${SB_RUN_ROOT:-$(mktemp -d "$BASE/sub-banner-touch.XXXXXX")}" +export TMPDIR="$RUN_ROOT/tmp" CLANG_MODULE_CACHE_PATH="$RUN_ROOT/clang" SWIFT_MODULECACHE_PATH="$RUN_ROOT/modules" +export NODE_COMPILE_CACHE="$RUN_ROOT/node" BUN_RUNTIME_TRANSPILER_CACHE_PATH="$RUN_ROOT/bun" +mkdir -p "$TMPDIR" +MODULES=("$HOME"/Library/Developer/Xcode/DerivedData/SodaLive-*/Build/Products/Debug-iphonesimulator/Kingfisher.swiftmodule) +KINGFISHER="${KINGFISHER_MODULE_PATH:-$(dirname "${MODULES[0]}")}" +NAME="${SB_RUN_NAME:-tests}" +CAROUSEL_SOURCE="${SB_CAROUSEL_SOURCE:-$ROOT/SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift}" +ruby "$ROOT/work/tests/generate-sub-banner-gesture-project.rb" "$ROOT" "$RUN_ROOT" "$KINGFISHER" "$CAROUSEL_SOURCE" +shasum -a 256 "$ROOT/SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift" > "$RUN_ROOT/$NAME-before.sha256" +shasum -a 256 "$CAROUSEL_SOURCE" > "$RUN_ROOT/$NAME-compiled.sha256" +printf 'Owned root: %s\n' "$RUN_ROOT" +set +e +xcodebuild -project "$RUN_ROOT/Gesture.xcodeproj" -scheme Gesture -configuration Debug \ + -destination "platform=iOS Simulator,id=$SB_SIMULATOR_ID" \ + -derivedDataPath "$RUN_ROOT/build" -resultBundlePath "$RUN_ROOT/$NAME.xcresult" \ + -parallel-testing-enabled NO -maximum-concurrent-test-simulator-destinations 1 \ + test "$@" > "$RUN_ROOT/$NAME.log" 2>&1 +RESULT=$? +set -e +shasum -a 256 "$ROOT/SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift" > "$RUN_ROOT/$NAME-after.sha256" +rg 'Test Case|Executed|error:|failed|passed|TEST SUCCEEDED|TEST FAILED' "$RUN_ROOT/$NAME.log" || true +printf 'xcodebuild exit: %s; log: %s/%s.log\n' "$RESULT" "$RUN_ROOT" "$NAME" +exit "$RESULT"