feat(recommendation): 추천 서브 배너와 연속 순환을 추가한다
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserProfileChannelDonationView.swift; sourceTree = "<group>"; };
|
||||
24266E32FC49D7C31610C2AD /* BannerCarousel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BannerCarousel.swift; sourceTree = "<group>"; };
|
||||
BA2900013010000000A5BBA3 /* SubBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubBanner.swift; sourceTree = "<group>"; };
|
||||
BA2900023010000000A5BBA3 /* SubBannerLinkOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubBannerLinkOpener.swift; sourceTree = "<group>"; };
|
||||
BA2900033010000000A5BBA3 /* SubBannerCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubBannerCarousel.swift; sourceTree = "<group>"; };
|
||||
25C5D06E4F54C0ED7B7BD462 /* CreatorChannelFanTalkViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkViewModel.swift; sourceTree = "<group>"; };
|
||||
25C5D06F4F54C0ED7B7BD462 /* CreatorChannelFanTalkWriteViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkWriteViewModel.swift; sourceTree = "<group>"; };
|
||||
25C5D0704F54C0ED7B7BD462 /* CreatorChannelFanTalkWriteView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkWriteView.swift; sourceTree = "<group>"; };
|
||||
@@ -3388,6 +3397,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
24266E32FC49D7C31610C2AD /* BannerCarousel.swift */,
|
||||
BA2900013010000000A5BBA3 /* SubBanner.swift */,
|
||||
BA2900023010000000A5BBA3 /* SubBannerLinkOpener.swift */,
|
||||
BA2900033010000000A5BBA3 /* SubBannerCarousel.swift */,
|
||||
);
|
||||
path = Banner;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Drag: Gesture>: 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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
+6
@@ -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 &&
|
||||
|
||||
+3
-1
@@ -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)"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+5
@@ -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 {
|
||||
|
||||
+6
-1
@@ -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)"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Response, MoyaError>)!
|
||||
}
|
||||
public final class MoyaProvider<T: TargetType> { public init() {} }
|
||||
#elseif SB_COMBINE_MOYA_DOUBLE
|
||||
import Moya
|
||||
extension MoyaProvider {
|
||||
public func requestPublisher(_ target: T) -> AnyPublisher<Response, MoyaError> {
|
||||
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<Response, MoyaError> {
|
||||
fatalError("Unrelated community request must not execute")
|
||||
}
|
||||
func purchaseCommunityPost(postId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
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<Response, MoyaError>().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"),
|
||||
("<html>bad</html>", 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<Response, MoyaError>()
|
||||
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<HomeRecommendationResponse>.self, from: Data(payload(home: true, items: mixed).utf8))
|
||||
let content = try! JSONDecoder().decode(ApiResponse<AudioRecommendationsResponse>.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<AudioRecommendationsResponse>.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<HomeRecommendationResponse>.self, from: Data(json.utf8))) == nil, "Home subBanners required")
|
||||
} else {
|
||||
expect((try? JSONDecoder().decode(ApiResponse<AudioRecommendationsResponse>.self, from: Data(json.utf8))) == nil, "Content subBanners required")
|
||||
}
|
||||
}
|
||||
let subject = PassthroughSubject<Response, MoyaError>()
|
||||
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
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" "$@"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user