feat(recommendation): 추천 서브 배너와 연속 순환을 추가한다

This commit is contained in:
Yu Sung
2026-10-30 12:19:00 +09:00
parent 88f923072c
commit 5dcd0f0104
18 changed files with 1045 additions and 2 deletions
+260
View File
@@ -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
+203
View File
@@ -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 }
}
}
}
}
}
+169
View File
@@ -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)
}
}
+44
View File
@@ -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"